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,
|
||||
}
|
||||
|
||||
# Disable Notion action tools if no Notion connector is configured
|
||||
modified_disabled_tools = list(disabled_tools) if disabled_tools else []
|
||||
has_notion_connector = (
|
||||
available_connectors is not None and "NOTION_CONNECTOR" in available_connectors
|
||||
)
|
||||
if not has_notion_connector:
|
||||
notion_tools = ["create_notion_page", "update_notion_page", "delete_notion_page"]
|
||||
modified_disabled_tools.extend(notion_tools)
|
||||
|
||||
# Build tools using the async registry (includes MCP tools)
|
||||
tools = await build_tools_async(
|
||||
dependencies=dependencies,
|
||||
enabled_tools=enabled_tools,
|
||||
disabled_tools=disabled_tools,
|
||||
disabled_tools=modified_disabled_tools,
|
||||
additional_tools=list(additional_tools) if additional_tools else None,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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 .link_preview import create_link_preview_tool
|
||||
from .mcp_tool import load_mcp_tools
|
||||
from .notion import (
|
||||
create_create_notion_page_tool,
|
||||
create_delete_notion_page_tool,
|
||||
create_update_notion_page_tool,
|
||||
)
|
||||
from .podcast import create_generate_podcast_tool
|
||||
from .report import create_generate_report_tool
|
||||
from .scrape_webpage import create_scrape_webpage_tool
|
||||
|
|
@ -212,15 +217,38 @@ BUILTIN_TOOLS: list[ToolDefinition] = [
|
|||
requires=["user_id", "search_space_id", "db_session", "thread_visibility"],
|
||||
),
|
||||
# =========================================================================
|
||||
# ADD YOUR CUSTOM TOOLS BELOW
|
||||
# NOTION TOOLS - create, update, delete pages
|
||||
# =========================================================================
|
||||
# Example:
|
||||
# ToolDefinition(
|
||||
# name="my_custom_tool",
|
||||
# description="What my tool does",
|
||||
# factory=lambda deps: create_my_custom_tool(...),
|
||||
# requires=["search_space_id"],
|
||||
# ),
|
||||
ToolDefinition(
|
||||
name="create_notion_page",
|
||||
description="Create a new page in the user's Notion workspace",
|
||||
factory=lambda deps: create_create_notion_page_tool(
|
||||
db_session=deps["db_session"],
|
||||
search_space_id=deps["search_space_id"],
|
||||
user_id=deps["user_id"],
|
||||
),
|
||||
requires=["db_session", "search_space_id", "user_id"],
|
||||
),
|
||||
ToolDefinition(
|
||||
name="update_notion_page",
|
||||
description="Append new content to an existing Notion page",
|
||||
factory=lambda deps: create_update_notion_page_tool(
|
||||
db_session=deps["db_session"],
|
||||
search_space_id=deps["search_space_id"],
|
||||
user_id=deps["user_id"],
|
||||
),
|
||||
requires=["db_session", "search_space_id", "user_id"],
|
||||
),
|
||||
ToolDefinition(
|
||||
name="delete_notion_page",
|
||||
description="Delete an existing Notion page",
|
||||
factory=lambda deps: create_delete_notion_page_tool(
|
||||
db_session=deps["db_session"],
|
||||
search_space_id=deps["search_space_id"],
|
||||
user_id=deps["user_id"],
|
||||
),
|
||||
requires=["db_session", "search_space_id", "user_id"],
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Any, TypeVar
|
||||
|
||||
|
|
@ -10,7 +11,6 @@ from sqlalchemy.future import select
|
|||
|
||||
from app.config import config
|
||||
from app.db import SearchSourceConnector
|
||||
from app.routes.notion_add_connector_route import refresh_notion_token
|
||||
from app.schemas.notion_auth_credentials import NotionAuthCredentialsBase
|
||||
from app.utils.oauth_security import TokenEncryption
|
||||
|
||||
|
|
@ -219,6 +219,7 @@ class NotionHistoryConnector:
|
|||
)
|
||||
|
||||
# Refresh token
|
||||
from app.routes.notion_add_connector_route import refresh_notion_token
|
||||
connector = await refresh_notion_token(self._session, connector)
|
||||
|
||||
# Reload credentials after refresh
|
||||
|
|
@ -777,3 +778,356 @@ class NotionHistoryConnector:
|
|||
|
||||
# Return empty string for unsupported block types
|
||||
return ""
|
||||
|
||||
# =========================================================================
|
||||
# WRITE OPERATIONS (create, update, delete pages)
|
||||
# =========================================================================
|
||||
|
||||
async def _get_first_accessible_parent(self) -> str | None:
|
||||
"""
|
||||
Get the first accessible page ID that can be used as a parent.
|
||||
|
||||
Returns:
|
||||
Page ID string, or None if no accessible pages found
|
||||
"""
|
||||
try:
|
||||
notion = await self._get_client()
|
||||
|
||||
# Search for pages, get most recently edited first
|
||||
response = await self._api_call_with_retry(
|
||||
notion.search,
|
||||
filter={"property": "object", "value": "page"},
|
||||
sort={"direction": "descending", "timestamp": "last_edited_time"},
|
||||
page_size=1, # We only need the first one
|
||||
)
|
||||
|
||||
results = response.get("results", [])
|
||||
if results:
|
||||
return results[0]["id"]
|
||||
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error finding accessible parent page: {e}")
|
||||
return None
|
||||
|
||||
def _markdown_to_blocks(self, markdown: str) -> list[dict[str, Any]]:
|
||||
"""
|
||||
Convert markdown content to Notion blocks.
|
||||
|
||||
This is a simple converter that handles basic markdown.
|
||||
For more complex markdown, consider using a proper markdown parser.
|
||||
|
||||
Args:
|
||||
markdown: Markdown content
|
||||
|
||||
Returns:
|
||||
List of Notion block objects
|
||||
"""
|
||||
blocks = []
|
||||
lines = markdown.split("\n")
|
||||
|
||||
for line in lines:
|
||||
line = line.strip()
|
||||
|
||||
if not line:
|
||||
continue
|
||||
|
||||
# Heading 1
|
||||
if line.startswith("# "):
|
||||
blocks.append({
|
||||
"object": "block",
|
||||
"type": "heading_1",
|
||||
"heading_1": {
|
||||
"rich_text": [{"type": "text", "text": {"content": line[2:]}}]
|
||||
},
|
||||
})
|
||||
# Heading 2
|
||||
elif line.startswith("## "):
|
||||
blocks.append({
|
||||
"object": "block",
|
||||
"type": "heading_2",
|
||||
"heading_2": {
|
||||
"rich_text": [{"type": "text", "text": {"content": line[3:]}}]
|
||||
},
|
||||
})
|
||||
# Heading 3
|
||||
elif line.startswith("### "):
|
||||
blocks.append({
|
||||
"object": "block",
|
||||
"type": "heading_3",
|
||||
"heading_3": {
|
||||
"rich_text": [{"type": "text", "text": {"content": line[4:]}}]
|
||||
},
|
||||
})
|
||||
# Bullet list
|
||||
elif line.startswith("- ") or line.startswith("* "):
|
||||
blocks.append({
|
||||
"object": "block",
|
||||
"type": "bulleted_list_item",
|
||||
"bulleted_list_item": {
|
||||
"rich_text": [{"type": "text", "text": {"content": line[2:]}}]
|
||||
},
|
||||
})
|
||||
# Numbered list
|
||||
elif (match := re.match(r'^(\d+)\.\s+(.*)$', line)):
|
||||
content = match.group(2) # Extract text after "number. "
|
||||
blocks.append({
|
||||
"object": "block",
|
||||
"type": "numbered_list_item",
|
||||
"numbered_list_item": {
|
||||
"rich_text": [{"type": "text", "text": {"content": content}}]
|
||||
},
|
||||
})
|
||||
# Regular paragraph
|
||||
else:
|
||||
blocks.append({
|
||||
"object": "block",
|
||||
"type": "paragraph",
|
||||
"paragraph": {
|
||||
"rich_text": [{"type": "text", "text": {"content": line}}]
|
||||
},
|
||||
})
|
||||
|
||||
return blocks
|
||||
|
||||
async def create_page(
|
||||
self, title: str, content: str, parent_page_id: str | None = None
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Create a new Notion page.
|
||||
|
||||
Args:
|
||||
title: Page title
|
||||
content: Page content (markdown format)
|
||||
parent_page_id: Optional parent page ID (creates as subpage if provided)
|
||||
|
||||
Returns:
|
||||
Dictionary with page details:
|
||||
- page_id: Created page ID
|
||||
- url: Page URL
|
||||
- title: Page title
|
||||
- status: "success" or "error"
|
||||
- message: Success/error message
|
||||
|
||||
Raises:
|
||||
APIResponseError: If Notion API returns an error
|
||||
"""
|
||||
try:
|
||||
logger.info(f"Creating Notion page: title='{title}', parent_page_id={parent_page_id}")
|
||||
|
||||
# Get Notion client
|
||||
notion = await self._get_client()
|
||||
|
||||
# Convert markdown content to Notion blocks
|
||||
children = self._markdown_to_blocks(content)
|
||||
|
||||
# Prepare parent - find first available page if not provided
|
||||
if not parent_page_id:
|
||||
logger.info("No parent_page_id provided, searching for first accessible page...")
|
||||
parent_page_id = await self._get_first_accessible_parent()
|
||||
if not parent_page_id:
|
||||
logger.warning("No accessible parent pages found")
|
||||
return {
|
||||
"status": "error",
|
||||
"message": "Could not find any accessible Notion pages to use as parent. "
|
||||
"Please make sure your Notion integration has access to at least one page.",
|
||||
}
|
||||
logger.info(f"Using parent_page_id: {parent_page_id}")
|
||||
|
||||
parent = {"type": "page_id", "page_id": parent_page_id}
|
||||
|
||||
# Create the page with standard title property
|
||||
properties = {
|
||||
"title": {
|
||||
"title": [{"type": "text", "text": {"content": title}}]
|
||||
}
|
||||
}
|
||||
|
||||
response = await self._api_call_with_retry(
|
||||
notion.pages.create,
|
||||
parent=parent,
|
||||
properties=properties,
|
||||
children=children[:100], # Notion API limit: 100 blocks per request
|
||||
)
|
||||
|
||||
page_id = response["id"]
|
||||
page_url = response["url"]
|
||||
|
||||
# If content has more than 100 blocks, append them
|
||||
if len(children) > 100:
|
||||
for i in range(100, len(children), 100):
|
||||
batch = children[i : i + 100]
|
||||
await self._api_call_with_retry(
|
||||
notion.blocks.children.append,
|
||||
block_id=page_id,
|
||||
children=batch
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"page_id": page_id,
|
||||
"url": page_url,
|
||||
"title": title,
|
||||
"message": f"Created Notion page '{title}'",
|
||||
}
|
||||
|
||||
except APIResponseError as e:
|
||||
logger.error(f"Notion API error creating page: {e}")
|
||||
error_msg = e.body.get("message", str(e)) if hasattr(e, "body") else str(e)
|
||||
return {
|
||||
"status": "error",
|
||||
"message": f"Failed to create Notion page: {error_msg}",
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error creating Notion page: {e}")
|
||||
return {
|
||||
"status": "error",
|
||||
"message": f"Failed to create Notion page: {e!s}",
|
||||
}
|
||||
|
||||
async def update_page(
|
||||
self, page_id: str, content: str | None = None
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Update an existing Notion page by appending new content.
|
||||
|
||||
Note: Content is appended to the page, not replaced.
|
||||
|
||||
Args:
|
||||
page_id: Page ID to update
|
||||
content: New markdown content to append to the page (optional)
|
||||
|
||||
Returns:
|
||||
Dictionary with update result
|
||||
|
||||
Raises:
|
||||
APIResponseError: If Notion API returns an error
|
||||
"""
|
||||
try:
|
||||
notion = await self._get_client()
|
||||
|
||||
# Append content if provided
|
||||
if content:
|
||||
# Convert new content to blocks
|
||||
try:
|
||||
children = self._markdown_to_blocks(content)
|
||||
if not children:
|
||||
logger.warning("No blocks generated from content, skipping append")
|
||||
return {
|
||||
"status": "error",
|
||||
"message": "Content conversion failed: no valid blocks generated",
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to convert markdown to blocks: {e}")
|
||||
return {
|
||||
"status": "error",
|
||||
"message": f"Failed to parse content: {e!s}",
|
||||
}
|
||||
|
||||
# Append new content blocks
|
||||
try:
|
||||
for i in range(0, len(children), 100):
|
||||
batch = children[i : i + 100]
|
||||
await self._api_call_with_retry(
|
||||
notion.blocks.children.append,
|
||||
block_id=page_id,
|
||||
children=batch
|
||||
)
|
||||
logger.info(f"Successfully appended {len(children)} new blocks to page {page_id}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to append content blocks: {e}")
|
||||
return {
|
||||
"status": "error",
|
||||
"message": f"Failed to append content: {e!s}",
|
||||
}
|
||||
|
||||
# Get updated page info
|
||||
response = await self._api_call_with_retry(
|
||||
notion.pages.retrieve,
|
||||
page_id=page_id
|
||||
)
|
||||
page_url = response["url"]
|
||||
page_title = response["properties"]["title"]["title"][0]["text"]["content"]
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"page_id": page_id,
|
||||
"url": page_url,
|
||||
"title": page_title,
|
||||
"message": f"Updated Notion page '{page_title}' (content appended)",
|
||||
}
|
||||
|
||||
except APIResponseError as e:
|
||||
logger.error(f"Notion API error updating page: {e}")
|
||||
error_msg = e.body.get("message", str(e)) if hasattr(e, "body") else str(e)
|
||||
return {
|
||||
"status": "error",
|
||||
"message": f"Failed to update Notion page: {error_msg}",
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error updating Notion page: {e}")
|
||||
return {
|
||||
"status": "error",
|
||||
"message": f"Failed to update Notion page: {e!s}",
|
||||
}
|
||||
|
||||
async def delete_page(self, page_id: str) -> dict[str, Any]:
|
||||
"""
|
||||
Delete (archive) a Notion page.
|
||||
|
||||
Note: Notion doesn't truly delete pages, it archives them.
|
||||
|
||||
Args:
|
||||
page_id: Page ID to delete
|
||||
|
||||
Returns:
|
||||
Dictionary with deletion result
|
||||
|
||||
Raises:
|
||||
APIResponseError: If Notion API returns an error
|
||||
"""
|
||||
try:
|
||||
notion = await self._get_client()
|
||||
|
||||
# Archive the page (Notion's way of "deleting")
|
||||
response = await self._api_call_with_retry(
|
||||
notion.pages.update,
|
||||
page_id=page_id,
|
||||
archived=True
|
||||
)
|
||||
|
||||
page_title = "Unknown"
|
||||
try:
|
||||
page_title = response["properties"]["title"]["title"][0]["text"][
|
||||
"content"
|
||||
]
|
||||
except (KeyError, IndexError):
|
||||
pass
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"page_id": page_id,
|
||||
"message": f"Deleted Notion page '{page_title}'",
|
||||
}
|
||||
|
||||
except APIResponseError as e:
|
||||
logger.error(f"Notion API error deleting page: {e}")
|
||||
# Handle both dict and string body formats
|
||||
if hasattr(e, "body"):
|
||||
if isinstance(e.body, dict):
|
||||
error_msg = e.body.get("message", str(e))
|
||||
else:
|
||||
error_msg = str(e.body) if e.body else str(e)
|
||||
else:
|
||||
error_msg = str(e)
|
||||
return {
|
||||
"status": "error",
|
||||
"message": f"Failed to delete Notion page: {error_msg}",
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error deleting Notion page: {e}")
|
||||
return {
|
||||
"status": "error",
|
||||
"message": f"Failed to delete Notion page: {e!s}",
|
||||
}
|
||||
|
|
|
|||
|
|
@ -43,11 +43,12 @@ from app.schemas.new_chat import (
|
|||
PublicChatSnapshotCreateResponse,
|
||||
PublicChatSnapshotListResponse,
|
||||
RegenerateRequest,
|
||||
ResumeRequest,
|
||||
ThreadHistoryLoadResponse,
|
||||
ThreadListItem,
|
||||
ThreadListResponse,
|
||||
)
|
||||
from app.tasks.chat.stream_new_chat import stream_new_chat
|
||||
from app.tasks.chat.stream_new_chat import stream_new_chat, stream_resume_chat
|
||||
from app.users import current_active_user
|
||||
from app.utils.rbac import check_permission
|
||||
|
||||
|
|
@ -1326,3 +1327,78 @@ async def regenerate_response(
|
|||
status_code=500,
|
||||
detail=f"An unexpected error occurred during regeneration: {e!s}",
|
||||
) from None
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Resume Interrupted Chat Endpoint
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@router.post("/threads/{thread_id}/resume")
|
||||
async def resume_chat(
|
||||
thread_id: int,
|
||||
request: ResumeRequest,
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
user: User = Depends(current_active_user),
|
||||
):
|
||||
try:
|
||||
result = await session.execute(
|
||||
select(NewChatThread).filter(NewChatThread.id == thread_id)
|
||||
)
|
||||
thread = result.scalars().first()
|
||||
|
||||
if not thread:
|
||||
raise HTTPException(status_code=404, detail="Thread not found")
|
||||
|
||||
await check_permission(
|
||||
session,
|
||||
user,
|
||||
thread.search_space_id,
|
||||
Permission.CHATS_CREATE.value,
|
||||
"You don't have permission to chat in this search space",
|
||||
)
|
||||
|
||||
await check_thread_access(session, thread, user)
|
||||
|
||||
search_space_result = await session.execute(
|
||||
select(SearchSpace).filter(SearchSpace.id == request.search_space_id)
|
||||
)
|
||||
search_space = search_space_result.scalars().first()
|
||||
|
||||
if not search_space:
|
||||
raise HTTPException(status_code=404, detail="Search space not found")
|
||||
|
||||
llm_config_id = (
|
||||
search_space.agent_llm_id if search_space.agent_llm_id is not None else -1
|
||||
)
|
||||
|
||||
decisions = [d.model_dump() for d in request.decisions]
|
||||
|
||||
return StreamingResponse(
|
||||
stream_resume_chat(
|
||||
chat_id=thread_id,
|
||||
search_space_id=request.search_space_id,
|
||||
decisions=decisions,
|
||||
session=session,
|
||||
user_id=str(user.id),
|
||||
llm_config_id=llm_config_id,
|
||||
thread_visibility=thread.visibility,
|
||||
),
|
||||
media_type="text/event-stream",
|
||||
headers={
|
||||
"Cache-Control": "no-cache",
|
||||
"Connection": "keep-alive",
|
||||
"X-Accel-Buffering": "no",
|
||||
},
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
import traceback
|
||||
|
||||
traceback.print_exc()
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"An unexpected error occurred during resume: {e!s}",
|
||||
) from None
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ These schemas follow the assistant-ui ThreadHistoryAdapter pattern:
|
|||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
from typing import Any, Literal
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
|
@ -193,6 +193,16 @@ class RegenerateRequest(BaseModel):
|
|||
mentioned_surfsense_doc_ids: list[int] | None = None
|
||||
|
||||
|
||||
class ResumeDecision(BaseModel):
|
||||
type: Literal["approve", "edit", "reject"]
|
||||
edited_action: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class ResumeRequest(BaseModel):
|
||||
search_space_id: int
|
||||
decisions: list[ResumeDecision]
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Public Chat Snapshot Schemas
|
||||
# =============================================================================
|
||||
|
|
|
|||
|
|
@ -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
|
||||
# =========================================================================
|
||||
|
|
|
|||
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);
|
||||
}
|
||||
|
||||
/* Human-in-the-loop approval card animations */
|
||||
@keyframes pulse-subtle {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
box-shadow: 0 0 0 0 rgb(0 0 0 / 0.15);
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
box-shadow: 0 0 20px 4px rgb(0 0 0 / 0.12);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-pulse-subtle {
|
||||
animation: pulse-subtle 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||
}
|
||||
|
||||
/* Integrations section — vertical column auto-scroll */
|
||||
@keyframes integrations-scroll-up {
|
||||
0% {
|
||||
|
|
|
|||
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,
|
||||
} from "./article";
|
||||
export { Audio } from "./audio";
|
||||
export { CreateNotionPageToolUI } from "./create-notion-page";
|
||||
export {
|
||||
type DeepAgentThinkingArgs,
|
||||
type DeepAgentThinkingResult,
|
||||
|
|
@ -78,6 +79,7 @@ export {
|
|||
ScrapeWebpageResultSchema,
|
||||
ScrapeWebpageToolUI,
|
||||
} from "./scrape-webpage";
|
||||
export { UpdateNotionPageToolUI } from "./update-notion-page";
|
||||
export {
|
||||
type MemoryItem,
|
||||
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