diff --git a/surfsense_backend/app/agents/new_chat/chat_deepagent.py b/surfsense_backend/app/agents/new_chat/chat_deepagent.py index d58a0fadb..619282daf 100644 --- a/surfsense_backend/app/agents/new_chat/chat_deepagent.py +++ b/surfsense_backend/app/agents/new_chat/chat_deepagent.py @@ -243,11 +243,20 @@ async def create_surfsense_deep_agent( "available_document_types": available_document_types, } + # Disable Notion action tools if no Notion connector is configured + modified_disabled_tools = list(disabled_tools) if disabled_tools else [] + has_notion_connector = ( + available_connectors is not None and "NOTION_CONNECTOR" in available_connectors + ) + if not has_notion_connector: + notion_tools = ["create_notion_page", "update_notion_page", "delete_notion_page"] + modified_disabled_tools.extend(notion_tools) + # Build tools using the async registry (includes MCP tools) tools = await build_tools_async( dependencies=dependencies, enabled_tools=enabled_tools, - disabled_tools=disabled_tools, + disabled_tools=modified_disabled_tools, additional_tools=list(additional_tools) if additional_tools else None, ) diff --git a/surfsense_backend/app/agents/new_chat/tools/notion/__init__.py b/surfsense_backend/app/agents/new_chat/tools/notion/__init__.py new file mode 100644 index 000000000..6ce825dca --- /dev/null +++ b/surfsense_backend/app/agents/new_chat/tools/notion/__init__.py @@ -0,0 +1,11 @@ +"""Notion tools for creating, updating, and deleting pages.""" + +from .create_page import create_create_notion_page_tool +from .delete_page import create_delete_notion_page_tool +from .update_page import create_update_notion_page_tool + +__all__ = [ + "create_create_notion_page_tool", + "create_delete_notion_page_tool", + "create_update_notion_page_tool", +] diff --git a/surfsense_backend/app/agents/new_chat/tools/notion/create_page.py b/surfsense_backend/app/agents/new_chat/tools/notion/create_page.py new file mode 100644 index 000000000..1a09fb355 --- /dev/null +++ b/surfsense_backend/app/agents/new_chat/tools/notion/create_page.py @@ -0,0 +1,210 @@ +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.notion_history import NotionHistoryConnector +from app.services.notion import NotionToolMetadataService + +logger = logging.getLogger(__name__) + + +def create_create_notion_page_tool( + db_session: AsyncSession | None = None, + search_space_id: int | None = None, + user_id: str | None = None, + connector_id: int | None = None, +): + """ + Factory function to create the create_notion_page tool. + + Args: + db_session: Database session for accessing Notion connector + search_space_id: Search space ID to find the Notion connector + user_id: User ID for fetching user-specific context + connector_id: Optional specific connector ID (if known) + + Returns: + Configured create_notion_page tool + """ + + @tool + async def create_notion_page( + title: str, + content: str, + parent_page_id: str | None = None, + ) -> dict[str, Any]: + """Create a new page in Notion with the given title and content. + + Use this tool when the user asks you to create, save, or publish + something to Notion. The page will be created in the user's + configured Notion workspace. + + Args: + title: The title of the Notion page. + content: The markdown content for the page body (supports headings, lists, paragraphs). + parent_page_id: Optional parent page ID to create as a subpage. + If not provided, will ask for one. + + Returns: + Dictionary with: + - status: "success", "rejected", or "error" + - page_id: Created page ID (if success) + - url: URL to the created page (if success) + - title: Page title (if success) + - message: Result message + + IMPORTANT: If status is "rejected", the user explicitly declined the action. + Respond with a brief acknowledgment (e.g., "Understood, I didn't create the page.") + and move on. Do NOT ask for parent page IDs, troubleshoot, or suggest alternatives. + + Examples: + - "Create a Notion page titled 'Meeting Notes' with content 'Discussed project timeline'" + - "Save this to Notion with title 'Research Summary'" + """ + logger.info(f"create_notion_page called: title='{title}', parent_page_id={parent_page_id}") + + if db_session is None or search_space_id is None or user_id is None: + logger.error("Notion tool not properly configured - missing required parameters") + return { + "status": "error", + "message": "Notion tool not properly configured. Please contact support.", + } + + try: + metadata_service = NotionToolMetadataService(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"], + } + + logger.info(f"Requesting approval for creating Notion page: '{title}'") + approval = interrupt({ + "type": "notion_page_creation", + "action": { + "tool": "create_notion_page", + "params": { + "title": title, + "content": content, + "parent_page_id": parent_page_id, + "connector_id": connector_id, + }, + }, + "context": context, + }) + + decisions = approval.get("decisions", []) + if not decisions: + logger.warning("No approval decision received") + 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": + logger.info("Notion page creation rejected by user") + return { + "status": "rejected", + "message": "User declined. The page was not created. Do not ask again or suggest alternatives.", + } + + edited_action = decision.get("edited_action", {}) + final_params = edited_action.get("args", {}) if edited_action else {} + + final_title = final_params.get("title", title) + final_content = final_params.get("content", content) + final_parent_page_id = final_params.get("parent_page_id", parent_page_id) + final_connector_id = final_params.get("connector_id", connector_id) + + if not final_title or not final_title.strip(): + logger.error("Title is empty or contains only whitespace") + return { + "status": "error", + "message": "Page title cannot be empty. Please provide a valid title.", + } + + logger.info(f"Creating Notion page with final params: title='{final_title}'") + + from sqlalchemy.future import select + + from app.db import SearchSourceConnector, SearchSourceConnectorType + + actual_connector_id = final_connector_id + if actual_connector_id is None: + result = await db_session.execute( + select(SearchSourceConnector).filter( + SearchSourceConnector.search_space_id == search_space_id, + SearchSourceConnector.user_id == user_id, + SearchSourceConnector.connector_type + == SearchSourceConnectorType.NOTION_CONNECTOR, + ) + ) + connector = result.scalars().first() + + if not connector: + logger.warning(f"No Notion connector found for search_space_id={search_space_id}") + return { + "status": "error", + "message": "No Notion connector found. Please connect Notion in your workspace settings.", + } + + actual_connector_id = connector.id + logger.info(f"Found Notion connector: id={actual_connector_id}") + else: + result = await db_session.execute( + select(SearchSourceConnector).filter( + SearchSourceConnector.id == actual_connector_id, + SearchSourceConnector.search_space_id == search_space_id, + SearchSourceConnector.user_id == user_id, + SearchSourceConnector.connector_type + == SearchSourceConnectorType.NOTION_CONNECTOR, + ) + ) + connector = result.scalars().first() + + if not connector: + logger.error( + f"Invalid connector_id={actual_connector_id} for search_space_id={search_space_id}" + ) + return { + "status": "error", + "message": "Selected Notion account is invalid or has been disconnected. Please select a valid account.", + } + logger.info(f"Validated Notion connector: id={actual_connector_id}") + + notion_connector = NotionHistoryConnector( + session=db_session, + connector_id=actual_connector_id, + ) + + result = await notion_connector.create_page( + title=final_title, + content=final_content, + parent_page_id=final_parent_page_id, + ) + logger.info(f"create_page result: {result.get('status')} - {result.get('message', '')}") + return result + + except Exception as e: + from langgraph.errors import GraphInterrupt + + if isinstance(e, GraphInterrupt): + raise + + logger.error(f"Error creating Notion page: {e}", exc_info=True) + return { + "status": "error", + "message": str(e) if isinstance(e, ValueError) else f"Unexpected error: {e!s}", + } + + return create_notion_page diff --git a/surfsense_backend/app/agents/new_chat/tools/notion/delete_page.py b/surfsense_backend/app/agents/new_chat/tools/notion/delete_page.py new file mode 100644 index 000000000..4e992d87c --- /dev/null +++ b/surfsense_backend/app/agents/new_chat/tools/notion/delete_page.py @@ -0,0 +1,238 @@ +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.notion_history import NotionHistoryConnector +from app.services.notion.tool_metadata_service import NotionToolMetadataService + +logger = logging.getLogger(__name__) + + +def create_delete_notion_page_tool( + db_session: AsyncSession | None = None, + search_space_id: int | None = None, + user_id: str | None = None, + connector_id: int | None = None, +): + """ + Factory function to create the delete_notion_page tool. + + Args: + db_session: Database session for accessing Notion connector + search_space_id: Search space ID to find the Notion connector + user_id: User ID for finding the correct Notion connector + connector_id: Optional specific connector ID (if known) + + Returns: + Configured delete_notion_page tool + """ + + @tool + async def delete_notion_page( + page_title: str, + delete_from_db: bool = False, + ) -> dict[str, Any]: + """Delete (archive) a Notion page. + + Use this tool when the user asks you to delete, remove, or archive + a Notion page. Note that Notion doesn't permanently delete pages, + it archives them (they can be restored from trash). + + Args: + page_title: The title of the Notion page to delete. + delete_from_db: Whether to also remove the page from the knowledge base. + Default is False (in Notion). + Set to True to permanently remove from both Notion and knowledge base. + + Returns: + Dictionary with: + - status: "success", "rejected", "not_found", or "error" + - page_id: Deleted page ID (if success) + - message: Success or error message + - deleted_from_db: Whether the page was also removed from knowledge base (if success) + + Examples: + - "Delete the 'Meeting Notes' Notion page" + - "Remove the 'Old Project Plan' Notion page" + - "Archive the 'Draft Ideas' Notion page" + """ + logger.info(f"delete_notion_page called: page_title='{page_title}', delete_from_db={delete_from_db}") + + if db_session is None or search_space_id is None or user_id is None: + logger.error("Notion tool not properly configured - missing required parameters") + return { + "status": "error", + "message": "Notion tool not properly configured. Please contact support.", + } + + try: + # Get page context (page_id, account, title) from indexed data + metadata_service = NotionToolMetadataService(db_session) + context = await metadata_service.get_delete_context( + search_space_id, user_id, page_title + ) + + if "error" in context: + error_msg = context["error"] + # Check if it's a "not found" error (softer handling for LLM) + if "not found" in error_msg.lower(): + logger.warning(f"Page not found: {error_msg}") + return { + "status": "not_found", + "message": error_msg, + } + else: + logger.error(f"Failed to fetch delete context: {error_msg}") + return { + "status": "error", + "message": error_msg, + } + + page_id = context.get("page_id") + connector_id_from_context = context.get("account", {}).get("id") + document_id = context.get("document_id") + + logger.info(f"Requesting approval for deleting Notion page: '{page_title}' (page_id={page_id}, delete_from_db={delete_from_db})") + + # Request approval before deleting + approval = interrupt( + { + "type": "notion_page_deletion", + "action": { + "tool": "delete_notion_page", + "params": { + "page_id": page_id, + "connector_id": connector_id_from_context, + "delete_from_db": delete_from_db, + }, + }, + "context": context, + } + ) + + decisions = approval.get("decisions", []) + if not decisions: + logger.warning("No approval decision received") + 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": + logger.info("Notion page deletion rejected by user") + return { + "status": "rejected", + "message": "User declined. The page was not deleted. Do not ask again or suggest alternatives.", + } + + # Extract edited action arguments (if user modified the checkbox) + edited_action = decision.get("edited_action", {}) + final_params = edited_action.get("args", {}) if edited_action else {} + + final_page_id = final_params.get("page_id", page_id) + final_connector_id = final_params.get("connector_id", connector_id_from_context) + final_delete_from_db = final_params.get("delete_from_db", delete_from_db) + + logger.info(f"Deleting Notion page with final params: page_id={final_page_id}, connector_id={final_connector_id}, delete_from_db={final_delete_from_db}") + + from sqlalchemy.future import select + + from app.db import SearchSourceConnector, SearchSourceConnectorType + + # Validate the connector + if final_connector_id: + 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.NOTION_CONNECTOR, + ) + ) + connector = result.scalars().first() + + if not connector: + logger.error( + f"Invalid connector_id={final_connector_id} for search_space_id={search_space_id}" + ) + return { + "status": "error", + "message": "Selected Notion account is invalid or has been disconnected. Please select a valid account.", + } + actual_connector_id = connector.id + logger.info(f"Validated Notion connector: id={actual_connector_id}") + else: + logger.error("No connector found for this page") + return { + "status": "error", + "message": "No connector found for this page.", + } + + # Create connector instance + notion_connector = NotionHistoryConnector( + session=db_session, + connector_id=actual_connector_id, + ) + + # Delete the page from Notion + result = await notion_connector.delete_page(page_id=final_page_id) + logger.info(f"delete_page result: {result.get('status')} - {result.get('message', '')}") + + # If deletion was successful and user wants to delete from DB + deleted_from_db = False + if result.get("status") == "success" and final_delete_from_db and document_id: + try: + from sqlalchemy.future import select + + from app.db import Document + + # Get the document + doc_result = await db_session.execute( + select(Document).filter(Document.id == document_id) + ) + document = doc_result.scalars().first() + + if document: + await db_session.delete(document) + await db_session.commit() + deleted_from_db = True + logger.info(f"Deleted document {document_id} from knowledge base") + else: + logger.warning(f"Document {document_id} not found in DB") + except Exception as e: + logger.error(f"Failed to delete document from DB: {e}") + # Don't fail the whole operation if DB deletion fails + # The page is already deleted from Notion, so inform the user + result["warning"] = f"Page deleted from Notion, but failed to remove from knowledge base: {e!s}" + + # Update result with DB deletion status + if result.get("status") == "success": + result["deleted_from_db"] = deleted_from_db + if deleted_from_db: + result["message"] = f"{result.get('message', '')} (also removed from knowledge base)" + + return result + + except Exception as e: + from langgraph.errors import GraphInterrupt + + if isinstance(e, GraphInterrupt): + raise + + logger.error(f"Error deleting Notion page: {e}", exc_info=True) + return { + "status": "error", + "message": str(e) + if isinstance(e, ValueError) + else f"Unexpected error: {e!s}", + } + + return delete_notion_page diff --git a/surfsense_backend/app/agents/new_chat/tools/notion/update_page.py b/surfsense_backend/app/agents/new_chat/tools/notion/update_page.py new file mode 100644 index 000000000..5e948e458 --- /dev/null +++ b/surfsense_backend/app/agents/new_chat/tools/notion/update_page.py @@ -0,0 +1,212 @@ +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.notion_history import NotionHistoryConnector +from app.services.notion import NotionToolMetadataService + +logger = logging.getLogger(__name__) + + +def create_update_notion_page_tool( + db_session: AsyncSession | None = None, + search_space_id: int | None = None, + user_id: str | None = None, + connector_id: int | None = None, +): + """ + Factory function to create the update_notion_page tool. + + Args: + db_session: Database session for accessing Notion connector + search_space_id: Search space ID to find the Notion connector + user_id: User ID for fetching user-specific context + connector_id: Optional specific connector ID (if known) + + Returns: + Configured update_notion_page tool + """ + + @tool + async def update_notion_page( + page_title: str, + content: str, + ) -> dict[str, Any]: + """Update an existing Notion page by appending new content. + + Use this tool when the user asks you to add content to, modify, or update + a Notion page. The new content will be appended to the existing page content. + + Args: + page_title: The title of the Notion page to update. + content: The markdown content to append to the page body (supports headings, lists, paragraphs). + + Returns: + Dictionary with: + - status: "success", "rejected", "not_found", or "error" + - page_id: Updated page ID (if success) + - url: URL to the updated page (if success) + - title: Current page title (if success) + - message: Result message + + IMPORTANT: + - If status is "rejected", the user explicitly declined the action. + Respond with a brief acknowledgment (e.g., "Understood, I didn't update the page.") + and move on. Do NOT ask for alternatives or troubleshoot. + - If status is "not_found", inform the user conversationally using the exact message provided. + Example: "I couldn't find the page '[page_title]' in your indexed Notion pages. [message details]" + Do NOT treat this as an error. Do NOT invent information. Simply relay the message and + ask the user to verify the page title or check if it's been indexed. + + Examples: + - "Add 'New meeting notes from today' to the 'Meeting Notes' Notion page" + - "Append the following to the 'Project Plan' Notion page: '# Status Update\n\nCompleted phase 1'" + """ + logger.info(f"update_notion_page called: page_title='{page_title}', content_length={len(content) if content else 0}") + + if db_session is None or search_space_id is None or user_id is None: + logger.error("Notion tool not properly configured - missing required parameters") + return { + "status": "error", + "message": "Notion tool not properly configured. Please contact support.", + } + + if not content or not content.strip(): + logger.error(f"Empty content provided for page '{page_title}'") + return { + "status": "error", + "message": "Content is required to update the page. Please provide the actual content you want to add.", + } + + try: + metadata_service = NotionToolMetadataService(db_session) + context = await metadata_service.get_update_context( + search_space_id, user_id, page_title + ) + + if "error" in context: + error_msg = context["error"] + # Check if it's a "not found" error (softer handling for LLM) + if "not found" in error_msg.lower(): + logger.warning(f"Page not found: {error_msg}") + return { + "status": "not_found", + "message": error_msg, + } + else: + logger.error(f"Failed to fetch update context: {error_msg}") + return { + "status": "error", + "message": error_msg, + } + + page_id = context.get("page_id") + connector_id_from_context = context.get("account", {}).get("id") + + logger.info(f"Requesting approval for updating Notion page: '{page_title}' (page_id={page_id})") + approval = interrupt( + { + "type": "notion_page_update", + "action": { + "tool": "update_notion_page", + "params": { + "page_id": page_id, + "content": content, + "connector_id": connector_id_from_context, + }, + }, + "context": context, + } + ) + + decisions = approval.get("decisions", []) + if not decisions: + logger.warning("No approval decision received") + 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": + logger.info("Notion page update rejected by user") + return { + "status": "rejected", + "message": "User declined. The page was not updated. Do not ask again or suggest alternatives.", + } + + edited_action = decision.get("edited_action", {}) + final_params = edited_action.get("args", {}) if edited_action else {} + + final_page_id = final_params.get("page_id", page_id) + final_content = final_params.get("content", content) + final_connector_id = final_params.get("connector_id", connector_id_from_context) + + logger.info(f"Updating Notion page with final params: page_id={final_page_id}, has_content={final_content is not None}") + + from sqlalchemy.future import select + + from app.db import SearchSourceConnector, SearchSourceConnectorType + + if final_connector_id: + 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.NOTION_CONNECTOR, + ) + ) + connector = result.scalars().first() + + if not connector: + logger.error( + f"Invalid connector_id={final_connector_id} for search_space_id={search_space_id}" + ) + return { + "status": "error", + "message": "Selected Notion account is invalid or has been disconnected. Please select a valid account.", + } + actual_connector_id = connector.id + logger.info(f"Validated Notion connector: id={actual_connector_id}") + else: + logger.error("No connector found for this page") + return { + "status": "error", + "message": "No connector found for this page.", + } + + notion_connector = NotionHistoryConnector( + session=db_session, + connector_id=actual_connector_id, + ) + + result = await notion_connector.update_page( + page_id=final_page_id, + content=final_content, + ) + logger.info(f"update_page result: {result.get('status')} - {result.get('message', '')}") + return result + + except Exception as e: + from langgraph.errors import GraphInterrupt + + if isinstance(e, GraphInterrupt): + raise + + logger.error(f"Error updating Notion page: {e}", exc_info=True) + return { + "status": "error", + "message": str(e) + if isinstance(e, ValueError) + else f"Unexpected error: {e!s}", + } + + return update_notion_page diff --git a/surfsense_backend/app/agents/new_chat/tools/registry.py b/surfsense_backend/app/agents/new_chat/tools/registry.py index 275b674ec..5d63e178f 100644 --- a/surfsense_backend/app/agents/new_chat/tools/registry.py +++ b/surfsense_backend/app/agents/new_chat/tools/registry.py @@ -50,6 +50,11 @@ from .generate_image import create_generate_image_tool from .knowledge_base import create_search_knowledge_base_tool from .link_preview import create_link_preview_tool from .mcp_tool import load_mcp_tools +from .notion import ( + create_create_notion_page_tool, + create_delete_notion_page_tool, + create_update_notion_page_tool, +) from .podcast import create_generate_podcast_tool from .report import create_generate_report_tool from .scrape_webpage import create_scrape_webpage_tool @@ -212,15 +217,38 @@ BUILTIN_TOOLS: list[ToolDefinition] = [ requires=["user_id", "search_space_id", "db_session", "thread_visibility"], ), # ========================================================================= - # ADD YOUR CUSTOM TOOLS BELOW + # NOTION TOOLS - create, update, delete pages # ========================================================================= - # Example: - # ToolDefinition( - # name="my_custom_tool", - # description="What my tool does", - # factory=lambda deps: create_my_custom_tool(...), - # requires=["search_space_id"], - # ), + ToolDefinition( + name="create_notion_page", + description="Create a new page in the user's Notion workspace", + factory=lambda deps: create_create_notion_page_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="update_notion_page", + description="Append new content to an existing Notion page", + factory=lambda deps: create_update_notion_page_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="delete_notion_page", + description="Delete an existing Notion page", + factory=lambda deps: create_delete_notion_page_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"], + ), ] diff --git a/surfsense_backend/app/connectors/notion_history.py b/surfsense_backend/app/connectors/notion_history.py index 525b0b4c3..6b2dff586 100644 --- a/surfsense_backend/app/connectors/notion_history.py +++ b/surfsense_backend/app/connectors/notion_history.py @@ -1,5 +1,6 @@ import asyncio import logging +import re from collections.abc import Awaitable, Callable from typing import Any, TypeVar @@ -10,7 +11,6 @@ from sqlalchemy.future import select from app.config import config from app.db import SearchSourceConnector -from app.routes.notion_add_connector_route import refresh_notion_token from app.schemas.notion_auth_credentials import NotionAuthCredentialsBase from app.utils.oauth_security import TokenEncryption @@ -219,6 +219,7 @@ class NotionHistoryConnector: ) # Refresh token + from app.routes.notion_add_connector_route import refresh_notion_token connector = await refresh_notion_token(self._session, connector) # Reload credentials after refresh @@ -777,3 +778,356 @@ class NotionHistoryConnector: # Return empty string for unsupported block types return "" + + # ========================================================================= + # WRITE OPERATIONS (create, update, delete pages) + # ========================================================================= + + async def _get_first_accessible_parent(self) -> str | None: + """ + Get the first accessible page ID that can be used as a parent. + + Returns: + Page ID string, or None if no accessible pages found + """ + try: + notion = await self._get_client() + + # Search for pages, get most recently edited first + response = await self._api_call_with_retry( + notion.search, + filter={"property": "object", "value": "page"}, + sort={"direction": "descending", "timestamp": "last_edited_time"}, + page_size=1, # We only need the first one + ) + + results = response.get("results", []) + if results: + return results[0]["id"] + + return None + + except Exception as e: + logger.error(f"Error finding accessible parent page: {e}") + return None + + def _markdown_to_blocks(self, markdown: str) -> list[dict[str, Any]]: + """ + Convert markdown content to Notion blocks. + + This is a simple converter that handles basic markdown. + For more complex markdown, consider using a proper markdown parser. + + Args: + markdown: Markdown content + + Returns: + List of Notion block objects + """ + blocks = [] + lines = markdown.split("\n") + + for line in lines: + line = line.strip() + + if not line: + continue + + # Heading 1 + if line.startswith("# "): + blocks.append({ + "object": "block", + "type": "heading_1", + "heading_1": { + "rich_text": [{"type": "text", "text": {"content": line[2:]}}] + }, + }) + # Heading 2 + elif line.startswith("## "): + blocks.append({ + "object": "block", + "type": "heading_2", + "heading_2": { + "rich_text": [{"type": "text", "text": {"content": line[3:]}}] + }, + }) + # Heading 3 + elif line.startswith("### "): + blocks.append({ + "object": "block", + "type": "heading_3", + "heading_3": { + "rich_text": [{"type": "text", "text": {"content": line[4:]}}] + }, + }) + # Bullet list + elif line.startswith("- ") or line.startswith("* "): + blocks.append({ + "object": "block", + "type": "bulleted_list_item", + "bulleted_list_item": { + "rich_text": [{"type": "text", "text": {"content": line[2:]}}] + }, + }) + # Numbered list + elif (match := re.match(r'^(\d+)\.\s+(.*)$', line)): + content = match.group(2) # Extract text after "number. " + blocks.append({ + "object": "block", + "type": "numbered_list_item", + "numbered_list_item": { + "rich_text": [{"type": "text", "text": {"content": content}}] + }, + }) + # Regular paragraph + else: + blocks.append({ + "object": "block", + "type": "paragraph", + "paragraph": { + "rich_text": [{"type": "text", "text": {"content": line}}] + }, + }) + + return blocks + + async def create_page( + self, title: str, content: str, parent_page_id: str | None = None + ) -> dict[str, Any]: + """ + Create a new Notion page. + + Args: + title: Page title + content: Page content (markdown format) + parent_page_id: Optional parent page ID (creates as subpage if provided) + + Returns: + Dictionary with page details: + - page_id: Created page ID + - url: Page URL + - title: Page title + - status: "success" or "error" + - message: Success/error message + + Raises: + APIResponseError: If Notion API returns an error + """ + try: + logger.info(f"Creating Notion page: title='{title}', parent_page_id={parent_page_id}") + + # Get Notion client + notion = await self._get_client() + + # Convert markdown content to Notion blocks + children = self._markdown_to_blocks(content) + + # Prepare parent - find first available page if not provided + if not parent_page_id: + logger.info("No parent_page_id provided, searching for first accessible page...") + parent_page_id = await self._get_first_accessible_parent() + if not parent_page_id: + logger.warning("No accessible parent pages found") + return { + "status": "error", + "message": "Could not find any accessible Notion pages to use as parent. " + "Please make sure your Notion integration has access to at least one page.", + } + logger.info(f"Using parent_page_id: {parent_page_id}") + + parent = {"type": "page_id", "page_id": parent_page_id} + + # Create the page with standard title property + properties = { + "title": { + "title": [{"type": "text", "text": {"content": title}}] + } + } + + response = await self._api_call_with_retry( + notion.pages.create, + parent=parent, + properties=properties, + children=children[:100], # Notion API limit: 100 blocks per request + ) + + page_id = response["id"] + page_url = response["url"] + + # If content has more than 100 blocks, append them + if len(children) > 100: + for i in range(100, len(children), 100): + batch = children[i : i + 100] + await self._api_call_with_retry( + notion.blocks.children.append, + block_id=page_id, + children=batch + ) + + return { + "status": "success", + "page_id": page_id, + "url": page_url, + "title": title, + "message": f"Created Notion page '{title}'", + } + + except APIResponseError as e: + logger.error(f"Notion API error creating page: {e}") + error_msg = e.body.get("message", str(e)) if hasattr(e, "body") else str(e) + return { + "status": "error", + "message": f"Failed to create Notion page: {error_msg}", + } + except Exception as e: + logger.error(f"Unexpected error creating Notion page: {e}") + return { + "status": "error", + "message": f"Failed to create Notion page: {e!s}", + } + + async def update_page( + self, page_id: str, content: str | None = None + ) -> dict[str, Any]: + """ + Update an existing Notion page by appending new content. + + Note: Content is appended to the page, not replaced. + + Args: + page_id: Page ID to update + content: New markdown content to append to the page (optional) + + Returns: + Dictionary with update result + + Raises: + APIResponseError: If Notion API returns an error + """ + try: + notion = await self._get_client() + + # Append content if provided + if content: + # Convert new content to blocks + try: + children = self._markdown_to_blocks(content) + if not children: + logger.warning("No blocks generated from content, skipping append") + return { + "status": "error", + "message": "Content conversion failed: no valid blocks generated", + } + except Exception as e: + logger.error(f"Failed to convert markdown to blocks: {e}") + return { + "status": "error", + "message": f"Failed to parse content: {e!s}", + } + + # Append new content blocks + try: + for i in range(0, len(children), 100): + batch = children[i : i + 100] + await self._api_call_with_retry( + notion.blocks.children.append, + block_id=page_id, + children=batch + ) + logger.info(f"Successfully appended {len(children)} new blocks to page {page_id}") + except Exception as e: + logger.error(f"Failed to append content blocks: {e}") + return { + "status": "error", + "message": f"Failed to append content: {e!s}", + } + + # Get updated page info + response = await self._api_call_with_retry( + notion.pages.retrieve, + page_id=page_id + ) + page_url = response["url"] + page_title = response["properties"]["title"]["title"][0]["text"]["content"] + + return { + "status": "success", + "page_id": page_id, + "url": page_url, + "title": page_title, + "message": f"Updated Notion page '{page_title}' (content appended)", + } + + except APIResponseError as e: + logger.error(f"Notion API error updating page: {e}") + error_msg = e.body.get("message", str(e)) if hasattr(e, "body") else str(e) + return { + "status": "error", + "message": f"Failed to update Notion page: {error_msg}", + } + except Exception as e: + logger.error(f"Unexpected error updating Notion page: {e}") + return { + "status": "error", + "message": f"Failed to update Notion page: {e!s}", + } + + async def delete_page(self, page_id: str) -> dict[str, Any]: + """ + Delete (archive) a Notion page. + + Note: Notion doesn't truly delete pages, it archives them. + + Args: + page_id: Page ID to delete + + Returns: + Dictionary with deletion result + + Raises: + APIResponseError: If Notion API returns an error + """ + try: + notion = await self._get_client() + + # Archive the page (Notion's way of "deleting") + response = await self._api_call_with_retry( + notion.pages.update, + page_id=page_id, + archived=True + ) + + page_title = "Unknown" + try: + page_title = response["properties"]["title"]["title"][0]["text"][ + "content" + ] + except (KeyError, IndexError): + pass + + return { + "status": "success", + "page_id": page_id, + "message": f"Deleted Notion page '{page_title}'", + } + + except APIResponseError as e: + logger.error(f"Notion API error deleting page: {e}") + # Handle both dict and string body formats + if hasattr(e, "body"): + if isinstance(e.body, dict): + error_msg = e.body.get("message", str(e)) + else: + error_msg = str(e.body) if e.body else str(e) + else: + error_msg = str(e) + return { + "status": "error", + "message": f"Failed to delete Notion page: {error_msg}", + } + except Exception as e: + logger.error(f"Unexpected error deleting Notion page: {e}") + return { + "status": "error", + "message": f"Failed to delete Notion page: {e!s}", + } diff --git a/surfsense_backend/app/routes/new_chat_routes.py b/surfsense_backend/app/routes/new_chat_routes.py index ecb1c2a6f..53e6c8e09 100644 --- a/surfsense_backend/app/routes/new_chat_routes.py +++ b/surfsense_backend/app/routes/new_chat_routes.py @@ -43,11 +43,12 @@ from app.schemas.new_chat import ( PublicChatSnapshotCreateResponse, PublicChatSnapshotListResponse, RegenerateRequest, + ResumeRequest, ThreadHistoryLoadResponse, ThreadListItem, ThreadListResponse, ) -from app.tasks.chat.stream_new_chat import stream_new_chat +from app.tasks.chat.stream_new_chat import stream_new_chat, stream_resume_chat from app.users import current_active_user from app.utils.rbac import check_permission @@ -1326,3 +1327,78 @@ async def regenerate_response( status_code=500, detail=f"An unexpected error occurred during regeneration: {e!s}", ) from None + + +# ============================================================================= +# Resume Interrupted Chat Endpoint +# ============================================================================= + + +@router.post("/threads/{thread_id}/resume") +async def resume_chat( + thread_id: int, + request: ResumeRequest, + session: AsyncSession = Depends(get_async_session), + user: User = Depends(current_active_user), +): + try: + result = await session.execute( + select(NewChatThread).filter(NewChatThread.id == thread_id) + ) + thread = result.scalars().first() + + if not thread: + raise HTTPException(status_code=404, detail="Thread not found") + + await check_permission( + session, + user, + thread.search_space_id, + Permission.CHATS_CREATE.value, + "You don't have permission to chat in this search space", + ) + + await check_thread_access(session, thread, user) + + search_space_result = await session.execute( + select(SearchSpace).filter(SearchSpace.id == request.search_space_id) + ) + search_space = search_space_result.scalars().first() + + if not search_space: + raise HTTPException(status_code=404, detail="Search space not found") + + llm_config_id = ( + search_space.agent_llm_id if search_space.agent_llm_id is not None else -1 + ) + + decisions = [d.model_dump() for d in request.decisions] + + return StreamingResponse( + stream_resume_chat( + chat_id=thread_id, + search_space_id=request.search_space_id, + decisions=decisions, + session=session, + user_id=str(user.id), + llm_config_id=llm_config_id, + thread_visibility=thread.visibility, + ), + media_type="text/event-stream", + headers={ + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "X-Accel-Buffering": "no", + }, + ) + + except HTTPException: + raise + except Exception as e: + import traceback + + traceback.print_exc() + raise HTTPException( + status_code=500, + detail=f"An unexpected error occurred during resume: {e!s}", + ) from None diff --git a/surfsense_backend/app/schemas/new_chat.py b/surfsense_backend/app/schemas/new_chat.py index efa314979..916f42024 100644 --- a/surfsense_backend/app/schemas/new_chat.py +++ b/surfsense_backend/app/schemas/new_chat.py @@ -7,7 +7,7 @@ These schemas follow the assistant-ui ThreadHistoryAdapter pattern: """ from datetime import datetime -from typing import Any +from typing import Any, Literal from uuid import UUID from pydantic import BaseModel, ConfigDict, Field @@ -193,6 +193,16 @@ class RegenerateRequest(BaseModel): mentioned_surfsense_doc_ids: list[int] | None = None +class ResumeDecision(BaseModel): + type: Literal["approve", "edit", "reject"] + edited_action: dict[str, Any] | None = None + + +class ResumeRequest(BaseModel): + search_space_id: int + decisions: list[ResumeDecision] + + # ============================================================================= # Public Chat Snapshot Schemas # ============================================================================= diff --git a/surfsense_backend/app/services/new_streaming_service.py b/surfsense_backend/app/services/new_streaming_service.py index 57fbc9663..4bb5530ce 100644 --- a/surfsense_backend/app/services/new_streaming_service.py +++ b/surfsense_backend/app/services/new_streaming_service.py @@ -504,6 +504,61 @@ class VercelStreamingService: }, ) + def format_interrupt_request(self, interrupt_value: dict[str, Any]) -> str: + """Format an interrupt request for human-in-the-loop approval. + + Args: + interrupt_value: The interrupt payload from either: + - interrupt_on config: {action_requests: [...], review_configs: [...]} + - interrupt() primitive: {type: "...", message: "...", action: {...}, context: {...}} + + Returns: + str: SSE formatted interrupt request data part + """ + normalized_payload = self._normalize_interrupt_payload(interrupt_value) + return self.format_data("interrupt-request", normalized_payload) + + def _normalize_interrupt_payload(self, interrupt_value: dict[str, Any]) -> dict[str, Any]: + """Normalize interrupt payloads from different sources into a consistent format. + + Handles two interrupt sources: + 1. interrupt_on config (Deep Agent built-in): Already has action_requests/review_configs + 2. interrupt() primitive (custom tool code): Has type/action/context (message is optional) + + Args: + interrupt_value: Raw interrupt payload from Deep Agent + + Returns: + dict: Normalized payload with action_requests, review_configs, and optional context/message + """ + if "action_requests" in interrupt_value and "review_configs" in interrupt_value: + return interrupt_value + + interrupt_type = interrupt_value.get("type", "unknown") + message = interrupt_value.get("message") + action = interrupt_value.get("action", {}) + context = interrupt_value.get("context", {}) + + normalized = { + "action_requests": [ + { + "name": action.get("tool", "unknown_tool"), + "args": action.get("params", {}), + } + ], + "review_configs": [ + { + "action_name": action.get("tool", "unknown_tool"), + "allowed_decisions": ["approve", "edit", "reject"], + } + ], + "interrupt_type": interrupt_type, + "context": context, + } + if message: + normalized["message"] = message + return normalized + # ========================================================================= # Error Part # ========================================================================= diff --git a/surfsense_backend/app/services/notion/__init__.py b/surfsense_backend/app/services/notion/__init__.py new file mode 100644 index 000000000..3818d1af3 --- /dev/null +++ b/surfsense_backend/app/services/notion/__init__.py @@ -0,0 +1,11 @@ +from app.services.notion.tool_metadata_service import ( + NotionAccount, + NotionPage, + NotionToolMetadataService, +) + +__all__ = [ + "NotionAccount", + "NotionPage", + "NotionToolMetadataService", +] diff --git a/surfsense_backend/app/services/notion/tool_metadata_service.py b/surfsense_backend/app/services/notion/tool_metadata_service.py new file mode 100644 index 000000000..c3caed358 --- /dev/null +++ b/surfsense_backend/app/services/notion/tool_metadata_service.py @@ -0,0 +1,200 @@ +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 NotionAccount: + id: int + name: str + workspace_id: str | None + workspace_name: str + workspace_icon: str + + @classmethod + def from_connector(cls, connector: SearchSourceConnector) -> "NotionAccount": + return cls( + id=connector.id, + name=connector.name, + workspace_id=connector.config.get("workspace_id"), + workspace_name=connector.config.get("workspace_name", "Unnamed Workspace"), + workspace_icon=connector.config.get("workspace_icon", "📄"), + ) + + def to_dict(self) -> dict: + return { + "id": self.id, + "name": self.name, + "workspace_id": self.workspace_id, + "workspace_name": self.workspace_name, + "workspace_icon": self.workspace_icon, + } + + +@dataclass +class NotionPage: + page_id: str + title: str + connector_id: int + document_id: int + + @classmethod + def from_document(cls, document: Document) -> "NotionPage": + return cls( + page_id=document.document_metadata.get("page_id"), + title=document.title, + connector_id=document.connector_id, + document_id=document.id, + ) + + def to_dict(self) -> dict: + return { + "page_id": self.page_id, + "title": self.title, + "connector_id": self.connector_id, + "document_id": self.document_id, + } + + +class NotionToolMetadataService: + 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_notion_accounts(search_space_id, user_id) + + if not accounts: + return { + "accounts": [], + "parent_pages": {}, + "error": "No Notion accounts connected", + } + + parent_pages = await self._get_parent_pages_by_account( + search_space_id, accounts + ) + + return { + "accounts": [acc.to_dict() for acc in accounts], + "parent_pages": parent_pages, + } + + async def get_update_context( + self, search_space_id: int, user_id: str, page_title: 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.NOTION_CONNECTOR, + func.lower(Document.title) == func.lower(page_title), + SearchSourceConnector.user_id == user_id, + ) + ) + ) + document = result.scalars().first() + + if not document: + return { + "error": f"Page '{page_title}' not found in your indexed Notion pages. " + "This could mean: (1) the page doesn't exist, (2) it hasn't been indexed yet, " + "or (3) the page title is different. Please check the exact page title in Notion." + } + + 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 = NotionAccount.from_connector(connector) + + page_id = document.document_metadata.get("page_id") + if not page_id: + return {"error": "Page ID not found in document metadata"} + + return { + "account": account.to_dict(), + "page_id": page_id, + "current_title": document.title, + "document_id": document.id, + "indexed_at": document.document_metadata.get("indexed_at"), + } + + async def get_delete_context( + self, search_space_id: int, user_id: str, page_title: str + ) -> dict: + return await self.get_update_context(search_space_id, user_id, page_title) + + async def _get_notion_accounts( + self, search_space_id: int, user_id: str + ) -> list[NotionAccount]: + 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.NOTION_CONNECTOR, + ) + ) + .order_by(SearchSourceConnector.last_indexed_at.desc()) + ) + connectors = result.scalars().all() + return [NotionAccount.from_connector(conn) for conn in connectors] + + async def _get_parent_pages_by_account( + self, search_space_id: int, accounts: list[NotionAccount] + ) -> dict: + parent_pages = {} + + for account in accounts: + result = await self._db_session.execute( + select(Document) + .filter( + and_( + Document.search_space_id == search_space_id, + Document.connector_id == account.id, + Document.document_type == DocumentType.NOTION_CONNECTOR, + ) + ) + .order_by(Document.updated_at.desc()) + .limit(50) + ) + documents = result.scalars().all() + + parent_pages[account.id] = [ + { + "page_id": doc.document_metadata.get("page_id"), + "title": doc.title, + "document_id": doc.id, + } + for doc in documents + if doc.document_metadata.get("page_id") + ] + + return parent_pages diff --git a/surfsense_backend/app/tasks/chat/stream_new_chat.py b/surfsense_backend/app/tasks/chat/stream_new_chat.py index 636a7413b..204bbb54a 100644 --- a/surfsense_backend/app/tasks/chat/stream_new_chat.py +++ b/surfsense_backend/app/tasks/chat/stream_new_chat.py @@ -11,6 +11,8 @@ Supports loading LLM configurations from: import json from collections.abc import AsyncGenerator +from dataclasses import dataclass +from typing import Any from uuid import UUID from langchain_core.messages import HumanMessage @@ -178,6 +180,555 @@ def extract_todos_from_deepagents(command_output) -> dict: return {"todos": todos_data} +@dataclass +class StreamResult: + accumulated_text: str = "" + is_interrupted: bool = False + interrupt_value: dict[str, Any] | None = None + + +async def _stream_agent_events( + agent: Any, + config: dict[str, Any], + input_data: Any, + streaming_service: VercelStreamingService, + result: StreamResult, + step_prefix: str = "thinking", + initial_step_id: str | None = None, + initial_step_title: str = "", + initial_step_items: list[str] | None = None, +) -> AsyncGenerator[str, None]: + """Shared async generator that streams and formats astream_events from the agent. + + Yields SSE-formatted strings. After exhausting, inspect the ``result`` + object for accumulated_text and interrupt state. + + Args: + agent: The compiled LangGraph agent. + config: LangGraph config dict (must include configurable.thread_id). + input_data: The input to pass to agent.astream_events (dict or Command). + streaming_service: VercelStreamingService instance for formatting events. + result: Mutable StreamResult populated with accumulated_text / interrupt info. + step_prefix: Prefix for thinking step IDs (e.g. "thinking" or "thinking-resume"). + initial_step_id: If set, the helper inherits an already-active thinking step. + initial_step_title: Title of the inherited thinking step. + initial_step_items: Items of the inherited thinking step. + + Yields: + SSE-formatted strings for each event. + """ + accumulated_text = "" + current_text_id: str | None = None + thinking_step_counter = 1 if initial_step_id else 0 + tool_step_ids: dict[str, str] = {} + completed_step_ids: set[str] = set() + last_active_step_id: str | None = initial_step_id + last_active_step_title: str = initial_step_title + last_active_step_items: list[str] = initial_step_items or [] + just_finished_tool: bool = False + + def next_thinking_step_id() -> str: + nonlocal thinking_step_counter + thinking_step_counter += 1 + return f"{step_prefix}-{thinking_step_counter}" + + def complete_current_step() -> str | None: + nonlocal last_active_step_id + if last_active_step_id and last_active_step_id not in completed_step_ids: + completed_step_ids.add(last_active_step_id) + event = streaming_service.format_thinking_step( + step_id=last_active_step_id, + title=last_active_step_title, + status="completed", + items=last_active_step_items if last_active_step_items else None, + ) + last_active_step_id = None + return event + return None + + async for event in agent.astream_events(input_data, config=config, version="v2"): + event_type = event.get("event", "") + + if event_type == "on_chat_model_stream": + chunk = event.get("data", {}).get("chunk") + if chunk and hasattr(chunk, "content"): + content = chunk.content + if content and isinstance(content, str): + if current_text_id is None: + completion_event = complete_current_step() + if completion_event: + yield completion_event + if just_finished_tool: + last_active_step_id = None + last_active_step_title = "" + last_active_step_items = [] + just_finished_tool = False + current_text_id = streaming_service.generate_text_id() + yield streaming_service.format_text_start(current_text_id) + yield streaming_service.format_text_delta(current_text_id, content) + accumulated_text += content + + elif event_type == "on_tool_start": + tool_name = event.get("name", "unknown_tool") + run_id = event.get("run_id", "") + tool_input = event.get("data", {}).get("input", {}) + + if current_text_id is not None: + yield streaming_service.format_text_end(current_text_id) + current_text_id = None + + if last_active_step_title != "Synthesizing response": + completion_event = complete_current_step() + if completion_event: + yield completion_event + + just_finished_tool = False + tool_step_id = next_thinking_step_id() + tool_step_ids[run_id] = tool_step_id + last_active_step_id = tool_step_id + + if tool_name == "search_knowledge_base": + query = ( + tool_input.get("query", "") + if isinstance(tool_input, dict) + else str(tool_input) + ) + last_active_step_title = "Searching knowledge base" + last_active_step_items = [ + f"Query: {query[:100]}{'...' if len(query) > 100 else ''}" + ] + yield streaming_service.format_thinking_step( + step_id=tool_step_id, + title="Searching knowledge base", + status="in_progress", + items=last_active_step_items, + ) + elif tool_name == "link_preview": + url = ( + tool_input.get("url", "") + if isinstance(tool_input, dict) + else str(tool_input) + ) + last_active_step_title = "Fetching link preview" + last_active_step_items = [ + f"URL: {url[:80]}{'...' if len(url) > 80 else ''}" + ] + yield streaming_service.format_thinking_step( + step_id=tool_step_id, + title="Fetching link preview", + status="in_progress", + items=last_active_step_items, + ) + elif tool_name == "display_image": + src = ( + tool_input.get("src", "") + if isinstance(tool_input, dict) + else str(tool_input) + ) + title = ( + tool_input.get("title", "") if isinstance(tool_input, dict) else "" + ) + last_active_step_title = "Analyzing the image" + last_active_step_items = [ + f"Analyzing: {title[:50] if title else src[:50]}{'...' if len(title or src) > 50 else ''}" + ] + yield streaming_service.format_thinking_step( + step_id=tool_step_id, + title="Analyzing the image", + status="in_progress", + items=last_active_step_items, + ) + elif tool_name == "scrape_webpage": + url = ( + tool_input.get("url", "") + if isinstance(tool_input, dict) + else str(tool_input) + ) + last_active_step_title = "Scraping webpage" + last_active_step_items = [ + f"URL: {url[:80]}{'...' if len(url) > 80 else ''}" + ] + yield streaming_service.format_thinking_step( + step_id=tool_step_id, + title="Scraping webpage", + status="in_progress", + items=last_active_step_items, + ) + elif tool_name == "generate_podcast": + podcast_title = ( + tool_input.get("podcast_title", "SurfSense Podcast") + if isinstance(tool_input, dict) + else "SurfSense Podcast" + ) + content_len = len( + tool_input.get("source_content", "") + if isinstance(tool_input, dict) + else "" + ) + last_active_step_title = "Generating podcast" + last_active_step_items = [ + f"Title: {podcast_title}", + f"Content: {content_len:,} characters", + "Preparing audio generation...", + ] + yield streaming_service.format_thinking_step( + step_id=tool_step_id, + title="Generating podcast", + status="in_progress", + items=last_active_step_items, + ) + else: + last_active_step_title = f"Using {tool_name.replace('_', ' ')}" + last_active_step_items = [] + yield streaming_service.format_thinking_step( + step_id=tool_step_id, + title=last_active_step_title, + status="in_progress", + ) + + tool_call_id = ( + f"call_{run_id[:32]}" + if run_id + else streaming_service.generate_tool_call_id() + ) + yield streaming_service.format_tool_input_start(tool_call_id, tool_name) + yield streaming_service.format_tool_input_available( + tool_call_id, + tool_name, + tool_input if isinstance(tool_input, dict) else {"input": tool_input}, + ) + + elif event_type == "on_tool_end": + run_id = event.get("run_id", "") + tool_name = event.get("name", "unknown_tool") + raw_output = event.get("data", {}).get("output", "") + + if hasattr(raw_output, "content"): + content = raw_output.content + if isinstance(content, str): + try: + tool_output = json.loads(content) + except (json.JSONDecodeError, TypeError): + tool_output = {"result": content} + elif isinstance(content, dict): + tool_output = content + else: + tool_output = {"result": str(content)} + elif isinstance(raw_output, dict): + tool_output = raw_output + else: + tool_output = {"result": str(raw_output) if raw_output else "completed"} + + tool_call_id = f"call_{run_id[:32]}" if run_id else "call_unknown" + original_step_id = tool_step_ids.get( + run_id, f"{step_prefix}-unknown-{run_id[:8]}" + ) + completed_step_ids.add(original_step_id) + + if tool_name == "search_knowledge_base": + result_info = "Search completed" + if isinstance(tool_output, dict): + result_len = tool_output.get("result_length", 0) + if result_len > 0: + result_info = f"Found relevant information ({result_len} chars)" + completed_items = [*last_active_step_items, result_info] + yield streaming_service.format_thinking_step( + step_id=original_step_id, + title="Searching knowledge base", + status="completed", + items=completed_items, + ) + elif tool_name == "link_preview": + if isinstance(tool_output, dict): + title = tool_output.get("title", "Link") + domain = tool_output.get("domain", "") + has_error = "error" in tool_output + if has_error: + completed_items = [ + *last_active_step_items, + f"Error: {tool_output.get('error', 'Failed to fetch')}", + ] + else: + completed_items = [ + *last_active_step_items, + f"Title: {title[:60]}{'...' if len(title) > 60 else ''}", + f"Domain: {domain}" if domain else "Preview loaded", + ] + else: + completed_items = [*last_active_step_items, "Preview loaded"] + yield streaming_service.format_thinking_step( + step_id=original_step_id, + title="Fetching link preview", + status="completed", + items=completed_items, + ) + elif tool_name == "display_image": + if isinstance(tool_output, dict): + title = tool_output.get("title", "") + alt = tool_output.get("alt", "Image") + display_name = title or alt + completed_items = [ + *last_active_step_items, + f"Analyzed: {display_name[:50]}{'...' if len(display_name) > 50 else ''}", + ] + else: + completed_items = [*last_active_step_items, "Image analyzed"] + yield streaming_service.format_thinking_step( + step_id=original_step_id, + title="Analyzing the image", + status="completed", + items=completed_items, + ) + elif tool_name == "scrape_webpage": + if isinstance(tool_output, dict): + title = tool_output.get("title", "Webpage") + word_count = tool_output.get("word_count", 0) + has_error = "error" in tool_output + if has_error: + completed_items = [ + *last_active_step_items, + f"Error: {tool_output.get('error', 'Failed to scrape')[:50]}", + ] + else: + completed_items = [ + *last_active_step_items, + f"Title: {title[:50]}{'...' if len(title) > 50 else ''}", + f"Extracted: {word_count:,} words", + ] + else: + completed_items = [*last_active_step_items, "Content extracted"] + yield streaming_service.format_thinking_step( + step_id=original_step_id, + title="Scraping webpage", + status="completed", + items=completed_items, + ) + elif tool_name == "generate_podcast": + podcast_status = ( + tool_output.get("status", "unknown") + if isinstance(tool_output, dict) + else "unknown" + ) + podcast_title = ( + tool_output.get("title", "Podcast") + if isinstance(tool_output, dict) + else "Podcast" + ) + if podcast_status == "processing": + completed_items = [ + f"Title: {podcast_title}", + "Audio generation started", + "Processing in background...", + ] + elif podcast_status == "already_generating": + completed_items = [ + f"Title: {podcast_title}", + "Podcast already in progress", + "Please wait for it to complete", + ] + elif podcast_status == "error": + error_msg = ( + tool_output.get("error", "Unknown error") + if isinstance(tool_output, dict) + else "Unknown error" + ) + completed_items = [ + f"Title: {podcast_title}", + f"Error: {error_msg[:50]}", + ] + else: + completed_items = last_active_step_items + yield streaming_service.format_thinking_step( + step_id=original_step_id, + title="Generating podcast", + status="completed", + items=completed_items, + ) + elif tool_name == "ls": + if isinstance(tool_output, dict): + ls_output = tool_output.get("result", "") + elif isinstance(tool_output, str): + ls_output = tool_output + else: + ls_output = str(tool_output) if tool_output else "" + file_names: list[str] = [] + if ls_output: + for line in ls_output.strip().split("\n"): + line = line.strip() + if line: + name = line.rstrip("/").split("/")[-1] + if name and len(name) <= 40: + file_names.append(name) + elif name: + file_names.append(name[:37] + "...") + if file_names: + if len(file_names) <= 5: + completed_items = [f"[{name}]" for name in file_names] + else: + completed_items = [f"[{name}]" for name in file_names[:4]] + completed_items.append(f"(+{len(file_names) - 4} more)") + else: + completed_items = ["No files found"] + yield streaming_service.format_thinking_step( + step_id=original_step_id, + title="Exploring files", + status="completed", + items=completed_items, + ) + else: + yield streaming_service.format_thinking_step( + step_id=original_step_id, + title=f"Using {tool_name.replace('_', ' ')}", + status="completed", + items=last_active_step_items, + ) + + just_finished_tool = True + last_active_step_id = None + last_active_step_title = "" + last_active_step_items = [] + + if tool_name == "generate_podcast": + yield streaming_service.format_tool_output_available( + tool_call_id, + tool_output + if isinstance(tool_output, dict) + else {"result": tool_output}, + ) + if ( + isinstance(tool_output, dict) + and tool_output.get("status") == "success" + ): + yield streaming_service.format_terminal_info( + f"Podcast generated successfully: {tool_output.get('title', 'Podcast')}", + "success", + ) + else: + error_msg = ( + tool_output.get("error", "Unknown error") + if isinstance(tool_output, dict) + else "Unknown error" + ) + yield streaming_service.format_terminal_info( + f"Podcast generation failed: {error_msg}", + "error", + ) + elif tool_name == "link_preview": + yield streaming_service.format_tool_output_available( + tool_call_id, + tool_output + if isinstance(tool_output, dict) + else {"result": tool_output}, + ) + if isinstance(tool_output, dict) and "error" not in tool_output: + title = tool_output.get("title", "Link") + yield streaming_service.format_terminal_info( + f"Link preview loaded: {title[:50]}{'...' if len(title) > 50 else ''}", + "success", + ) + else: + error_msg = ( + tool_output.get("error", "Failed to fetch") + if isinstance(tool_output, dict) + else "Failed to fetch" + ) + yield streaming_service.format_terminal_info( + f"Link preview failed: {error_msg}", + "error", + ) + elif tool_name == "display_image": + yield streaming_service.format_tool_output_available( + tool_call_id, + tool_output + if isinstance(tool_output, dict) + else {"result": tool_output}, + ) + if isinstance(tool_output, dict): + title = tool_output.get("title") or tool_output.get("alt", "Image") + yield streaming_service.format_terminal_info( + f"Image analyzed: {title[:40]}{'...' if len(title) > 40 else ''}", + "success", + ) + elif tool_name == "scrape_webpage": + if isinstance(tool_output, dict): + display_output = { + k: v for k, v in tool_output.items() if k != "content" + } + if "content" in tool_output: + content = tool_output.get("content", "") + display_output["content_preview"] = ( + content[:500] + "..." if len(content) > 500 else content + ) + yield streaming_service.format_tool_output_available( + tool_call_id, + display_output, + ) + else: + yield streaming_service.format_tool_output_available( + tool_call_id, + {"result": tool_output}, + ) + if isinstance(tool_output, dict) and "error" not in tool_output: + title = tool_output.get("title", "Webpage") + word_count = tool_output.get("word_count", 0) + yield streaming_service.format_terminal_info( + f"Scraped: {title[:40]}{'...' if len(title) > 40 else ''} ({word_count:,} words)", + "success", + ) + else: + error_msg = ( + tool_output.get("error", "Failed to scrape") + if isinstance(tool_output, dict) + else "Failed to scrape" + ) + yield streaming_service.format_terminal_info( + f"Scrape failed: {error_msg}", + "error", + ) + elif tool_name == "search_knowledge_base": + yield streaming_service.format_tool_output_available( + tool_call_id, + {"status": "completed", "result_length": len(str(tool_output))}, + ) + yield streaming_service.format_terminal_info( + "Knowledge base search completed", "success" + ) + elif tool_name in ("create_notion_page", "update_notion_page", "delete_notion_page"): + yield streaming_service.format_tool_output_available( + tool_call_id, + tool_output if isinstance(tool_output, dict) else {"result": tool_output}, + ) + else: + yield streaming_service.format_tool_output_available( + tool_call_id, + {"status": "completed", "result_length": len(str(tool_output))}, + ) + yield streaming_service.format_terminal_info( + f"Tool {tool_name} completed", "success" + ) + + elif event_type in ("on_chain_end", "on_agent_end"): + if current_text_id is not None: + yield streaming_service.format_text_end(current_text_id) + current_text_id = None + + if current_text_id is not None: + yield streaming_service.format_text_end(current_text_id) + + completion_event = complete_current_step() + if completion_event: + yield completion_event + + result.accumulated_text = accumulated_text + + state = await agent.aget_state(config) + is_interrupted = state.tasks and any(task.interrupts for task in state.tasks) + if is_interrupted: + result.is_interrupted = True + result.interrupt_value = state.tasks[0].interrupts[0].value + yield streaming_service.format_interrupt_request(result.interrupt_value) + + async def stream_new_chat( user_query: str, search_space_id: int, @@ -215,9 +766,6 @@ async def stream_new_chat( """ streaming_service = VercelStreamingService() - # Track the current text block for streaming (defined early for exception handling) - current_text_id: str | None = None - try: # Mark AI as responding to this user for live collaboration if user_id: @@ -394,63 +942,18 @@ async def stream_new_chat( yield streaming_service.format_message_start() yield streaming_service.format_start_step() - # Reset text tracking for this stream - accumulated_text = "" - - # Track thinking steps for chain-of-thought display - thinking_step_counter = 0 - # Map run_id -> step_id for tool calls so we can update them on completion - tool_step_ids: dict[str, str] = {} - # Track the last active step so we can mark it complete at the end - last_active_step_id: str | None = None - last_active_step_title: str = "" - last_active_step_items: list[str] = [] - # Track which steps have been completed to avoid duplicate completions - completed_step_ids: set[str] = set() - # Track if we just finished a tool (text flows silently after tools) - just_finished_tool: bool = False - # Track write_todos calls to show "Creating plan" vs "Updating plan" - # Disabled for now - # write_todos_call_count: int = 0 - - def next_thinking_step_id() -> str: - nonlocal thinking_step_counter - thinking_step_counter += 1 - return f"thinking-{thinking_step_counter}" - - def complete_current_step() -> str | None: - """Complete the current active step and return the completion event, if any.""" - nonlocal last_active_step_id, last_active_step_title, last_active_step_items - if last_active_step_id and last_active_step_id not in completed_step_ids: - completed_step_ids.add(last_active_step_id) - return streaming_service.format_thinking_step( - step_id=last_active_step_id, - title=last_active_step_title, - status="completed", - items=last_active_step_items if last_active_step_items else None, - ) - return None - # Initial thinking step - analyzing the request - analyze_step_id = next_thinking_step_id() - last_active_step_id = analyze_step_id - - # Determine step title and action verb based on context if mentioned_documents or mentioned_surfsense_docs: - last_active_step_title = "Analyzing referenced content" + initial_title = "Analyzing referenced content" action_verb = "Analyzing" else: - last_active_step_title = "Understanding your request" + initial_title = "Understanding your request" action_verb = "Processing" - # Build the message with inline context about referenced documents processing_parts = [] - - # Add the user query query_text = user_query[:80] + ("..." if len(user_query) > 80 else "") processing_parts.append(query_text) - # Add mentioned document names inline if mentioned_documents: doc_names = [] for doc in mentioned_documents: @@ -463,7 +966,6 @@ async def stream_new_chat( else: processing_parts.append(f"[{len(doc_names)} documents]") - # Add mentioned SurfSense docs inline if mentioned_surfsense_docs: doc_names = [] for doc in mentioned_surfsense_docs: @@ -476,804 +978,37 @@ async def stream_new_chat( else: processing_parts.append(f"[{len(doc_names)} docs]") - last_active_step_items = [f"{action_verb}: {' '.join(processing_parts)}"] + initial_items = [f"{action_verb}: {' '.join(processing_parts)}"] + initial_step_id = "thinking-1" yield streaming_service.format_thinking_step( - step_id=analyze_step_id, - title=last_active_step_title, + step_id=initial_step_id, + title=initial_title, status="in_progress", - items=last_active_step_items, + items=initial_items, ) - # Stream the agent response with thread config for memory - async for event in agent.astream_events( - input_state, config=config, version="v2" + stream_result = StreamResult() + async for sse in _stream_agent_events( + agent=agent, + config=config, + input_data=input_state, + streaming_service=streaming_service, + result=stream_result, + step_prefix="thinking", + initial_step_id=initial_step_id, + initial_step_title=initial_title, + initial_step_items=initial_items, ): - event_type = event.get("event", "") + yield sse - # Handle chat model stream events (text streaming) - if event_type == "on_chat_model_stream": - chunk = event.get("data", {}).get("chunk") - if chunk and hasattr(chunk, "content"): - content = chunk.content - if content and isinstance(content, str): - # Start a new text block if needed - if current_text_id is None: - # Complete any previous step - completion_event = complete_current_step() - if completion_event: - yield completion_event + if stream_result.is_interrupted: + yield streaming_service.format_finish_step() + yield streaming_service.format_finish() + yield streaming_service.format_done() + return - if just_finished_tool: - # Clear the active step tracking - text flows without a dedicated step - last_active_step_id = None - last_active_step_title = "" - last_active_step_items = [] - just_finished_tool = False - - current_text_id = streaming_service.generate_text_id() - yield streaming_service.format_text_start(current_text_id) - - # Stream the text delta - yield streaming_service.format_text_delta( - current_text_id, content - ) - accumulated_text += content - - # Handle tool calls - elif event_type == "on_tool_start": - tool_name = event.get("name", "unknown_tool") - run_id = event.get("run_id", "") - tool_input = event.get("data", {}).get("input", {}) - - # End current text block if any - if current_text_id is not None: - yield streaming_service.format_text_end(current_text_id) - current_text_id = None - - # Complete any previous step EXCEPT "Synthesizing response" - # (we want to reuse the Synthesizing step after tools complete) - if last_active_step_title != "Synthesizing response": - completion_event = complete_current_step() - if completion_event: - yield completion_event - - # Reset the just_finished_tool flag since we're starting a new tool - just_finished_tool = False - - # Create thinking step for the tool call and store it for later update - tool_step_id = next_thinking_step_id() - tool_step_ids[run_id] = tool_step_id - last_active_step_id = tool_step_id - if tool_name == "search_knowledge_base": - query = ( - tool_input.get("query", "") - if isinstance(tool_input, dict) - else str(tool_input) - ) - last_active_step_title = "Searching knowledge base" - last_active_step_items = [ - f"Query: {query[:100]}{'...' if len(query) > 100 else ''}" - ] - yield streaming_service.format_thinking_step( - step_id=tool_step_id, - title="Searching knowledge base", - status="in_progress", - items=last_active_step_items, - ) - elif tool_name == "link_preview": - url = ( - tool_input.get("url", "") - if isinstance(tool_input, dict) - else str(tool_input) - ) - last_active_step_title = "Fetching link preview" - last_active_step_items = [ - f"URL: {url[:80]}{'...' if len(url) > 80 else ''}" - ] - yield streaming_service.format_thinking_step( - step_id=tool_step_id, - title="Fetching link preview", - status="in_progress", - items=last_active_step_items, - ) - elif tool_name == "display_image": - src = ( - tool_input.get("src", "") - if isinstance(tool_input, dict) - else str(tool_input) - ) - title = ( - tool_input.get("title", "") - if isinstance(tool_input, dict) - else "" - ) - last_active_step_title = "Analyzing the image" - last_active_step_items = [ - f"Analyzing: {title[:50] if title else src[:50]}{'...' if len(title or src) > 50 else ''}" - ] - yield streaming_service.format_thinking_step( - step_id=tool_step_id, - title="Analyzing the image", - status="in_progress", - items=last_active_step_items, - ) - elif tool_name == "scrape_webpage": - url = ( - tool_input.get("url", "") - if isinstance(tool_input, dict) - else str(tool_input) - ) - last_active_step_title = "Scraping webpage" - last_active_step_items = [ - f"URL: {url[:80]}{'...' if len(url) > 80 else ''}" - ] - yield streaming_service.format_thinking_step( - step_id=tool_step_id, - title="Scraping webpage", - status="in_progress", - items=last_active_step_items, - ) - # elif tool_name == "write_todos": # Disabled for now - # # Track write_todos calls for better messaging - # write_todos_call_count += 1 - # todos = ( - # tool_input.get("todos", []) - # if isinstance(tool_input, dict) - # else [] - # ) - # todo_count = len(todos) if isinstance(todos, list) else 0 - - # if write_todos_call_count == 1: - # # First call - creating the plan - # last_active_step_title = "Creating plan" - # last_active_step_items = [f"Defining {todo_count} tasks..."] - # else: - # # Subsequent calls - updating the plan - # # Try to provide context about what's being updated - # in_progress_count = ( - # sum( - # 1 - # for t in todos - # if isinstance(t, dict) - # and t.get("status") == "in_progress" - # ) - # if isinstance(todos, list) - # else 0 - # ) - # completed_count = ( - # sum( - # 1 - # for t in todos - # if isinstance(t, dict) - # and t.get("status") == "completed" - # ) - # if isinstance(todos, list) - # else 0 - # ) - - # last_active_step_title = "Updating progress" - # last_active_step_items = ( - # [ - # f"Progress: {completed_count}/{todo_count} completed", - # f"In progress: {in_progress_count} tasks", - # ] - # if completed_count > 0 - # else [f"Working on {todo_count} tasks"] - # ) - - # yield streaming_service.format_thinking_step( - # step_id=tool_step_id, - # title=last_active_step_title, - # status="in_progress", - # items=last_active_step_items, - # ) - elif tool_name == "generate_podcast": - podcast_title = ( - tool_input.get("podcast_title", "SurfSense Podcast") - if isinstance(tool_input, dict) - else "SurfSense Podcast" - ) - # Get content length for context - content_len = len( - tool_input.get("source_content", "") - if isinstance(tool_input, dict) - else "" - ) - last_active_step_title = "Generating podcast" - last_active_step_items = [ - f"Title: {podcast_title}", - f"Content: {content_len:,} characters", - "Preparing audio generation...", - ] - yield streaming_service.format_thinking_step( - step_id=tool_step_id, - title="Generating podcast", - status="in_progress", - items=last_active_step_items, - ) - elif tool_name == "generate_report": - report_topic = ( - tool_input.get("topic", "Report") - if isinstance(tool_input, dict) - else "Report" - ) - report_style = ( - tool_input.get("report_style", "detailed") - if isinstance(tool_input, dict) - else "detailed" - ) - content_len = len( - tool_input.get("source_content", "") - if isinstance(tool_input, dict) - else "" - ) - last_active_step_title = "Generating report" - last_active_step_items = [ - f"Topic: {report_topic}", - f"Style: {report_style}", - f"Source content: {content_len:,} characters", - "Generating report with LLM...", - ] - yield streaming_service.format_thinking_step( - step_id=tool_step_id, - title="Generating report", - status="in_progress", - items=last_active_step_items, - ) - # elif tool_name == "ls": - # last_active_step_title = "Exploring files" - # last_active_step_items = [] - # yield streaming_service.format_thinking_step( - # step_id=tool_step_id, - # title="Exploring files", - # status="in_progress", - # items=None, - # ) - else: - last_active_step_title = f"Using {tool_name.replace('_', ' ')}" - last_active_step_items = [] - yield streaming_service.format_thinking_step( - step_id=tool_step_id, - title=last_active_step_title, - status="in_progress", - ) - - # Stream tool info - tool_call_id = ( - f"call_{run_id[:32]}" - if run_id - else streaming_service.generate_tool_call_id() - ) - yield streaming_service.format_tool_input_start(tool_call_id, tool_name) - yield streaming_service.format_tool_input_available( - tool_call_id, - tool_name, - tool_input - if isinstance(tool_input, dict) - else {"input": tool_input}, - ) - - elif event_type == "on_tool_end": - run_id = event.get("run_id", "") - tool_name = event.get("name", "unknown_tool") - raw_output = event.get("data", {}).get("output", "") - - # Handle deepagents' write_todos Command object specially - # Disabled for now - # if tool_name == "write_todos" and hasattr(raw_output, "update"): - # # deepagents returns a Command object - extract todos directly - # tool_output = extract_todos_from_deepagents(raw_output) - # elif hasattr(raw_output, "content"): - if hasattr(raw_output, "content"): - # It's a ToolMessage object - extract the content - content = raw_output.content - # If content is a string that looks like JSON, try to parse it - if isinstance(content, str): - try: - tool_output = json.loads(content) - except (json.JSONDecodeError, TypeError): - tool_output = {"result": content} - elif isinstance(content, dict): - tool_output = content - else: - tool_output = {"result": str(content)} - elif isinstance(raw_output, dict): - tool_output = raw_output - else: - tool_output = { - "result": str(raw_output) if raw_output else "completed" - } - - tool_call_id = f"call_{run_id[:32]}" if run_id else "call_unknown" - - # Get the original tool step ID to update it (not create a new one) - original_step_id = tool_step_ids.get( - run_id, f"thinking-unknown-{run_id[:8]}" - ) - - # Mark the tool thinking step as completed using the SAME step ID - # Also add to completed set so we don't try to complete it again - completed_step_ids.add(original_step_id) - if tool_name == "search_knowledge_base": - # Get result count if available - result_info = "Search completed" - if isinstance(tool_output, dict): - result_len = tool_output.get("result_length", 0) - if result_len > 0: - result_info = ( - f"Found relevant information ({result_len} chars)" - ) - # Include original query in completed items - completed_items = [*last_active_step_items, result_info] - yield streaming_service.format_thinking_step( - step_id=original_step_id, - title="Searching knowledge base", - status="completed", - items=completed_items, - ) - elif tool_name == "link_preview": - # Build completion items based on link preview result - if isinstance(tool_output, dict): - title = tool_output.get("title", "Link") - domain = tool_output.get("domain", "") - has_error = "error" in tool_output - if has_error: - completed_items = [ - *last_active_step_items, - f"Error: {tool_output.get('error', 'Failed to fetch')}", - ] - else: - completed_items = [ - *last_active_step_items, - f"Title: {title[:60]}{'...' if len(title) > 60 else ''}", - f"Domain: {domain}" if domain else "Preview loaded", - ] - else: - completed_items = [*last_active_step_items, "Preview loaded"] - yield streaming_service.format_thinking_step( - step_id=original_step_id, - title="Fetching link preview", - status="completed", - items=completed_items, - ) - elif tool_name == "display_image": - # Build completion items for image analysis - if isinstance(tool_output, dict): - title = tool_output.get("title", "") - alt = tool_output.get("alt", "Image") - display_name = title or alt - completed_items = [ - *last_active_step_items, - f"Analyzed: {display_name[:50]}{'...' if len(display_name) > 50 else ''}", - ] - else: - completed_items = [*last_active_step_items, "Image analyzed"] - yield streaming_service.format_thinking_step( - step_id=original_step_id, - title="Analyzing the image", - status="completed", - items=completed_items, - ) - elif tool_name == "scrape_webpage": - # Build completion items for webpage scraping - if isinstance(tool_output, dict): - title = tool_output.get("title", "Webpage") - word_count = tool_output.get("word_count", 0) - has_error = "error" in tool_output - if has_error: - completed_items = [ - *last_active_step_items, - f"Error: {tool_output.get('error', 'Failed to scrape')[:50]}", - ] - else: - completed_items = [ - *last_active_step_items, - f"Title: {title[:50]}{'...' if len(title) > 50 else ''}", - f"Extracted: {word_count:,} words", - ] - else: - completed_items = [*last_active_step_items, "Content extracted"] - yield streaming_service.format_thinking_step( - step_id=original_step_id, - title="Scraping webpage", - status="completed", - items=completed_items, - ) - elif tool_name == "generate_podcast": - # Build detailed completion items based on podcast status - podcast_status = ( - tool_output.get("status", "unknown") - if isinstance(tool_output, dict) - else "unknown" - ) - podcast_title = ( - tool_output.get("title", "Podcast") - if isinstance(tool_output, dict) - else "Podcast" - ) - - if podcast_status == "processing": - completed_items = [ - f"Title: {podcast_title}", - "Audio generation started", - "Processing in background...", - ] - elif podcast_status == "already_generating": - completed_items = [ - f"Title: {podcast_title}", - "Podcast already in progress", - "Please wait for it to complete", - ] - elif podcast_status == "error": - error_msg = ( - tool_output.get("error", "Unknown error") - if isinstance(tool_output, dict) - else "Unknown error" - ) - completed_items = [ - f"Title: {podcast_title}", - f"Error: {error_msg[:50]}", - ] - else: - completed_items = last_active_step_items - - yield streaming_service.format_thinking_step( - step_id=original_step_id, - title="Generating podcast", - status="completed", - items=completed_items, - ) - elif tool_name == "generate_report": - # Build detailed completion items based on report status - report_status = ( - tool_output.get("status", "unknown") - if isinstance(tool_output, dict) - else "unknown" - ) - report_title = ( - tool_output.get("title", "Report") - if isinstance(tool_output, dict) - else "Report" - ) - word_count = ( - tool_output.get("word_count", 0) - if isinstance(tool_output, dict) - else 0 - ) - - if report_status == "ready": - completed_items = [ - f"Title: {report_title}", - f"Words: {word_count:,}", - "Report generated successfully", - ] - elif report_status == "failed": - error_msg = ( - tool_output.get("error", "Unknown error") - if isinstance(tool_output, dict) - else "Unknown error" - ) - completed_items = [ - f"Title: {report_title}", - f"Error: {error_msg[:50]}", - ] - else: - completed_items = last_active_step_items - - yield streaming_service.format_thinking_step( - step_id=original_step_id, - title="Generating report", - status="completed", - items=completed_items, - ) - # elif tool_name == "write_todos": # Disabled for now - # # Build completion items for planning/updating - # if isinstance(tool_output, dict): - # todos = tool_output.get("todos", []) - # todo_count = len(todos) if isinstance(todos, list) else 0 - # completed_count = ( - # sum( - # 1 - # for t in todos - # if isinstance(t, dict) - # and t.get("status") == "completed" - # ) - # if isinstance(todos, list) - # else 0 - # ) - # in_progress_count = ( - # sum( - # 1 - # for t in todos - # if isinstance(t, dict) - # and t.get("status") == "in_progress" - # ) - # if isinstance(todos, list) - # else 0 - # ) - - # # Use context-aware completion message - # if last_active_step_title == "Creating plan": - # completed_items = [f"Created {todo_count} tasks"] - # else: - # # Updating progress - show stats - # completed_items = [ - # f"Progress: {completed_count}/{todo_count} completed", - # ] - # if in_progress_count > 0: - # # Find the currently in-progress task name - # in_progress_task = next( - # ( - # t.get("content", "")[:40] - # for t in todos - # if isinstance(t, dict) - # and t.get("status") == "in_progress" - # ), - # None, - # ) - # if in_progress_task: - # completed_items.append( - # f"Current: {in_progress_task}..." - # ) - # else: - # completed_items = ["Plan updated"] - # yield streaming_service.format_thinking_step( - # step_id=original_step_id, - # title=last_active_step_title, - # status="completed", - # items=completed_items, - # ) - elif tool_name == "ls": - # Build completion items showing file names found - if isinstance(tool_output, dict): - result = tool_output.get("result", "") - elif isinstance(tool_output, str): - result = tool_output - else: - result = str(tool_output) if tool_output else "" - - # Parse file paths and extract just the file names - file_names = [] - if result: - # The ls tool returns paths, extract just the file/folder names - for line in result.strip().split("\n"): - line = line.strip() - if line: - # Get just the filename from the path - name = line.rstrip("/").split("/")[-1] - if name and len(name) <= 40: - file_names.append(name) - elif name: - file_names.append(name[:37] + "...") - - # Build display items - wrap file names in brackets for icon rendering - if file_names: - if len(file_names) <= 5: - # Wrap each file name in brackets for styled tile rendering - completed_items = [f"[{name}]" for name in file_names] - else: - # Show first few with brackets and count - completed_items = [f"[{name}]" for name in file_names[:4]] - completed_items.append(f"(+{len(file_names) - 4} more)") - else: - completed_items = ["No files found"] - - yield streaming_service.format_thinking_step( - step_id=original_step_id, - title="Exploring files", - status="completed", - items=completed_items, - ) - else: - yield streaming_service.format_thinking_step( - step_id=original_step_id, - title=f"Using {tool_name.replace('_', ' ')}", - status="completed", - items=last_active_step_items, - ) - - # Mark that we just finished a tool - "Synthesizing response" will be created - # when text actually starts flowing (not immediately) - just_finished_tool = True - # Clear the active step since the tool is done - last_active_step_id = None - last_active_step_title = "" - last_active_step_items = [] - - # Handle different tool outputs - if tool_name == "generate_podcast": - # Stream the full podcast result so frontend can render the audio player - yield streaming_service.format_tool_output_available( - tool_call_id, - tool_output - if isinstance(tool_output, dict) - else {"result": tool_output}, - ) - # Send appropriate terminal message based on status - if ( - isinstance(tool_output, dict) - and tool_output.get("status") == "success" - ): - yield streaming_service.format_terminal_info( - f"Podcast generated successfully: {tool_output.get('title', 'Podcast')}", - "success", - ) - else: - error_msg = ( - tool_output.get("error", "Unknown error") - if isinstance(tool_output, dict) - else "Unknown error" - ) - yield streaming_service.format_terminal_info( - f"Podcast generation failed: {error_msg}", - "error", - ) - elif tool_name == "generate_report": - # Stream the full report result so frontend can render the ReportViewer - yield streaming_service.format_tool_output_available( - tool_call_id, - tool_output - if isinstance(tool_output, dict) - else {"result": tool_output}, - ) - # Send appropriate terminal message based on status - if ( - isinstance(tool_output, dict) - and tool_output.get("status") == "ready" - ): - word_count = tool_output.get("word_count", 0) - yield streaming_service.format_terminal_info( - f"Report generated: {tool_output.get('title', 'Report')} ({word_count:,} words)", - "success", - ) - else: - error_msg = ( - tool_output.get("error", "Unknown error") - if isinstance(tool_output, dict) - else "Unknown error" - ) - yield streaming_service.format_terminal_info( - f"Report generation failed: {error_msg}", - "error", - ) - elif tool_name == "link_preview": - # Stream the full link preview result so frontend can render the MediaCard - yield streaming_service.format_tool_output_available( - tool_call_id, - tool_output - if isinstance(tool_output, dict) - else {"result": tool_output}, - ) - # Send appropriate terminal message - if isinstance(tool_output, dict) and "error" not in tool_output: - title = tool_output.get("title", "Link") - yield streaming_service.format_terminal_info( - f"Link preview loaded: {title[:50]}{'...' if len(title) > 50 else ''}", - "success", - ) - else: - error_msg = ( - tool_output.get("error", "Failed to fetch") - if isinstance(tool_output, dict) - else "Failed to fetch" - ) - yield streaming_service.format_terminal_info( - f"Link preview failed: {error_msg}", - "error", - ) - elif tool_name == "display_image": - # Stream the full image result so frontend can render the Image component - yield streaming_service.format_tool_output_available( - tool_call_id, - tool_output - if isinstance(tool_output, dict) - else {"result": tool_output}, - ) - # Send terminal message - if isinstance(tool_output, dict): - title = tool_output.get("title") or tool_output.get( - "alt", "Image" - ) - yield streaming_service.format_terminal_info( - f"Image analyzed: {title[:40]}{'...' if len(title) > 40 else ''}", - "success", - ) - elif tool_name == "scrape_webpage": - # Stream the scrape result so frontend can render the Article component - # Note: We send metadata for display, but content goes to LLM for processing - if isinstance(tool_output, dict): - # Create a display-friendly output (without full content for the card) - display_output = { - k: v for k, v in tool_output.items() if k != "content" - } - # But keep a truncated content preview - if "content" in tool_output: - content = tool_output.get("content", "") - display_output["content_preview"] = ( - content[:500] + "..." if len(content) > 500 else content - ) - yield streaming_service.format_tool_output_available( - tool_call_id, - display_output, - ) - else: - yield streaming_service.format_tool_output_available( - tool_call_id, - {"result": tool_output}, - ) - # Send terminal message - if isinstance(tool_output, dict) and "error" not in tool_output: - title = tool_output.get("title", "Webpage") - word_count = tool_output.get("word_count", 0) - yield streaming_service.format_terminal_info( - f"Scraped: {title[:40]}{'...' if len(title) > 40 else ''} ({word_count:,} words)", - "success", - ) - else: - error_msg = ( - tool_output.get("error", "Failed to scrape") - if isinstance(tool_output, dict) - else "Failed to scrape" - ) - yield streaming_service.format_terminal_info( - f"Scrape failed: {error_msg}", - "error", - ) - elif tool_name == "search_knowledge_base": - # Don't stream the full output for search (can be very large), just acknowledge - yield streaming_service.format_tool_output_available( - tool_call_id, - {"status": "completed", "result_length": len(str(tool_output))}, - ) - yield streaming_service.format_terminal_info( - "Knowledge base search completed", "success" - ) - # elif tool_name == "write_todos": # Disabled for now - # # Stream the full write_todos result so frontend can render the Plan component - # yield streaming_service.format_tool_output_available( - # tool_call_id, - # tool_output - # if isinstance(tool_output, dict) - # else {"result": tool_output}, - # ) - # # Send terminal message with plan info - # if isinstance(tool_output, dict): - # todos = tool_output.get("todos", []) - # todo_count = len(todos) if isinstance(todos, list) else 0 - # yield streaming_service.format_terminal_info( - # f"Plan created ({todo_count} tasks)", - # "success", - # ) - # else: - # yield streaming_service.format_terminal_info( - # "Plan created", - # "success", - # ) - else: - # Default handling for other tools - yield streaming_service.format_tool_output_available( - tool_call_id, - {"status": "completed", "result_length": len(str(tool_output))}, - ) - yield streaming_service.format_terminal_info( - f"Tool {tool_name} completed", "success" - ) - - # Handle chain/agent end to close any open text blocks - elif event_type in ("on_chain_end", "on_agent_end"): - if current_text_id is not None: - yield streaming_service.format_text_end(current_text_id) - current_text_id = None - - # Ensure text block is closed - if current_text_id is not None: - yield streaming_service.format_text_end(current_text_id) - - # Mark the last active thinking step as completed using the same title - completion_event = complete_current_step() - if completion_event: - yield completion_event + accumulated_text = stream_result.accumulated_text # Generate LLM title for new chats after first response # Check if this is the first assistant response by counting existing assistant messages @@ -1345,10 +1080,6 @@ async def stream_new_chat( print(f"[stream_new_chat] Exception type: {type(e).__name__}") print(f"[stream_new_chat] Traceback:\n{traceback.format_exc()}") - # Close any open text block - if current_text_id is not None: - yield streaming_service.format_text_end(current_text_id) - yield streaming_service.format_error(error_message) yield streaming_service.format_finish_step() yield streaming_service.format_finish() @@ -1357,3 +1088,120 @@ async def stream_new_chat( finally: # Clear AI responding state for live collaboration await clear_ai_responding(session, chat_id) + + +async def stream_resume_chat( + chat_id: int, + search_space_id: int, + decisions: list[dict], + session: AsyncSession, + user_id: str | None = None, + llm_config_id: int = -1, + thread_visibility: ChatVisibility | None = None, +) -> AsyncGenerator[str, None]: + streaming_service = VercelStreamingService() + + try: + if user_id: + await set_ai_responding(session, chat_id, UUID(user_id)) + + agent_config: AgentConfig | None = None + if llm_config_id >= 0: + agent_config = await load_agent_config( + session=session, + config_id=llm_config_id, + search_space_id=search_space_id, + ) + if not agent_config: + yield streaming_service.format_error( + f"Failed to load NewLLMConfig with id {llm_config_id}" + ) + yield streaming_service.format_done() + return + llm = create_chat_litellm_from_agent_config(agent_config) + else: + llm_config = load_llm_config_from_yaml(llm_config_id=llm_config_id) + if not llm_config: + yield streaming_service.format_error( + f"Failed to load LLM config with id {llm_config_id}" + ) + yield streaming_service.format_done() + return + llm = create_chat_litellm_from_config(llm_config) + agent_config = AgentConfig.from_yaml_config(llm_config) + + if not llm: + yield streaming_service.format_error("Failed to create LLM instance") + yield streaming_service.format_done() + return + + connector_service = ConnectorService(session, search_space_id=search_space_id) + + from app.db import SearchSourceConnectorType + + firecrawl_api_key = None + webcrawler_connector = await connector_service.get_connector_by_type( + SearchSourceConnectorType.WEBCRAWLER_CONNECTOR, search_space_id + ) + if webcrawler_connector and webcrawler_connector.config: + firecrawl_api_key = webcrawler_connector.config.get("FIRECRAWL_API_KEY") + + checkpointer = await get_checkpointer() + visibility = thread_visibility or ChatVisibility.PRIVATE + + agent = await create_surfsense_deep_agent( + llm=llm, + search_space_id=search_space_id, + db_session=session, + connector_service=connector_service, + checkpointer=checkpointer, + user_id=user_id, + thread_id=chat_id, + agent_config=agent_config, + firecrawl_api_key=firecrawl_api_key, + thread_visibility=visibility, + ) + + from langgraph.types import Command + + config = { + "configurable": {"thread_id": str(chat_id)}, + "recursion_limit": 80, + } + + yield streaming_service.format_message_start() + yield streaming_service.format_start_step() + + stream_result = StreamResult() + async for sse in _stream_agent_events( + agent=agent, + config=config, + input_data=Command(resume={"decisions": decisions}), + streaming_service=streaming_service, + result=stream_result, + step_prefix="thinking-resume", + ): + yield sse + if stream_result.is_interrupted: + yield streaming_service.format_finish_step() + yield streaming_service.format_finish() + yield streaming_service.format_done() + return + + yield streaming_service.format_finish_step() + yield streaming_service.format_finish() + yield streaming_service.format_done() + + except Exception as e: + import traceback + + error_message = f"Error during resume: {e!s}" + print(f"[stream_resume_chat] {error_message}") + print(f"[stream_resume_chat] Traceback:\n{traceback.format_exc()}") + yield streaming_service.format_error(error_message) + yield streaming_service.format_finish_step() + yield streaming_service.format_finish() + yield streaming_service.format_done() + + finally: + await clear_ai_responding(session, chat_id) 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 6b4a9bf8f..05947b6e8 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 @@ -33,13 +33,16 @@ import { membersAtom } from "@/atoms/members/members-query.atoms"; import { currentUserAtom } from "@/atoms/user/user-query.atoms"; import { Thread } from "@/components/assistant-ui/thread"; import { ChatHeader } from "@/components/new-chat/chat-header"; +import { CreateNotionPageToolUI } from "@/components/tool-ui/create-notion-page"; import { ReportPanel } from "@/components/report-panel/report-panel"; import type { ThinkingStep } from "@/components/tool-ui/deepagent-thinking"; +import { DeleteNotionPageToolUI } from "@/components/tool-ui/delete-notion-page"; 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 { LinkPreviewToolUI } from "@/components/tool-ui/link-preview"; import { ScrapeWebpageToolUI } from "@/components/tool-ui/scrape-webpage"; +import { UpdateNotionPageToolUI } from "@/components/tool-ui/update-notion-page"; import { RecallMemoryToolUI, SaveMemoryToolUI } from "@/components/tool-ui/user-memory"; import { Skeleton } from "@/components/ui/skeleton"; import { useChatSessionStateSync } from "@/hooks/use-chat-session-state"; @@ -53,6 +56,17 @@ import { looksLikePodcastRequest, setActivePodcastTaskId, } from "@/lib/chat/podcast-state"; +import { + addToolCall, + appendText, + buildContentForPersistence, + buildContentForUI, + type ContentPart, + type ContentPartsState, + readSSEStream, + type ThinkingStepData, + updateToolCall, +} from "@/lib/chat/streaming-state"; import { appendMessage, createThread, @@ -123,20 +137,13 @@ const TOOLS_WITH_UI = new Set([ "generate_report", "link_preview", "display_image", + "delete_notion_page", "scrape_webpage", + "create_notion_page", + "update_notion_page", // "write_todos", // Disabled for now ]); -/** - * Type for thinking step data from the backend - */ -interface ThinkingStepData { - id: string; - title: string; - status: "pending" | "in_progress" | "completed"; - items: string[]; -} - export default function NewChatPage() { const params = useParams(); const queryClient = useQueryClient(); @@ -151,6 +158,11 @@ export default function NewChatPage() { new Map() ); const abortControllerRef = useRef(null); + const [pendingInterrupt, setPendingInterrupt] = useState<{ + threadId: number; + assistantMsgId: string; + interruptData: Record; + } | null>(null); // Get mentioned document IDs from the composer const mentionedDocumentIds = useAtomValue(mentionedDocumentIdsAtom); @@ -385,6 +397,16 @@ export default function NewChatPage() { })); }, [currentThread, setCurrentThreadState]); + // Cleanup on unmount - abort any in-flight requests + useEffect(() => { + return () => { + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + abortControllerRef.current = null; + } + }; + }, []); + // Cancel ongoing request const cancelRun = useCallback(async () => { if (abortControllerRef.current) { @@ -540,101 +562,13 @@ export default function NewChatPage() { const assistantMsgId = `msg-assistant-${Date.now()}`; const currentThinkingSteps = new Map(); - // Ordered content parts to preserve inline tool call positions - // Each part is either a text segment or a tool call - type ContentPart = - | { type: "text"; text: string } - | { - type: "tool-call"; - toolCallId: string; - toolName: string; - args: Record; - result?: unknown; - }; - const contentParts: ContentPart[] = []; - - // Track the current text segment index (for appending text deltas) - let currentTextPartIndex = -1; - - // Map to track tool call indices for updating results - const toolCallIndices = new Map(); - - // Helper to get or create the current text part for appending text - const appendText = (delta: string) => { - if (currentTextPartIndex >= 0 && contentParts[currentTextPartIndex]?.type === "text") { - // Append to existing text part - (contentParts[currentTextPartIndex] as { type: "text"; text: string }).text += delta; - } else { - // Create new text part - contentParts.push({ type: "text", text: delta }); - currentTextPartIndex = contentParts.length - 1; - } - }; - - // Helper to add a tool call (this "breaks" the current text segment) - const addToolCall = (toolCallId: string, toolName: string, args: Record) => { - if (TOOLS_WITH_UI.has(toolName)) { - contentParts.push({ - type: "tool-call", - toolCallId, - toolName, - args, - }); - toolCallIndices.set(toolCallId, contentParts.length - 1); - // Reset text part index so next text creates a new segment - currentTextPartIndex = -1; - } - }; - - // Helper to update a tool call's args or result - const updateToolCall = ( - toolCallId: string, - update: { args?: Record; result?: unknown } - ) => { - const index = toolCallIndices.get(toolCallId); - if (index !== undefined && contentParts[index]?.type === "tool-call") { - const tc = contentParts[index] as ContentPart & { type: "tool-call" }; - if (update.args) tc.args = update.args; - if (update.result !== undefined) tc.result = update.result; - } - }; - - // Helper to build content for UI (without thinking-steps to avoid assistant-ui errors) - const buildContentForUI = (): ThreadMessageLike["content"] => { - // Filter to only include text parts with content and tool-calls with UI - const filtered = contentParts.filter((part) => { - if (part.type === "text") return part.text.length > 0; - if (part.type === "tool-call") return TOOLS_WITH_UI.has(part.toolName); - return false; - }); - return filtered.length > 0 - ? (filtered as ThreadMessageLike["content"]) - : [{ type: "text", text: "" }]; - }; - - // Helper to build content for persistence (includes thinking-steps for restoration) - const buildContentForPersistence = (): unknown[] => { - const parts: unknown[] = []; - - // Include thinking steps for persistence - if (currentThinkingSteps.size > 0) { - parts.push({ - type: "thinking-steps", - steps: Array.from(currentThinkingSteps.values()), - }); - } - - // Add content parts (filtered) - for (const part of contentParts) { - if (part.type === "text" && part.text.length > 0) { - parts.push(part); - } else if (part.type === "tool-call" && TOOLS_WITH_UI.has(part.toolName)) { - parts.push(part); - } - } - - return parts.length > 0 ? parts : [{ type: "text", text: "" }]; + const contentPartsState: ContentPartsState = { + contentParts: [], + currentTextPartIndex: -1, + toolCallIndices: new Map(), }; + const { contentParts, toolCallIndices } = contentPartsState; + let wasInterrupted = false; // Add placeholder assistant message setMessages((prev) => [ @@ -700,150 +634,172 @@ export default function NewChatPage() { throw new Error(`Backend error: ${response.status}`); } - if (!response.body) { - throw new Error("No response body"); - } + for await (const parsed of readSSEStream(response)) { + switch (parsed.type) { + case "text-delta": + appendText(contentPartsState, parsed.delta); + setMessages((prev) => + prev.map((m) => + m.id === assistantMsgId + ? { ...m, content: buildContentForUI(contentPartsState, TOOLS_WITH_UI) } + : m + ) + ); + break; - // Parse SSE stream - const reader = response.body.getReader(); - const decoder = new TextDecoder(); - let buffer = ""; + case "tool-input-start": + // Add tool call inline - this breaks the current text segment + addToolCall(contentPartsState, TOOLS_WITH_UI, parsed.toolCallId, parsed.toolName, {}); + setMessages((prev) => + prev.map((m) => + m.id === assistantMsgId + ? { ...m, content: buildContentForUI(contentPartsState, TOOLS_WITH_UI) } + : m + ) + ); + break; - try { - while (true) { - const { done, value } = await reader.read(); - if (done) break; + case "tool-input-available": { + // Update existing tool call's args, or add if not exists + if (toolCallIndices.has(parsed.toolCallId)) { + updateToolCall(contentPartsState, parsed.toolCallId, { args: parsed.input || {} }); + } else { + addToolCall( + contentPartsState, + TOOLS_WITH_UI, + parsed.toolCallId, + parsed.toolName, + parsed.input || {} + ); + } + setMessages((prev) => + prev.map((m) => + m.id === assistantMsgId + ? { ...m, content: buildContentForUI(contentPartsState, TOOLS_WITH_UI) } + : m + ) + ); + break; + } - buffer += decoder.decode(value, { stream: true }); - const events = buffer.split(/\r?\n\r?\n/); - buffer = events.pop() || ""; - - for (const event of events) { - const lines = event.split(/\r?\n/); - for (const line of lines) { - if (!line.startsWith("data: ")) continue; - const data = line.slice(6).trim(); - if (!data || data === "[DONE]") continue; - - try { - const parsed = JSON.parse(data); - - switch (parsed.type) { - case "text-delta": - appendText(parsed.delta); - setMessages((prev) => - prev.map((m) => - m.id === assistantMsgId ? { ...m, content: buildContentForUI() } : m - ) - ); - break; - - case "tool-input-start": - // Add tool call inline - this breaks the current text segment - addToolCall(parsed.toolCallId, parsed.toolName, {}); - setMessages((prev) => - prev.map((m) => - m.id === assistantMsgId ? { ...m, content: buildContentForUI() } : m - ) - ); - break; - - case "tool-input-available": { - // Update existing tool call's args, or add if not exists - if (toolCallIndices.has(parsed.toolCallId)) { - updateToolCall(parsed.toolCallId, { args: parsed.input || {} }); - } else { - addToolCall(parsed.toolCallId, parsed.toolName, parsed.input || {}); - } - setMessages((prev) => - prev.map((m) => - m.id === assistantMsgId ? { ...m, content: buildContentForUI() } : m - ) - ); - break; - } - - case "tool-output-available": { - // Update the tool call with its result - updateToolCall(parsed.toolCallId, { result: parsed.output }); - // Handle podcast-specific logic - if (parsed.output?.status === "pending" && parsed.output?.podcast_id) { - // Check if this is a podcast tool by looking at the content part - const idx = toolCallIndices.get(parsed.toolCallId); - if (idx !== undefined) { - const part = contentParts[idx]; - if (part?.type === "tool-call" && part.toolName === "generate_podcast") { - setActivePodcastTaskId(String(parsed.output.podcast_id)); - } - } - } - setMessages((prev) => - prev.map((m) => - m.id === assistantMsgId ? { ...m, content: buildContentForUI() } : m - ) - ); - break; - } - - case "data-thinking-step": { - // Handle thinking step events for chain-of-thought display - const stepData = parsed.data as ThinkingStepData; - if (stepData?.id) { - currentThinkingSteps.set(stepData.id, stepData); - // Update thinking steps state for rendering - // The ThinkingStepsScrollHandler in Thread component - // will handle auto-scrolling when this state changes - setMessageThinkingSteps((prev) => { - const newMap = new Map(prev); - newMap.set(assistantMsgId, Array.from(currentThinkingSteps.values())); - return newMap; - }); - } - break; - } - - case "data-thread-title-update": { - // Handle thread title update from LLM-generated title - const titleData = parsed.data as { threadId: number; title: string }; - if (titleData?.title && titleData?.threadId === currentThreadId) { - // Update current thread state with new title - setCurrentThread((prev) => - prev ? { ...prev, title: titleData.title } : prev - ); - // Invalidate thread list to refresh sidebar - queryClient.invalidateQueries({ - queryKey: ["threads", String(searchSpaceId)], - }); - // Invalidate thread detail for breadcrumb update - queryClient.invalidateQueries({ - queryKey: [ - "threads", - String(searchSpaceId), - "detail", - String(titleData.threadId), - ], - }); - } - break; - } - - case "error": - throw new Error(parsed.errorText || "Server error"); + case "tool-output-available": { + // Update the tool call with its result + updateToolCall(contentPartsState, parsed.toolCallId, { result: parsed.output }); + // Handle podcast-specific logic + if (parsed.output?.status === "pending" && parsed.output?.podcast_id) { + // Check if this is a podcast tool by looking at the content part + const idx = toolCallIndices.get(parsed.toolCallId); + if (idx !== undefined) { + const part = contentParts[idx]; + if (part?.type === "tool-call" && part.toolName === "generate_podcast") { + setActivePodcastTaskId(String(parsed.output.podcast_id)); } - } catch (e) { - if (e instanceof SyntaxError) continue; - throw e; } } + setMessages((prev) => + prev.map((m) => + m.id === assistantMsgId + ? { ...m, content: buildContentForUI(contentPartsState, TOOLS_WITH_UI) } + : m + ) + ); + break; } + + case "data-thinking-step": { + // Handle thinking step events for chain-of-thought display + const stepData = parsed.data as ThinkingStepData; + if (stepData?.id) { + currentThinkingSteps.set(stepData.id, stepData); + // Update thinking steps state for rendering + // The ThinkingStepsScrollHandler in Thread component + // will handle auto-scrolling when this state changes + setMessageThinkingSteps((prev) => { + const newMap = new Map(prev); + newMap.set(assistantMsgId, Array.from(currentThinkingSteps.values())); + return newMap; + }); + } + break; + } + + case "data-thread-title-update": { + // Handle thread title update from LLM-generated title + const titleData = parsed.data as { threadId: number; title: string }; + if (titleData?.title && titleData?.threadId === currentThreadId) { + // Update current thread state with new title + setCurrentThread((prev) => (prev ? { ...prev, title: titleData.title } : prev)); + // Invalidate thread list to refresh sidebar + queryClient.invalidateQueries({ + queryKey: ["threads", String(searchSpaceId)], + }); + // Invalidate thread detail for breadcrumb update + queryClient.invalidateQueries({ + queryKey: [ + "threads", + String(searchSpaceId), + "detail", + String(titleData.threadId), + ], + }); + } + break; + } + + case "data-interrupt-request": { + wasInterrupted = true; + const interruptData = parsed.data as Record; + const actionRequests = (interruptData.action_requests ?? []) as Array<{ + name: string; + args: Record; + }>; + for (const action of actionRequests) { + const existingIdx = Array.from(toolCallIndices.entries()).find(([, idx]) => { + const part = contentParts[idx]; + return part?.type === "tool-call" && part.toolName === action.name; + }); + if (existingIdx) { + updateToolCall(contentPartsState, existingIdx[0], { + result: { __interrupt__: true, ...interruptData }, + }); + } else { + const tcId = `interrupt-${action.name}`; + addToolCall(contentPartsState, TOOLS_WITH_UI, tcId, action.name, action.args); + updateToolCall(contentPartsState, tcId, { + result: { __interrupt__: true, ...interruptData }, + }); + } + } + setMessages((prev) => + prev.map((m) => + m.id === assistantMsgId + ? { ...m, content: buildContentForUI(contentPartsState, TOOLS_WITH_UI) } + : m + ) + ); + if (currentThreadId) { + setPendingInterrupt({ + threadId: currentThreadId, + assistantMsgId, + interruptData, + }); + } + break; + } + + case "error": + throw new Error(parsed.errorText || "Server error"); } - } finally { - reader.releaseLock(); } // Persist assistant message (with thinking steps for restoration on refresh) - const finalContent = buildContentForPersistence(); - if (contentParts.length > 0) { + // Skip persistence for interrupted messages -- handleResume will persist the final version + const finalContent = buildContentForPersistence( + contentPartsState, + TOOLS_WITH_UI, + currentThinkingSteps + ); + if (contentParts.length > 0 && !wasInterrupted) { try { const savedMessage = await appendMessage(currentThreadId, { role: "assistant", @@ -856,6 +812,13 @@ export default function NewChatPage() { prev.map((m) => (m.id === assistantMsgId ? { ...m, id: newMsgId } : m)) ); + // Update pending interrupt with the new persisted message ID + setPendingInterrupt((prev) => + prev && prev.assistantMsgId === assistantMsgId + ? { ...prev, assistantMsgId: newMsgId } + : prev + ); + // Also update thinking steps map with new ID setMessageThinkingSteps((prev) => { const steps = prev.get(assistantMsgId); @@ -883,7 +846,11 @@ export default function NewChatPage() { (part.type === "tool-call" && TOOLS_WITH_UI.has(part.toolName)) ); if (hasContent && currentThreadId) { - const partialContent = buildContentForPersistence(); + const partialContent = buildContentForPersistence( + contentPartsState, + TOOLS_WITH_UI, + currentThinkingSteps + ); try { const savedMessage = await appendMessage(currentThreadId, { role: "assistant", @@ -948,6 +915,330 @@ export default function NewChatPage() { ] ); + const handleResume = useCallback( + async ( + decisions: Array<{ + type: string; + message?: string; + edited_action?: { name: string; args: Record }; + }> + ) => { + if (!pendingInterrupt) return; + const { threadId: resumeThreadId, assistantMsgId } = pendingInterrupt; + setPendingInterrupt(null); + setIsRunning(true); + + const token = getBearerToken(); + if (!token) { + toast.error("Not authenticated. Please log in again."); + setIsRunning(false); + return; + } + + const controller = new AbortController(); + abortControllerRef.current = controller; + + const currentThinkingSteps = new Map( + (messageThinkingSteps.get(assistantMsgId) ?? []).map((s) => [s.id, s]) + ); + + const contentPartsState: ContentPartsState = { + contentParts: [], + currentTextPartIndex: -1, + toolCallIndices: new Map(), + }; + const { contentParts, toolCallIndices } = contentPartsState; + + const existingMsg = messages.find((m) => m.id === assistantMsgId); + if (existingMsg && Array.isArray(existingMsg.content)) { + for (const part of existingMsg.content) { + if (typeof part === "object" && part !== null) { + const p = part as Record; + if (p.type === "text") { + contentParts.push({ type: "text", text: String(p.text ?? "") }); + contentPartsState.currentTextPartIndex = contentParts.length - 1; + } else if (p.type === "tool-call") { + toolCallIndices.set(String(p.toolCallId), contentParts.length); + contentParts.push({ + type: "tool-call", + toolCallId: String(p.toolCallId), + toolName: String(p.toolName), + args: (p.args as Record) ?? {}, + result: p.result as unknown, + }); + contentPartsState.currentTextPartIndex = -1; + } + } + } + } + + // Merge edited args if present to fix race condition + if (decisions.length > 0 && decisions[0].type === "edit" && decisions[0].edited_action) { + const editedAction = decisions[0].edited_action; + for (const part of contentParts) { + if (part.type === "tool-call" && part.toolName === editedAction.name) { + part.args = { ...part.args, ...editedAction.args }; + break; + } + } + } + + const decisionType = decisions[0]?.type as "approve" | "reject" | undefined; + if (decisionType) { + for (const part of contentParts) { + if ( + part.type === "tool-call" && + typeof part.result === "object" && + part.result !== null && + "__interrupt__" in (part.result as Record) + ) { + part.result = { + ...(part.result as Record), + __decided__: decisionType, + }; + } + } + } + + try { + const backendUrl = process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL || "http://localhost:8000"; + const response = await fetch(`${backendUrl}/api/v1/threads/${resumeThreadId}/resume`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ + search_space_id: searchSpaceId, + decisions, + }), + signal: controller.signal, + }); + + if (!response.ok) { + throw new Error(`Backend error: ${response.status}`); + } + + for await (const parsed of readSSEStream(response)) { + switch (parsed.type) { + case "text-delta": + appendText(contentPartsState, parsed.delta); + setMessages((prev) => + prev.map((m) => + m.id === assistantMsgId + ? { ...m, content: buildContentForUI(contentPartsState, TOOLS_WITH_UI) } + : m + ) + ); + break; + + case "tool-input-start": + addToolCall(contentPartsState, TOOLS_WITH_UI, parsed.toolCallId, parsed.toolName, {}); + setMessages((prev) => + prev.map((m) => + m.id === assistantMsgId + ? { ...m, content: buildContentForUI(contentPartsState, TOOLS_WITH_UI) } + : m + ) + ); + break; + + case "tool-input-available": + if (toolCallIndices.has(parsed.toolCallId)) { + updateToolCall(contentPartsState, parsed.toolCallId, { + args: parsed.input || {}, + }); + } else { + addToolCall( + contentPartsState, + TOOLS_WITH_UI, + parsed.toolCallId, + parsed.toolName, + parsed.input || {} + ); + } + setMessages((prev) => + prev.map((m) => + m.id === assistantMsgId + ? { ...m, content: buildContentForUI(contentPartsState, TOOLS_WITH_UI) } + : m + ) + ); + break; + + case "tool-output-available": + updateToolCall(contentPartsState, parsed.toolCallId, { + result: parsed.output, + }); + setMessages((prev) => + prev.map((m) => + m.id === assistantMsgId + ? { ...m, content: buildContentForUI(contentPartsState, TOOLS_WITH_UI) } + : m + ) + ); + break; + + case "data-thinking-step": { + const stepData = parsed.data as ThinkingStepData; + if (stepData?.id) { + currentThinkingSteps.set(stepData.id, stepData); + setMessageThinkingSteps((prev) => { + const newMap = new Map(prev); + newMap.set(assistantMsgId, Array.from(currentThinkingSteps.values())); + return newMap; + }); + } + break; + } + + case "data-interrupt-request": { + const interruptData = parsed.data as Record; + const actionRequests = (interruptData.action_requests ?? []) as Array<{ + name: string; + args: Record; + }>; + for (const action of actionRequests) { + const existingIdx = Array.from(toolCallIndices.entries()).find(([, idx]) => { + const part = contentParts[idx]; + return part?.type === "tool-call" && part.toolName === action.name; + }); + if (existingIdx) { + updateToolCall(contentPartsState, existingIdx[0], { + result: { + __interrupt__: true, + ...interruptData, + }, + }); + } else { + const tcId = `interrupt-${action.name}`; + addToolCall(contentPartsState, TOOLS_WITH_UI, tcId, action.name, action.args); + updateToolCall(contentPartsState, tcId, { + result: { + __interrupt__: true, + ...interruptData, + }, + }); + } + } + setMessages((prev) => + prev.map((m) => + m.id === assistantMsgId + ? { ...m, content: buildContentForUI(contentPartsState, TOOLS_WITH_UI) } + : m + ) + ); + setPendingInterrupt({ + threadId: resumeThreadId, + assistantMsgId, + interruptData, + }); + break; + } + + case "error": + throw new Error(parsed.errorText || "Server error"); + } + } + + const finalContent = buildContentForPersistence( + contentPartsState, + TOOLS_WITH_UI, + currentThinkingSteps + ); + if (contentParts.length > 0) { + try { + const savedMessage = await appendMessage(resumeThreadId, { + role: "assistant", + content: finalContent, + }); + const newMsgId = `msg-${savedMessage.id}`; + setMessages((prev) => + prev.map((m) => (m.id === assistantMsgId ? { ...m, id: newMsgId } : m)) + ); + setMessageThinkingSteps((prev) => { + const steps = prev.get(assistantMsgId); + if (steps) { + const newMap = new Map(prev); + newMap.delete(assistantMsgId); + newMap.set(newMsgId, steps); + return newMap; + } + return prev; + }); + } catch (err) { + console.error("Failed to persist resumed assistant message:", err); + } + } + } catch (error) { + if (error instanceof Error && error.name === "AbortError") { + return; + } + console.error("[NewChatPage] Resume error:", error); + toast.error("Failed to resume. Please try again."); + } finally { + setIsRunning(false); + abortControllerRef.current = null; + } + }, + [pendingInterrupt, messages, searchSpaceId, messageThinkingSteps] + ); + + useEffect(() => { + const handler = (e: Event) => { + const detail = (e as CustomEvent).detail as { + decisions: Array<{ + type: string; + message?: string; + edited_action?: { name: string; args: Record }; + }>; + }; + if (detail?.decisions && pendingInterrupt) { + const decision = detail.decisions[0]; + const decisionType = decision?.type as "approve" | "reject" | "edit"; + + setMessages((prev) => + prev.map((m) => { + if (m.id !== pendingInterrupt.assistantMsgId) return m; + const parts = m.content as unknown as Array>; + const newContent = parts.map((part) => { + if ( + part.type === "tool-call" && + typeof part.result === "object" && + part.result !== null && + "__interrupt__" in part.result + ) { + // For edit decisions, also update the displayed args + if (decisionType === "edit" && decision.edited_action) { + return { + ...part, + args: decision.edited_action.args, // Update displayed args + result: { + ...(part.result as Record), + __decided__: decisionType, + }, + }; + } + return { + ...part, + result: { + ...(part.result as Record), + __decided__: decisionType, + }, + }; + } + return part; + }); + return { ...m, content: newContent as unknown as ThreadMessageLike["content"] }; + }) + ); + handleResume(detail.decisions); + } + }; + window.addEventListener("hitl-decision", handler); + return () => window.removeEventListener("hitl-decision", handler); + }, [handleResume, pendingInterrupt]); + // Convert message (pass through since already in correct format) const convertMessage = useCallback( (message: ThreadMessageLike): ThreadMessageLike => message, @@ -1033,77 +1324,12 @@ export default function NewChatPage() { const assistantMsgId = `msg-assistant-${Date.now()}`; const currentThinkingSteps = new Map(); - // Content parts tracking (same as onNew) - type ContentPart = - | { type: "text"; text: string } - | { - type: "tool-call"; - toolCallId: string; - toolName: string; - args: Record; - result?: unknown; - }; - const contentParts: ContentPart[] = []; - let currentTextPartIndex = -1; - const toolCallIndices = new Map(); - - const appendText = (delta: string) => { - if (currentTextPartIndex >= 0 && contentParts[currentTextPartIndex]?.type === "text") { - (contentParts[currentTextPartIndex] as { type: "text"; text: string }).text += delta; - } else { - contentParts.push({ type: "text", text: delta }); - currentTextPartIndex = contentParts.length - 1; - } - }; - - const addToolCall = (toolCallId: string, toolName: string, args: Record) => { - if (TOOLS_WITH_UI.has(toolName)) { - contentParts.push({ type: "tool-call", toolCallId, toolName, args }); - toolCallIndices.set(toolCallId, contentParts.length - 1); - currentTextPartIndex = -1; - } - }; - - const updateToolCall = ( - toolCallId: string, - update: { args?: Record; result?: unknown } - ) => { - const index = toolCallIndices.get(toolCallId); - if (index !== undefined && contentParts[index]?.type === "tool-call") { - const tc = contentParts[index] as ContentPart & { type: "tool-call" }; - if (update.args) tc.args = update.args; - if (update.result !== undefined) tc.result = update.result; - } - }; - - const buildContentForUI = (): ThreadMessageLike["content"] => { - const filtered = contentParts.filter((part) => { - if (part.type === "text") return part.text.length > 0; - if (part.type === "tool-call") return TOOLS_WITH_UI.has(part.toolName); - return false; - }); - return filtered.length > 0 - ? (filtered as ThreadMessageLike["content"]) - : [{ type: "text", text: "" }]; - }; - - const buildContentForPersistence = (): unknown[] => { - const parts: unknown[] = []; - if (currentThinkingSteps.size > 0) { - parts.push({ - type: "thinking-steps", - steps: Array.from(currentThinkingSteps.values()), - }); - } - for (const part of contentParts) { - if (part.type === "text" && part.text.length > 0) { - parts.push(part); - } else if (part.type === "tool-call" && TOOLS_WITH_UI.has(part.toolName)) { - parts.push(part); - } - } - return parts.length > 0 ? parts : [{ type: "text", text: "" }]; + const contentPartsState: ContentPartsState = { + contentParts: [], + currentTextPartIndex: -1, + toolCallIndices: new Map(), }; + const { contentParts, toolCallIndices } = contentPartsState; // Add placeholder messages to UI // Always add back the user message (with new query for edit, or original content for reload) @@ -1147,113 +1373,95 @@ export default function NewChatPage() { throw new Error(`Backend error: ${response.status}`); } - if (!response.body) { - throw new Error("No response body"); - } + for await (const parsed of readSSEStream(response)) { + switch (parsed.type) { + case "text-delta": + appendText(contentPartsState, parsed.delta); + setMessages((prev) => + prev.map((m) => + m.id === assistantMsgId + ? { ...m, content: buildContentForUI(contentPartsState, TOOLS_WITH_UI) } + : m + ) + ); + break; - // Parse SSE stream (same logic as onNew) - const reader = response.body.getReader(); - const decoder = new TextDecoder(); - let buffer = ""; + case "tool-input-start": + addToolCall(contentPartsState, TOOLS_WITH_UI, parsed.toolCallId, parsed.toolName, {}); + setMessages((prev) => + prev.map((m) => + m.id === assistantMsgId + ? { ...m, content: buildContentForUI(contentPartsState, TOOLS_WITH_UI) } + : m + ) + ); + break; - try { - while (true) { - const { done, value } = await reader.read(); - if (done) break; + case "tool-input-available": + if (toolCallIndices.has(parsed.toolCallId)) { + updateToolCall(contentPartsState, parsed.toolCallId, { args: parsed.input || {} }); + } else { + addToolCall( + contentPartsState, + TOOLS_WITH_UI, + parsed.toolCallId, + parsed.toolName, + parsed.input || {} + ); + } + setMessages((prev) => + prev.map((m) => + m.id === assistantMsgId + ? { ...m, content: buildContentForUI(contentPartsState, TOOLS_WITH_UI) } + : m + ) + ); + break; - buffer += decoder.decode(value, { stream: true }); - const events = buffer.split(/\r?\n\r?\n/); - buffer = events.pop() || ""; - - for (const event of events) { - const lines = event.split(/\r?\n/); - for (const line of lines) { - if (!line.startsWith("data: ")) continue; - const data = line.slice(6).trim(); - if (!data || data === "[DONE]") continue; - - try { - const parsed = JSON.parse(data); - - switch (parsed.type) { - case "text-delta": - appendText(parsed.delta); - setMessages((prev) => - prev.map((m) => - m.id === assistantMsgId ? { ...m, content: buildContentForUI() } : m - ) - ); - break; - - case "tool-input-start": - addToolCall(parsed.toolCallId, parsed.toolName, {}); - setMessages((prev) => - prev.map((m) => - m.id === assistantMsgId ? { ...m, content: buildContentForUI() } : m - ) - ); - break; - - case "tool-input-available": - if (toolCallIndices.has(parsed.toolCallId)) { - updateToolCall(parsed.toolCallId, { args: parsed.input || {} }); - } else { - addToolCall(parsed.toolCallId, parsed.toolName, parsed.input || {}); - } - setMessages((prev) => - prev.map((m) => - m.id === assistantMsgId ? { ...m, content: buildContentForUI() } : m - ) - ); - break; - - case "tool-output-available": - updateToolCall(parsed.toolCallId, { result: parsed.output }); - if (parsed.output?.status === "pending" && parsed.output?.podcast_id) { - const idx = toolCallIndices.get(parsed.toolCallId); - if (idx !== undefined) { - const part = contentParts[idx]; - if (part?.type === "tool-call" && part.toolName === "generate_podcast") { - setActivePodcastTaskId(String(parsed.output.podcast_id)); - } - } - } - setMessages((prev) => - prev.map((m) => - m.id === assistantMsgId ? { ...m, content: buildContentForUI() } : m - ) - ); - break; - - case "data-thinking-step": { - const stepData = parsed.data as ThinkingStepData; - if (stepData?.id) { - currentThinkingSteps.set(stepData.id, stepData); - setMessageThinkingSteps((prev) => { - const newMap = new Map(prev); - newMap.set(assistantMsgId, Array.from(currentThinkingSteps.values())); - return newMap; - }); - } - break; - } - - case "error": - throw new Error(parsed.errorText || "Server error"); + case "tool-output-available": + updateToolCall(contentPartsState, parsed.toolCallId, { result: parsed.output }); + if (parsed.output?.status === "pending" && parsed.output?.podcast_id) { + const idx = toolCallIndices.get(parsed.toolCallId); + if (idx !== undefined) { + const part = contentParts[idx]; + if (part?.type === "tool-call" && part.toolName === "generate_podcast") { + setActivePodcastTaskId(String(parsed.output.podcast_id)); } - } catch (e) { - if (e instanceof SyntaxError) continue; - throw e; } } + setMessages((prev) => + prev.map((m) => + m.id === assistantMsgId + ? { ...m, content: buildContentForUI(contentPartsState, TOOLS_WITH_UI) } + : m + ) + ); + break; + + case "data-thinking-step": { + const stepData = parsed.data as ThinkingStepData; + if (stepData?.id) { + currentThinkingSteps.set(stepData.id, stepData); + setMessageThinkingSteps((prev) => { + const newMap = new Map(prev); + newMap.set(assistantMsgId, Array.from(currentThinkingSteps.values())); + return newMap; + }); + } + break; } + + case "error": + throw new Error(parsed.errorText || "Server error"); } - } finally { - reader.releaseLock(); } // Persist messages after streaming completes - const finalContent = buildContentForPersistence(); + const finalContent = buildContentForPersistence( + contentPartsState, + TOOLS_WITH_UI, + currentThinkingSteps + ); if (contentParts.length > 0) { try { // Persist user message (for both edit and reload modes, since backend deleted it) @@ -1440,6 +1648,9 @@ export default function NewChatPage() { + + + {/* Disabled for now */}
diff --git a/surfsense_web/app/globals.css b/surfsense_web/app/globals.css index d59c324b1..4095fc660 100644 --- a/surfsense_web/app/globals.css +++ b/surfsense_web/app/globals.css @@ -187,6 +187,23 @@ button { background-color: hsl(var(--muted-foreground) / 0.4); } +/* Human-in-the-loop approval card animations */ +@keyframes pulse-subtle { + 0%, + 100% { + opacity: 1; + box-shadow: 0 0 0 0 rgb(0 0 0 / 0.15); + } + 50% { + opacity: 1; + box-shadow: 0 0 20px 4px rgb(0 0 0 / 0.12); + } +} + +.animate-pulse-subtle { + animation: pulse-subtle 2s cubic-bezier(0.4, 0, 0.6, 1) infinite; +} + /* Integrations section — vertical column auto-scroll */ @keyframes integrations-scroll-up { 0% { diff --git a/surfsense_web/components/tool-ui/create-notion-page.tsx b/surfsense_web/components/tool-ui/create-notion-page.tsx new file mode 100644 index 000000000..e148c71ba --- /dev/null +++ b/surfsense_web/components/tool-ui/create-notion-page.tsx @@ -0,0 +1,503 @@ +"use client"; + +import { makeAssistantToolUI } from "@assistant-ui/react"; +import { AlertTriangleIcon, CheckIcon, 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 InterruptResult { + __interrupt__: true; + __decided__?: "approve" | "reject" | "edit"; + action_requests: Array<{ + name: string; + args: Record; + description?: string; + }>; + review_configs: Array<{ + action_name: string; + allowed_decisions: Array<"approve" | "edit" | "reject">; + }>; + interrupt_type?: string; + message?: string; + context?: { + accounts?: Array<{ + id: number; + name: string; + workspace_id: string | null; + workspace_name: string; + workspace_icon: string; + }>; + parent_pages?: Record< + number, + Array<{ + page_id: string; + title: string; + document_id: number; + }> + >; + error?: string; + }; +} + +interface SuccessResult { + status: "success"; + page_id: string; + title: string; + url: string; + content_preview?: string; + content_length?: number; + message?: string; +} + +interface ErrorResult { + status: "error"; + message: string; +} + +type CreateNotionPageResult = 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" + ); +} + +function ApprovalCard({ + args, + interruptData, + onDecision, +}: { + args: Record; + 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 [editedArgs, setEditedArgs] = useState>(args); + + const accounts = interruptData.context?.accounts ?? []; + const parentPages = interruptData.context?.parent_pages ?? {}; + + const defaultAccountId = useMemo(() => { + if (args.connector_id) return String(args.connector_id); + if (accounts.length === 1) return String(accounts[0].id); + return ""; + }, [args.connector_id, accounts]); + + const [selectedAccountId, setSelectedAccountId] = useState(defaultAccountId); + const [selectedParentPageId, setSelectedParentPageId] = useState( + args.parent_page_id ? String(args.parent_page_id) : "__none__" + ); + + const availableParentPages = useMemo(() => { + if (!selectedAccountId) return []; + return parentPages[Number(selectedAccountId)] ?? []; + }, [selectedAccountId, parentPages]); + + const isTitleValid = useMemo(() => { + const currentTitle = isEditing ? editedArgs.title : args.title; + return currentTitle && typeof currentTitle === "string" && currentTitle.trim().length > 0; + }, [isEditing, editedArgs.title, args.title]); + + const reviewConfig = interruptData.review_configs[0]; + const allowedDecisions = reviewConfig?.allowed_decisions ?? ["approve", "reject"]; + const canEdit = allowedDecisions.includes("edit"); + + return ( +
+
+
+ +
+
+

+ Create Notion Page +

+

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

+
+
+ + {/* Context section - account and parent page selection */} + {!decided && interruptData.context && ( +
+ {interruptData.context.error ? ( +

{interruptData.context.error}

+ ) : ( + <> + {accounts.length > 0 && ( +
+
+ Notion Account * +
+ +
+ )} + + {selectedAccountId && ( +
+
+ Parent Page (optional) +
+ + {availableParentPages.length === 0 && selectedAccountId && ( +

+ No pages available. Page will be created at workspace root. +

+ )} +
+ )} + + )} +
+ )} + + {/* Display mode - show args as read-only */} + {!isEditing && ( +
+ {args.title != null && ( +
+

Title

+

{String(args.title)}

+
+ )} + {args.content != null && ( +
+

Content

+

+ {String(args.content)} +

+
+ )} +
+ )} + + {/* Edit mode - show editable form fields */} + {isEditing && !decided && ( +
+
+ + setEditedArgs({ ...editedArgs, title: e.target.value })} + placeholder="Enter page title" + className={!isTitleValid ? "border-destructive" : ""} + /> + {!isTitleValid && ( +

Title is required and cannot be empty

+ )} +
+
+ +