mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-29 19:35:20 +02:00
Merge pull request #811 from CREDO23/feature/human-in-the-loop
[Feature] Add Human-in-the-Loop for sensitives operations (create/update/delete)
This commit is contained in:
commit
4fdb165a5f
20 changed files with 4314 additions and 1261 deletions
|
|
@ -243,11 +243,20 @@ async def create_surfsense_deep_agent(
|
||||||
"available_document_types": available_document_types,
|
"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)
|
# Build tools using the async registry (includes MCP tools)
|
||||||
tools = await build_tools_async(
|
tools = await build_tools_async(
|
||||||
dependencies=dependencies,
|
dependencies=dependencies,
|
||||||
enabled_tools=enabled_tools,
|
enabled_tools=enabled_tools,
|
||||||
disabled_tools=disabled_tools,
|
disabled_tools=modified_disabled_tools,
|
||||||
additional_tools=list(additional_tools) if additional_tools else None,
|
additional_tools=list(additional_tools) if additional_tools else None,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
]
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -50,6 +50,11 @@ from .generate_image import create_generate_image_tool
|
||||||
from .knowledge_base import create_search_knowledge_base_tool
|
from .knowledge_base import create_search_knowledge_base_tool
|
||||||
from .link_preview import create_link_preview_tool
|
from .link_preview import create_link_preview_tool
|
||||||
from .mcp_tool import load_mcp_tools
|
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 .podcast import create_generate_podcast_tool
|
||||||
from .report import create_generate_report_tool
|
from .report import create_generate_report_tool
|
||||||
from .scrape_webpage import create_scrape_webpage_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"],
|
requires=["user_id", "search_space_id", "db_session", "thread_visibility"],
|
||||||
),
|
),
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
# ADD YOUR CUSTOM TOOLS BELOW
|
# NOTION TOOLS - create, update, delete pages
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
# Example:
|
ToolDefinition(
|
||||||
# ToolDefinition(
|
name="create_notion_page",
|
||||||
# name="my_custom_tool",
|
description="Create a new page in the user's Notion workspace",
|
||||||
# description="What my tool does",
|
factory=lambda deps: create_create_notion_page_tool(
|
||||||
# factory=lambda deps: create_my_custom_tool(...),
|
db_session=deps["db_session"],
|
||||||
# requires=["search_space_id"],
|
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"],
|
||||||
|
),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
|
import re
|
||||||
from collections.abc import Awaitable, Callable
|
from collections.abc import Awaitable, Callable
|
||||||
from typing import Any, TypeVar
|
from typing import Any, TypeVar
|
||||||
|
|
||||||
|
|
@ -10,7 +11,6 @@ from sqlalchemy.future import select
|
||||||
|
|
||||||
from app.config import config
|
from app.config import config
|
||||||
from app.db import SearchSourceConnector
|
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.schemas.notion_auth_credentials import NotionAuthCredentialsBase
|
||||||
from app.utils.oauth_security import TokenEncryption
|
from app.utils.oauth_security import TokenEncryption
|
||||||
|
|
||||||
|
|
@ -219,6 +219,7 @@ class NotionHistoryConnector:
|
||||||
)
|
)
|
||||||
|
|
||||||
# Refresh token
|
# Refresh token
|
||||||
|
from app.routes.notion_add_connector_route import refresh_notion_token
|
||||||
connector = await refresh_notion_token(self._session, connector)
|
connector = await refresh_notion_token(self._session, connector)
|
||||||
|
|
||||||
# Reload credentials after refresh
|
# Reload credentials after refresh
|
||||||
|
|
@ -777,3 +778,356 @@ class NotionHistoryConnector:
|
||||||
|
|
||||||
# Return empty string for unsupported block types
|
# Return empty string for unsupported block types
|
||||||
return ""
|
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}",
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -43,11 +43,12 @@ from app.schemas.new_chat import (
|
||||||
PublicChatSnapshotCreateResponse,
|
PublicChatSnapshotCreateResponse,
|
||||||
PublicChatSnapshotListResponse,
|
PublicChatSnapshotListResponse,
|
||||||
RegenerateRequest,
|
RegenerateRequest,
|
||||||
|
ResumeRequest,
|
||||||
ThreadHistoryLoadResponse,
|
ThreadHistoryLoadResponse,
|
||||||
ThreadListItem,
|
ThreadListItem,
|
||||||
ThreadListResponse,
|
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.users import current_active_user
|
||||||
from app.utils.rbac import check_permission
|
from app.utils.rbac import check_permission
|
||||||
|
|
||||||
|
|
@ -1326,3 +1327,78 @@ async def regenerate_response(
|
||||||
status_code=500,
|
status_code=500,
|
||||||
detail=f"An unexpected error occurred during regeneration: {e!s}",
|
detail=f"An unexpected error occurred during regeneration: {e!s}",
|
||||||
) from None
|
) 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
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ These schemas follow the assistant-ui ThreadHistoryAdapter pattern:
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Any
|
from typing import Any, Literal
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
@ -193,6 +193,16 @@ class RegenerateRequest(BaseModel):
|
||||||
mentioned_surfsense_doc_ids: list[int] | None = None
|
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
|
# Public Chat Snapshot Schemas
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
|
||||||
|
|
@ -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
|
# Error Part
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
|
|
|
||||||
11
surfsense_backend/app/services/notion/__init__.py
Normal file
11
surfsense_backend/app/services/notion/__init__.py
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
from app.services.notion.tool_metadata_service import (
|
||||||
|
NotionAccount,
|
||||||
|
NotionPage,
|
||||||
|
NotionToolMetadataService,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"NotionAccount",
|
||||||
|
"NotionPage",
|
||||||
|
"NotionToolMetadataService",
|
||||||
|
]
|
||||||
200
surfsense_backend/app/services/notion/tool_metadata_service.py
Normal file
200
surfsense_backend/app/services/notion/tool_metadata_service.py
Normal file
|
|
@ -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
|
||||||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -187,6 +187,23 @@ button {
|
||||||
background-color: hsl(var(--muted-foreground) / 0.4);
|
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 */
|
/* Integrations section — vertical column auto-scroll */
|
||||||
@keyframes integrations-scroll-up {
|
@keyframes integrations-scroll-up {
|
||||||
0% {
|
0% {
|
||||||
|
|
|
||||||
503
surfsense_web/components/tool-ui/create-notion-page.tsx
Normal file
503
surfsense_web/components/tool-ui/create-notion-page.tsx
Normal file
|
|
@ -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<string, unknown>;
|
||||||
|
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<string, unknown>;
|
||||||
|
interruptData: InterruptResult;
|
||||||
|
onDecision: (decision: {
|
||||||
|
type: "approve" | "reject" | "edit";
|
||||||
|
message?: string;
|
||||||
|
edited_action?: { name: string; args: Record<string, unknown> };
|
||||||
|
}) => void;
|
||||||
|
}) {
|
||||||
|
const [decided, setDecided] = useState<"approve" | "reject" | "edit" | null>(
|
||||||
|
interruptData.__decided__ ?? null
|
||||||
|
);
|
||||||
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
|
const [editedArgs, setEditedArgs] = useState<Record<string, unknown>>(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<string>(defaultAccountId);
|
||||||
|
const [selectedParentPageId, setSelectedParentPageId] = useState<string>(
|
||||||
|
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 (
|
||||||
|
<div
|
||||||
|
className={`my-4 max-w-full overflow-hidden rounded-xl transition-all duration-300 ${
|
||||||
|
decided
|
||||||
|
? "border border-border bg-card shadow-sm"
|
||||||
|
: "border-2 border-foreground/20 bg-muted/30 dark:bg-muted/10 shadow-lg animate-pulse-subtle"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`flex items-center gap-3 border-b ${
|
||||||
|
decided ? "border-border bg-card" : "border-foreground/15 bg-muted/40 dark:bg-muted/20"
|
||||||
|
} px-4 py-3`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`flex size-9 shrink-0 items-center justify-center rounded-lg ${
|
||||||
|
decided ? "bg-muted" : "bg-muted animate-pulse"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<AlertTriangleIcon
|
||||||
|
className={`size-4 ${decided ? "text-muted-foreground" : "text-foreground"}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className={`text-sm font-medium ${decided ? "text-foreground" : "text-foreground"}`}>
|
||||||
|
Create Notion Page
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
className={`truncate text-xs ${
|
||||||
|
decided ? "text-muted-foreground" : "text-muted-foreground"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isEditing ? "You can edit the arguments below" : "Requires your approval to proceed"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Context section - account and parent page selection */}
|
||||||
|
{!decided && interruptData.context && (
|
||||||
|
<div className="border-b border-border px-4 py-3 bg-muted/30 space-y-3">
|
||||||
|
{interruptData.context.error ? (
|
||||||
|
<p className="text-sm text-destructive">{interruptData.context.error}</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{accounts.length > 0 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="text-xs font-medium text-muted-foreground">
|
||||||
|
Notion Account <span className="text-destructive">*</span>
|
||||||
|
</div>
|
||||||
|
<Select
|
||||||
|
value={selectedAccountId}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
setSelectedAccountId(value);
|
||||||
|
setSelectedParentPageId("__none__");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-full">
|
||||||
|
<SelectValue placeholder="Select an account" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{accounts.map((account) => (
|
||||||
|
<SelectItem key={account.id} value={String(account.id)}>
|
||||||
|
{account.workspace_name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selectedAccountId && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="text-xs font-medium text-muted-foreground">
|
||||||
|
Parent Page (optional)
|
||||||
|
</div>
|
||||||
|
<Select value={selectedParentPageId} onValueChange={setSelectedParentPageId}>
|
||||||
|
<SelectTrigger className="w-full">
|
||||||
|
<SelectValue placeholder="None" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="__none__">None</SelectItem>
|
||||||
|
{availableParentPages.map((page) => (
|
||||||
|
<SelectItem key={page.page_id} value={page.page_id}>
|
||||||
|
📄 {page.title}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
{availableParentPages.length === 0 && selectedAccountId && (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
No pages available. Page will be created at workspace root.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Display mode - show args as read-only */}
|
||||||
|
{!isEditing && (
|
||||||
|
<div className="space-y-2 px-4 py-3 bg-card">
|
||||||
|
{args.title != null && (
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-medium text-muted-foreground">Title</p>
|
||||||
|
<p className="text-sm text-foreground">{String(args.title)}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{args.content != null && (
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-medium text-muted-foreground">Content</p>
|
||||||
|
<p className="line-clamp-4 text-sm whitespace-pre-wrap text-foreground">
|
||||||
|
{String(args.content)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Edit mode - show editable form fields */}
|
||||||
|
{isEditing && !decided && (
|
||||||
|
<div className="space-y-3 px-4 py-3 bg-card">
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="notion-title"
|
||||||
|
className="text-xs font-medium text-muted-foreground mb-1.5 block"
|
||||||
|
>
|
||||||
|
Title <span className="text-destructive">*</span>
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="notion-title"
|
||||||
|
value={String(editedArgs.title ?? "")}
|
||||||
|
onChange={(e) => setEditedArgs({ ...editedArgs, title: e.target.value })}
|
||||||
|
placeholder="Enter page title"
|
||||||
|
className={!isTitleValid ? "border-destructive" : ""}
|
||||||
|
/>
|
||||||
|
{!isTitleValid && (
|
||||||
|
<p className="text-xs text-destructive mt-1">Title is required and cannot be empty</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="notion-content"
|
||||||
|
className="text-xs font-medium text-muted-foreground mb-1.5 block"
|
||||||
|
>
|
||||||
|
Content
|
||||||
|
</label>
|
||||||
|
<Textarea
|
||||||
|
id="notion-content"
|
||||||
|
value={String(editedArgs.content ?? "")}
|
||||||
|
onChange={(e) => setEditedArgs({ ...editedArgs, content: e.target.value })}
|
||||||
|
placeholder="Enter page content"
|
||||||
|
rows={6}
|
||||||
|
className="resize-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Action buttons */}
|
||||||
|
<div
|
||||||
|
className={`flex items-center gap-2 border-t ${
|
||||||
|
decided ? "border-border bg-card" : "border-foreground/15 bg-muted/20 dark:bg-muted/10"
|
||||||
|
} px-4 py-3`}
|
||||||
|
>
|
||||||
|
{decided ? (
|
||||||
|
<p className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||||
|
{decided === "approve" || decided === "edit" ? (
|
||||||
|
<>
|
||||||
|
<CheckIcon className="size-3.5 text-green-500" />
|
||||||
|
{decided === "edit" ? "Approved with Changes" : "Approved"}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<XIcon className="size-3.5 text-destructive" />
|
||||||
|
Rejected
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
) : isEditing ? (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setDecided("edit");
|
||||||
|
setIsEditing(false);
|
||||||
|
onDecision({
|
||||||
|
type: "edit",
|
||||||
|
edited_action: {
|
||||||
|
name: interruptData.action_requests[0].name,
|
||||||
|
args: {
|
||||||
|
...editedArgs,
|
||||||
|
connector_id: selectedAccountId ? Number(selectedAccountId) : null,
|
||||||
|
parent_page_id:
|
||||||
|
selectedParentPageId === "__none__" ? null : selectedParentPageId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
disabled={!selectedAccountId || !isTitleValid}
|
||||||
|
>
|
||||||
|
<CheckIcon />
|
||||||
|
Approve with Changes
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
setIsEditing(false);
|
||||||
|
setEditedArgs(args); // Reset to original args
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{allowedDecisions.includes("approve") && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setDecided("approve");
|
||||||
|
onDecision({
|
||||||
|
type: "approve",
|
||||||
|
edited_action: {
|
||||||
|
name: interruptData.action_requests[0].name,
|
||||||
|
args: {
|
||||||
|
...args,
|
||||||
|
connector_id: selectedAccountId ? Number(selectedAccountId) : null,
|
||||||
|
parent_page_id:
|
||||||
|
selectedParentPageId === "__none__" ? null : selectedParentPageId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
disabled={!selectedAccountId || !isTitleValid}
|
||||||
|
>
|
||||||
|
<CheckIcon />
|
||||||
|
Approve
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{canEdit && (
|
||||||
|
<Button size="sm" variant="outline" onClick={() => setIsEditing(true)}>
|
||||||
|
<PencilIcon />
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{allowedDecisions.includes("reject") && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
setDecided("reject");
|
||||||
|
onDecision({ type: "reject", message: "User rejected the action." });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<XIcon />
|
||||||
|
Reject
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ErrorCard({ result }: { result: ErrorResult }) {
|
||||||
|
return (
|
||||||
|
<div className="my-4 max-w-md overflow-hidden rounded-xl border border-destructive/50 bg-card">
|
||||||
|
<div className="flex items-center gap-3 border-b border-destructive/50 px-4 py-3">
|
||||||
|
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-destructive/10">
|
||||||
|
<XIcon className="size-4 text-destructive" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-sm font-medium text-destructive">Failed to create Notion page</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="px-4 py-3">
|
||||||
|
<p className="text-sm text-muted-foreground">{result.message}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SuccessCard({ result }: { result: SuccessResult }) {
|
||||||
|
return (
|
||||||
|
<div className="my-4 max-w-md overflow-hidden rounded-xl border border-border bg-card">
|
||||||
|
<div className="flex items-center gap-3 border-b border-border px-4 py-3">
|
||||||
|
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-green-500/10">
|
||||||
|
<CheckIcon className="size-4 text-green-500" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-[.8rem] text-muted-foreground">
|
||||||
|
{result.message || "Notion page created successfully"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2 px-4 py-3 text-xs">
|
||||||
|
<div>
|
||||||
|
<span className="font-medium text-muted-foreground">Title: </span>
|
||||||
|
<span>{result.title}</span>
|
||||||
|
</div>
|
||||||
|
{result.url && (
|
||||||
|
<div>
|
||||||
|
<a
|
||||||
|
href={result.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-primary hover:underline"
|
||||||
|
>
|
||||||
|
Open in Notion
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CreateNotionPageToolUI = makeAssistantToolUI<
|
||||||
|
{ title: string; content: string },
|
||||||
|
CreateNotionPageResult
|
||||||
|
>({
|
||||||
|
toolName: "create_notion_page",
|
||||||
|
render: function CreateNotionPageUI({ args, result, status }) {
|
||||||
|
if (status.type === "running") {
|
||||||
|
return (
|
||||||
|
<div className="my-4 flex max-w-md items-center gap-3 rounded-xl border border-border bg-card px-4 py-3">
|
||||||
|
<Loader2Icon className="size-4 animate-spin text-muted-foreground" />
|
||||||
|
<p className="text-sm text-muted-foreground">Preparing Notion page...</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!result) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isInterruptResult(result)) {
|
||||||
|
return (
|
||||||
|
<ApprovalCard
|
||||||
|
args={args}
|
||||||
|
interruptData={result}
|
||||||
|
onDecision={(decision) => {
|
||||||
|
const event = new CustomEvent("hitl-decision", {
|
||||||
|
detail: { decisions: [decision] },
|
||||||
|
});
|
||||||
|
window.dispatchEvent(event);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
typeof result === "object" &&
|
||||||
|
result !== null &&
|
||||||
|
"status" in result &&
|
||||||
|
(result as { status: string }).status === "rejected"
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isErrorResult(result)) {
|
||||||
|
return <ErrorCard result={result} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <SuccessCard result={result as SuccessResult} />;
|
||||||
|
},
|
||||||
|
});
|
||||||
421
surfsense_web/components/tool-ui/delete-notion-page.tsx
Normal file
421
surfsense_web/components/tool-ui/delete-notion-page.tsx
Normal file
|
|
@ -0,0 +1,421 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { makeAssistantToolUI } from "@assistant-ui/react";
|
||||||
|
import {
|
||||||
|
AlertTriangleIcon,
|
||||||
|
CheckIcon,
|
||||||
|
InfoIcon,
|
||||||
|
Loader2Icon,
|
||||||
|
TriangleAlertIcon,
|
||||||
|
XIcon,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
|
interface InterruptResult {
|
||||||
|
__interrupt__: true;
|
||||||
|
__decided__?: "approve" | "reject";
|
||||||
|
action_requests: Array<{
|
||||||
|
name: string;
|
||||||
|
args: Record<string, unknown>;
|
||||||
|
description?: string;
|
||||||
|
}>;
|
||||||
|
review_configs: Array<{
|
||||||
|
action_name: string;
|
||||||
|
allowed_decisions: Array<"approve" | "reject">;
|
||||||
|
}>;
|
||||||
|
interrupt_type?: string;
|
||||||
|
message?: string;
|
||||||
|
context?: {
|
||||||
|
account?: {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
workspace_id: string | null;
|
||||||
|
workspace_name: string;
|
||||||
|
workspace_icon: string;
|
||||||
|
};
|
||||||
|
page_id?: string;
|
||||||
|
current_title?: string;
|
||||||
|
document_id?: number;
|
||||||
|
indexed_at?: string;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SuccessResult {
|
||||||
|
status: "success";
|
||||||
|
page_id: string;
|
||||||
|
title?: string;
|
||||||
|
message?: string;
|
||||||
|
deleted_from_db?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ErrorResult {
|
||||||
|
status: "error";
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface InfoResult {
|
||||||
|
status: "not_found";
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WarningResult {
|
||||||
|
status: "success";
|
||||||
|
warning: string;
|
||||||
|
page_id?: string;
|
||||||
|
title?: string;
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type DeleteNotionPageResult = InterruptResult | SuccessResult | ErrorResult | InfoResult | WarningResult;
|
||||||
|
|
||||||
|
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 isInfoResult(result: unknown): result is InfoResult {
|
||||||
|
return (
|
||||||
|
typeof result === "object" &&
|
||||||
|
result !== null &&
|
||||||
|
"status" in result &&
|
||||||
|
(result as InfoResult).status === "not_found"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isWarningResult(result: unknown): result is WarningResult {
|
||||||
|
return (
|
||||||
|
typeof result === "object" &&
|
||||||
|
result !== null &&
|
||||||
|
"status" in result &&
|
||||||
|
(result as WarningResult).status === "success" &&
|
||||||
|
"warning" in result &&
|
||||||
|
typeof (result as WarningResult).warning === "string"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ApprovalCard({
|
||||||
|
args,
|
||||||
|
interruptData,
|
||||||
|
onDecision,
|
||||||
|
}: {
|
||||||
|
args: Record<string, unknown>;
|
||||||
|
interruptData: InterruptResult;
|
||||||
|
onDecision: (decision: {
|
||||||
|
type: "approve" | "reject";
|
||||||
|
message?: string;
|
||||||
|
edited_action?: { name: string; args: Record<string, unknown> };
|
||||||
|
}) => void;
|
||||||
|
}) {
|
||||||
|
const [decided, setDecided] = useState<"approve" | "reject" | null>(
|
||||||
|
interruptData.__decided__ ?? null
|
||||||
|
);
|
||||||
|
const [deleteFromDb, setDeleteFromDb] = useState(false);
|
||||||
|
|
||||||
|
const account = interruptData.context?.account;
|
||||||
|
const currentTitle = interruptData.context?.current_title;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`my-4 max-w-full overflow-hidden rounded-xl transition-all duration-300 ${
|
||||||
|
decided
|
||||||
|
? "border border-border bg-card shadow-sm"
|
||||||
|
: "border-2 border-foreground/20 bg-muted/30 dark:bg-muted/10 shadow-lg animate-pulse-subtle"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`flex items-center gap-3 border-b ${
|
||||||
|
decided ? "border-border bg-card" : "border-foreground/15 bg-muted/40 dark:bg-muted/20"
|
||||||
|
} px-4 py-3`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`flex size-9 shrink-0 items-center justify-center rounded-lg ${
|
||||||
|
decided ? "bg-muted" : "bg-muted animate-pulse"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<AlertTriangleIcon
|
||||||
|
className={`size-4 ${decided ? "text-muted-foreground" : "text-foreground"}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className={`text-sm font-medium ${decided ? "text-foreground" : "text-foreground"}`}>
|
||||||
|
Delete Notion Page
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
className={`truncate text-xs ${decided ? "text-muted-foreground" : "text-muted-foreground"}`}
|
||||||
|
>
|
||||||
|
Requires your approval to proceed
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Context section - READ ONLY account and page info */}
|
||||||
|
{!decided && interruptData.context && (
|
||||||
|
<div className="border-b border-border px-4 py-3 bg-muted/30 space-y-3">
|
||||||
|
{interruptData.context.error ? (
|
||||||
|
<p className="text-sm text-destructive">{interruptData.context.error}</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{account && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="text-xs font-medium text-muted-foreground">Notion Account</div>
|
||||||
|
<div className="w-full rounded-md border border-input bg-muted/50 px-3 py-2 text-sm">
|
||||||
|
{account.workspace_icon} {account.workspace_name}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{currentTitle && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="text-xs font-medium text-muted-foreground">Page to Delete</div>
|
||||||
|
<div className="w-full rounded-md border border-input bg-muted/50 px-3 py-2 text-sm">
|
||||||
|
📄 {currentTitle}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Checkbox for deleting from knowledge base */}
|
||||||
|
{!decided && (
|
||||||
|
<div className="px-4 py-3 border-t border-border bg-muted/20">
|
||||||
|
<label className="flex items-start gap-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={deleteFromDb}
|
||||||
|
onChange={(e) => setDeleteFromDb(e.target.checked)}
|
||||||
|
className="mt-0.5"
|
||||||
|
/>
|
||||||
|
<div className="flex-1">
|
||||||
|
<span className="text-sm text-foreground">Also remove from knowledge base</span>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
⚠️ This will permanently delete the page from your knowledge base (cannot be undone)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={`flex items-center gap-2 border-t ${
|
||||||
|
decided ? "border-border bg-card" : "border-foreground/15 bg-muted/20 dark:bg-muted/10"
|
||||||
|
} px-4 py-3`}
|
||||||
|
>
|
||||||
|
{decided ? (
|
||||||
|
<p className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||||
|
{decided === "approve" ? (
|
||||||
|
<>
|
||||||
|
<CheckIcon className="size-3.5 text-green-500" />
|
||||||
|
Approved
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<XIcon className="size-3.5 text-destructive" />
|
||||||
|
Rejected
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setDecided("approve");
|
||||||
|
onDecision({
|
||||||
|
type: "approve",
|
||||||
|
edited_action: {
|
||||||
|
name: interruptData.action_requests[0].name,
|
||||||
|
args: {
|
||||||
|
page_id: interruptData.context?.page_id,
|
||||||
|
connector_id: account?.id,
|
||||||
|
delete_from_db: deleteFromDb,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CheckIcon />
|
||||||
|
Approve
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
setDecided("reject");
|
||||||
|
onDecision({ type: "reject", message: "User rejected the action." });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<XIcon />
|
||||||
|
Reject
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ErrorCard({ result }: { result: ErrorResult }) {
|
||||||
|
return (
|
||||||
|
<div className="my-4 max-w-md overflow-hidden rounded-xl border border-destructive/50 bg-card">
|
||||||
|
<div className="flex items-center gap-3 border-b border-destructive/50 px-4 py-3">
|
||||||
|
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-destructive/10">
|
||||||
|
<XIcon className="size-4 text-destructive" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-sm font-medium text-destructive">Failed to delete Notion page</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="px-4 py-3">
|
||||||
|
<p className="text-sm text-muted-foreground">{result.message}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function InfoCard({ result }: { result: InfoResult }) {
|
||||||
|
return (
|
||||||
|
<div className="my-4 max-w-md overflow-hidden rounded-xl border border-amber-500/50 bg-card">
|
||||||
|
<div className="flex items-start gap-3 px-4 py-3">
|
||||||
|
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-amber-500/10">
|
||||||
|
<InfoIcon className="size-4 text-amber-500" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1 pt-2">
|
||||||
|
<p className="text-sm text-muted-foreground">{result.message}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function WarningCard({ result }: { result: WarningResult }) {
|
||||||
|
return (
|
||||||
|
<div className="my-4 max-w-md overflow-hidden rounded-xl border border-amber-500/50 bg-card">
|
||||||
|
<div className="flex items-center gap-3 border-b border-amber-500/50 px-4 py-3">
|
||||||
|
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-amber-500/10">
|
||||||
|
<TriangleAlertIcon className="size-4 text-amber-500" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-sm font-medium text-amber-600 dark:text-amber-500">Partial success</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2 px-4 py-3 text-xs">
|
||||||
|
<p className="text-sm text-muted-foreground">{result.warning}</p>
|
||||||
|
{result.title && (
|
||||||
|
<div className="pt-2">
|
||||||
|
<span className="font-medium text-muted-foreground">Deleted page: </span>
|
||||||
|
<span>{result.title}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SuccessCard({ result }: { result: SuccessResult }) {
|
||||||
|
return (
|
||||||
|
<div className="my-4 max-w-md overflow-hidden rounded-xl border border-border bg-card">
|
||||||
|
<div className="flex items-center gap-3 border-b border-border px-4 py-3">
|
||||||
|
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-green-500/10">
|
||||||
|
<CheckIcon className="size-4 text-green-500" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-[.8rem] text-muted-foreground">
|
||||||
|
{result.message || "Notion page deleted successfully"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{(result.deleted_from_db || result.title) && (
|
||||||
|
<div className="space-y-2 px-4 py-3 text-xs">
|
||||||
|
{result.title && (
|
||||||
|
<div>
|
||||||
|
<span className="font-medium text-muted-foreground">Deleted page: </span>
|
||||||
|
<span>{result.title}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{result.deleted_from_db && (
|
||||||
|
<div className="pt-1">
|
||||||
|
<span className="text-green-600 dark:text-green-500">
|
||||||
|
✓ Also removed from knowledge base
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DeleteNotionPageToolUI = makeAssistantToolUI<
|
||||||
|
{ page_title: string; delete_from_db?: boolean },
|
||||||
|
DeleteNotionPageResult
|
||||||
|
>({
|
||||||
|
toolName: "delete_notion_page",
|
||||||
|
render: function DeleteNotionPageUI({ args, result, status }) {
|
||||||
|
if (status.type === "running") {
|
||||||
|
return (
|
||||||
|
<div className="my-4 flex max-w-md items-center gap-3 rounded-xl border border-border bg-card px-4 py-3">
|
||||||
|
<Loader2Icon className="size-4 animate-spin text-muted-foreground" />
|
||||||
|
<p className="text-sm text-muted-foreground">Deleting Notion page...</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!result) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isInterruptResult(result)) {
|
||||||
|
return (
|
||||||
|
<ApprovalCard
|
||||||
|
args={args}
|
||||||
|
interruptData={result}
|
||||||
|
onDecision={(decision) => {
|
||||||
|
const event = new CustomEvent("hitl-decision", {
|
||||||
|
detail: { decisions: [decision] },
|
||||||
|
});
|
||||||
|
window.dispatchEvent(event);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
typeof result === "object" &&
|
||||||
|
result !== null &&
|
||||||
|
"status" in result &&
|
||||||
|
(result as { status: string }).status === "rejected"
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isInfoResult(result)) {
|
||||||
|
return <InfoCard result={result} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isWarningResult(result)) {
|
||||||
|
return <WarningCard result={result} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isErrorResult(result)) {
|
||||||
|
return <ErrorCard result={result} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <SuccessCard result={result as SuccessResult} />;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
@ -16,6 +16,7 @@ export {
|
||||||
type SerializableArticle,
|
type SerializableArticle,
|
||||||
} from "./article";
|
} from "./article";
|
||||||
export { Audio } from "./audio";
|
export { Audio } from "./audio";
|
||||||
|
export { CreateNotionPageToolUI } from "./create-notion-page";
|
||||||
export {
|
export {
|
||||||
type DeepAgentThinkingArgs,
|
type DeepAgentThinkingArgs,
|
||||||
type DeepAgentThinkingResult,
|
type DeepAgentThinkingResult,
|
||||||
|
|
@ -78,6 +79,7 @@ export {
|
||||||
ScrapeWebpageResultSchema,
|
ScrapeWebpageResultSchema,
|
||||||
ScrapeWebpageToolUI,
|
ScrapeWebpageToolUI,
|
||||||
} from "./scrape-webpage";
|
} from "./scrape-webpage";
|
||||||
|
export { UpdateNotionPageToolUI } from "./update-notion-page";
|
||||||
export {
|
export {
|
||||||
type MemoryItem,
|
type MemoryItem,
|
||||||
type RecallMemoryArgs,
|
type RecallMemoryArgs,
|
||||||
|
|
|
||||||
486
surfsense_web/components/tool-ui/update-notion-page.tsx
Normal file
486
surfsense_web/components/tool-ui/update-notion-page.tsx
Normal file
|
|
@ -0,0 +1,486 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { makeAssistantToolUI } from "@assistant-ui/react";
|
||||||
|
import {
|
||||||
|
AlertTriangleIcon,
|
||||||
|
CheckIcon,
|
||||||
|
InfoIcon,
|
||||||
|
Loader2Icon,
|
||||||
|
MaximizeIcon,
|
||||||
|
MinimizeIcon,
|
||||||
|
PencilIcon,
|
||||||
|
XIcon,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
|
||||||
|
interface InterruptResult {
|
||||||
|
__interrupt__: true;
|
||||||
|
__decided__?: "approve" | "reject" | "edit";
|
||||||
|
action_requests: Array<{
|
||||||
|
name: string;
|
||||||
|
args: Record<string, unknown>;
|
||||||
|
description?: string;
|
||||||
|
}>;
|
||||||
|
review_configs: Array<{
|
||||||
|
action_name: string;
|
||||||
|
allowed_decisions: Array<"approve" | "edit" | "reject">;
|
||||||
|
}>;
|
||||||
|
interrupt_type?: string;
|
||||||
|
message?: string;
|
||||||
|
context?: {
|
||||||
|
account?: {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
workspace_id: string | null;
|
||||||
|
workspace_name: string;
|
||||||
|
workspace_icon: string;
|
||||||
|
};
|
||||||
|
page_id?: string;
|
||||||
|
current_title?: string;
|
||||||
|
document_id?: number;
|
||||||
|
indexed_at?: string;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface InfoResult {
|
||||||
|
status: "not_found";
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type UpdateNotionPageResult = InterruptResult | SuccessResult | ErrorResult | InfoResult;
|
||||||
|
|
||||||
|
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 isInfoResult(result: unknown): result is InfoResult {
|
||||||
|
return (
|
||||||
|
typeof result === "object" &&
|
||||||
|
result !== null &&
|
||||||
|
"status" in result &&
|
||||||
|
(result as InfoResult).status === "not_found"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ApprovalCard({
|
||||||
|
args,
|
||||||
|
interruptData,
|
||||||
|
onDecision,
|
||||||
|
}: {
|
||||||
|
args: Record<string, unknown>;
|
||||||
|
interruptData: InterruptResult;
|
||||||
|
onDecision: (decision: {
|
||||||
|
type: "approve" | "reject" | "edit";
|
||||||
|
message?: string;
|
||||||
|
edited_action?: { name: string; args: Record<string, unknown> };
|
||||||
|
}) => void;
|
||||||
|
}) {
|
||||||
|
const [decided, setDecided] = useState<"approve" | "reject" | "edit" | null>(
|
||||||
|
interruptData.__decided__ ?? null
|
||||||
|
);
|
||||||
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
|
const [isFullScreen, setIsFullScreen] = useState(false);
|
||||||
|
const [editedArgs, setEditedArgs] = useState<Record<string, unknown>>(args);
|
||||||
|
|
||||||
|
const account = interruptData.context?.account;
|
||||||
|
const currentTitle = interruptData.context?.current_title;
|
||||||
|
|
||||||
|
const reviewConfig = interruptData.review_configs[0];
|
||||||
|
const allowedDecisions = reviewConfig?.allowed_decisions ?? ["approve", "reject"];
|
||||||
|
const canEdit = allowedDecisions.includes("edit");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Backdrop for full-screen mode */}
|
||||||
|
{isFullScreen && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 bg-black/50 backdrop-blur-sm"
|
||||||
|
onClick={() => setIsFullScreen(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={`${
|
||||||
|
isFullScreen
|
||||||
|
? "fixed left-1/2 top-1/2 z-50 h-[90vh] flex max-h-300 w-[90vw] max-w-350 -translate-x-1/2 -translate-y-1/2 flex-col"
|
||||||
|
: "my-4 max-w-full"
|
||||||
|
} overflow-hidden rounded-xl bg-background shadow-xl transition-all duration-300 ${
|
||||||
|
decided
|
||||||
|
? "border border-border bg-card shadow-sm"
|
||||||
|
: "border-2 border-foreground/20 bg-muted/30 dark:bg-muted/10 shadow-lg animate-pulse-subtle"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`flex items-center gap-3 border-b ${
|
||||||
|
decided ? "border-border bg-card" : "border-foreground/15 bg-muted/40 dark:bg-muted/20"
|
||||||
|
} px-4 py-3`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`flex size-9 shrink-0 items-center justify-center rounded-lg ${
|
||||||
|
decided ? "bg-muted" : "bg-muted animate-pulse"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<AlertTriangleIcon
|
||||||
|
className={`size-4 ${decided ? "text-muted-foreground" : "text-foreground"}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className={`text-sm font-medium ${decided ? "text-foreground" : "text-foreground"}`}>
|
||||||
|
Update Notion Page
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
className={`truncate text-xs ${
|
||||||
|
decided ? "text-muted-foreground" : "text-muted-foreground"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isEditing ? "You can edit the arguments below" : "Requires your approval to proceed"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{isEditing && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => setIsFullScreen(!isFullScreen)}
|
||||||
|
className="shrink-0"
|
||||||
|
>
|
||||||
|
{isFullScreen ? (
|
||||||
|
<MinimizeIcon className="size-4" />
|
||||||
|
) : (
|
||||||
|
<MaximizeIcon className="size-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Context section - READ ONLY account and page info */}
|
||||||
|
{!decided && interruptData.context && (
|
||||||
|
<div className="border-b border-border px-4 py-3 bg-muted/30 space-y-3">
|
||||||
|
{interruptData.context.error ? (
|
||||||
|
<p className="text-sm text-destructive">{interruptData.context.error}</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{account && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="text-xs font-medium text-muted-foreground">Notion Account</div>
|
||||||
|
<div className="w-full rounded-md border border-input bg-muted/50 px-3 py-2 text-sm">
|
||||||
|
{account.workspace_icon} {account.workspace_name}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{currentTitle && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="text-xs font-medium text-muted-foreground">Current Page</div>
|
||||||
|
<div className="w-full rounded-md border border-input bg-muted/50 px-3 py-2 text-sm">
|
||||||
|
📄 {currentTitle}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Display mode - show proposed changes as read-only */}
|
||||||
|
{!isEditing && (
|
||||||
|
<div
|
||||||
|
className={`space-y-2 px-4 py-3 bg-card ${isFullScreen ? "flex-1 overflow-y-auto" : ""}`}
|
||||||
|
>
|
||||||
|
{args.content != null && (
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-medium text-muted-foreground">New Content</p>
|
||||||
|
<p className="line-clamp-4 text-sm whitespace-pre-wrap text-foreground">
|
||||||
|
{String(args.content)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{args.content == null && (
|
||||||
|
<p className="text-sm text-muted-foreground italic">No content update specified</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Edit mode - show editable form fields */}
|
||||||
|
{isEditing && !decided && (
|
||||||
|
<div
|
||||||
|
className={`px-4 py-3 bg-card ${isFullScreen ? "flex-1 flex flex-col overflow-hidden" : ""}`}
|
||||||
|
>
|
||||||
|
<label
|
||||||
|
htmlFor="notion-content"
|
||||||
|
className="text-xs font-medium text-muted-foreground mb-1.5 block"
|
||||||
|
>
|
||||||
|
New Content
|
||||||
|
</label>
|
||||||
|
<Textarea
|
||||||
|
id="notion-content"
|
||||||
|
value={String(editedArgs.content ?? "")}
|
||||||
|
onChange={(e) => setEditedArgs({ ...editedArgs, content: e.target.value || null })}
|
||||||
|
placeholder="Enter content to append to the page"
|
||||||
|
rows={isFullScreen ? undefined : 12}
|
||||||
|
className={`resize-none ${isFullScreen ? "flex-1 min-h-0" : ""}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Action buttons */}
|
||||||
|
<div
|
||||||
|
className={`flex items-center gap-2 border-t ${
|
||||||
|
decided ? "border-border bg-card" : "border-foreground/15 bg-muted/20 dark:bg-muted/10"
|
||||||
|
} px-4 py-3`}
|
||||||
|
>
|
||||||
|
{decided ? (
|
||||||
|
<p className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||||
|
{decided === "approve" || decided === "edit" ? (
|
||||||
|
<>
|
||||||
|
<CheckIcon className="size-3.5 text-green-500" />
|
||||||
|
{decided === "edit" ? "Approved with Changes" : "Approved"}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<XIcon className="size-3.5 text-destructive" />
|
||||||
|
Rejected
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
) : isEditing ? (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setDecided("edit");
|
||||||
|
setIsEditing(false);
|
||||||
|
setIsFullScreen(false);
|
||||||
|
onDecision({
|
||||||
|
type: "edit",
|
||||||
|
edited_action: {
|
||||||
|
name: interruptData.action_requests[0].name,
|
||||||
|
args: {
|
||||||
|
page_id: args.page_id,
|
||||||
|
content: editedArgs.content,
|
||||||
|
connector_id: account?.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CheckIcon />
|
||||||
|
Approve with Changes
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
setIsEditing(false);
|
||||||
|
setIsFullScreen(false);
|
||||||
|
setEditedArgs(args); // Reset to original args
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{allowedDecisions.includes("approve") && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setDecided("approve");
|
||||||
|
onDecision({
|
||||||
|
type: "approve",
|
||||||
|
edited_action: {
|
||||||
|
name: interruptData.action_requests[0].name,
|
||||||
|
args: {
|
||||||
|
page_id: args.page_id,
|
||||||
|
content: args.content,
|
||||||
|
connector_id: account?.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CheckIcon />
|
||||||
|
Approve
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{canEdit && (
|
||||||
|
<Button size="sm" variant="outline" onClick={() => setIsEditing(true)}>
|
||||||
|
<PencilIcon />
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{allowedDecisions.includes("reject") && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
setDecided("reject");
|
||||||
|
onDecision({ type: "reject", message: "User rejected the action." });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<XIcon />
|
||||||
|
Reject
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ErrorCard({ result }: { result: ErrorResult }) {
|
||||||
|
return (
|
||||||
|
<div className="my-4 max-w-md overflow-hidden rounded-xl border border-destructive/50 bg-card">
|
||||||
|
<div className="flex items-center gap-3 border-b border-destructive/50 px-4 py-3">
|
||||||
|
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-destructive/10">
|
||||||
|
<XIcon className="size-4 text-destructive" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-sm font-medium text-destructive">Failed to update Notion page</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="px-4 py-3">
|
||||||
|
<p className="text-sm text-muted-foreground">{result.message}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function InfoCard({ result }: { result: InfoResult }) {
|
||||||
|
return (
|
||||||
|
<div className="my-4 max-w-md overflow-hidden rounded-xl border border-amber-500/50 bg-card">
|
||||||
|
<div className="flex items-start gap-3 px-4 py-3">
|
||||||
|
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-amber-500/10">
|
||||||
|
<InfoIcon className="size-4 text-amber-500" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1 pt-2">
|
||||||
|
<p className="text-sm text-muted-foreground">{result.message}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SuccessCard({ result }: { result: SuccessResult }) {
|
||||||
|
return (
|
||||||
|
<div className="my-4 max-w-md overflow-hidden rounded-xl border border-border bg-card">
|
||||||
|
<div className="flex items-center gap-3 border-b border-border px-4 py-3">
|
||||||
|
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-green-500/10">
|
||||||
|
<CheckIcon className="size-4 text-green-500" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-[.8rem] text-muted-foreground">
|
||||||
|
{result.message || "Notion page updated successfully"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2 px-4 py-3 text-xs">
|
||||||
|
<div>
|
||||||
|
<span className="font-medium text-muted-foreground">Title: </span>
|
||||||
|
<span>{result.title}</span>
|
||||||
|
</div>
|
||||||
|
{result.url && (
|
||||||
|
<div>
|
||||||
|
<a
|
||||||
|
href={result.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-primary hover:underline"
|
||||||
|
>
|
||||||
|
Open in Notion
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const UpdateNotionPageToolUI = makeAssistantToolUI<
|
||||||
|
{ page_title: string; content: string },
|
||||||
|
UpdateNotionPageResult
|
||||||
|
>({
|
||||||
|
toolName: "update_notion_page",
|
||||||
|
render: function UpdateNotionPageUI({ args, result, status }) {
|
||||||
|
if (status.type === "running") {
|
||||||
|
return (
|
||||||
|
<div className="my-4 flex max-w-md items-center gap-3 rounded-xl border border-border bg-card px-4 py-3">
|
||||||
|
<Loader2Icon className="size-4 animate-spin text-muted-foreground" />
|
||||||
|
<p className="text-sm text-muted-foreground">Updating Notion page...</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!result) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isInterruptResult(result)) {
|
||||||
|
return (
|
||||||
|
<ApprovalCard
|
||||||
|
args={args}
|
||||||
|
interruptData={result}
|
||||||
|
onDecision={(decision) => {
|
||||||
|
const event = new CustomEvent("hitl-decision", {
|
||||||
|
detail: { decisions: [decision] },
|
||||||
|
});
|
||||||
|
window.dispatchEvent(event);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
typeof result === "object" &&
|
||||||
|
result !== null &&
|
||||||
|
"status" in result &&
|
||||||
|
(result as { status: string }).status === "rejected"
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isInfoResult(result)) {
|
||||||
|
return <InfoCard result={result} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isErrorResult(result)) {
|
||||||
|
return <ErrorCard result={result} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <SuccessCard result={result as SuccessResult} />;
|
||||||
|
},
|
||||||
|
});
|
||||||
151
surfsense_web/lib/chat/streaming-state.ts
Normal file
151
surfsense_web/lib/chat/streaming-state.ts
Normal file
|
|
@ -0,0 +1,151 @@
|
||||||
|
import type { ThreadMessageLike } from "@assistant-ui/react";
|
||||||
|
|
||||||
|
export interface ThinkingStepData {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
status: "pending" | "in_progress" | "completed";
|
||||||
|
items: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ContentPart =
|
||||||
|
| { type: "text"; text: string }
|
||||||
|
| {
|
||||||
|
type: "tool-call";
|
||||||
|
toolCallId: string;
|
||||||
|
toolName: string;
|
||||||
|
args: Record<string, unknown>;
|
||||||
|
result?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface ContentPartsState {
|
||||||
|
contentParts: ContentPart[];
|
||||||
|
currentTextPartIndex: number;
|
||||||
|
toolCallIndices: Map<string, number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function appendText(state: ContentPartsState, delta: string): void {
|
||||||
|
if (
|
||||||
|
state.currentTextPartIndex >= 0 &&
|
||||||
|
state.contentParts[state.currentTextPartIndex]?.type === "text"
|
||||||
|
) {
|
||||||
|
(state.contentParts[state.currentTextPartIndex] as { type: "text"; text: string }).text +=
|
||||||
|
delta;
|
||||||
|
} else {
|
||||||
|
state.contentParts.push({ type: "text", text: delta });
|
||||||
|
state.currentTextPartIndex = state.contentParts.length - 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addToolCall(
|
||||||
|
state: ContentPartsState,
|
||||||
|
toolsWithUI: Set<string>,
|
||||||
|
toolCallId: string,
|
||||||
|
toolName: string,
|
||||||
|
args: Record<string, unknown>
|
||||||
|
): void {
|
||||||
|
if (toolsWithUI.has(toolName)) {
|
||||||
|
state.contentParts.push({
|
||||||
|
type: "tool-call",
|
||||||
|
toolCallId,
|
||||||
|
toolName,
|
||||||
|
args,
|
||||||
|
});
|
||||||
|
state.toolCallIndices.set(toolCallId, state.contentParts.length - 1);
|
||||||
|
state.currentTextPartIndex = -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateToolCall(
|
||||||
|
state: ContentPartsState,
|
||||||
|
toolCallId: string,
|
||||||
|
update: { args?: Record<string, unknown>; result?: unknown }
|
||||||
|
): void {
|
||||||
|
const index = state.toolCallIndices.get(toolCallId);
|
||||||
|
if (index !== undefined && state.contentParts[index]?.type === "tool-call") {
|
||||||
|
const tc = state.contentParts[index] as ContentPart & { type: "tool-call" };
|
||||||
|
if (update.args) tc.args = update.args;
|
||||||
|
if (update.result !== undefined) tc.result = update.result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildContentForUI(
|
||||||
|
state: ContentPartsState,
|
||||||
|
toolsWithUI: Set<string>
|
||||||
|
): ThreadMessageLike["content"] {
|
||||||
|
const filtered = state.contentParts.filter((part) => {
|
||||||
|
if (part.type === "text") return part.text.length > 0;
|
||||||
|
if (part.type === "tool-call") return toolsWithUI.has(part.toolName);
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
return filtered.length > 0
|
||||||
|
? (filtered as ThreadMessageLike["content"])
|
||||||
|
: [{ type: "text", text: "" }];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildContentForPersistence(
|
||||||
|
state: ContentPartsState,
|
||||||
|
toolsWithUI: Set<string>,
|
||||||
|
currentThinkingSteps: Map<string, ThinkingStepData>
|
||||||
|
): unknown[] {
|
||||||
|
const parts: unknown[] = [];
|
||||||
|
|
||||||
|
if (currentThinkingSteps.size > 0) {
|
||||||
|
parts.push({
|
||||||
|
type: "thinking-steps",
|
||||||
|
steps: Array.from(currentThinkingSteps.values()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const part of state.contentParts) {
|
||||||
|
if (part.type === "text" && part.text.length > 0) {
|
||||||
|
parts.push(part);
|
||||||
|
} else if (part.type === "tool-call" && toolsWithUI.has(part.toolName)) {
|
||||||
|
parts.push(part);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts.length > 0 ? parts : [{ type: "text", text: "" }];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Async generator that reads an SSE stream and yields parsed JSON objects.
|
||||||
|
* Handles buffering, event splitting, and skips malformed JSON / [DONE] lines.
|
||||||
|
*/
|
||||||
|
export async function* readSSEStream(response: Response): AsyncGenerator<any> {
|
||||||
|
if (!response.body) {
|
||||||
|
throw new Error("No response body");
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = response.body.getReader();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let buffer = "";
|
||||||
|
|
||||||
|
try {
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) 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 {
|
||||||
|
yield JSON.parse(data);
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof SyntaxError) continue;
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
reader.releaseLock();
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue