mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-29 19:35:20 +02:00
Merge pull request #811 from CREDO23/feature/human-in-the-loop
[Feature] Add Human-in-the-Loop for sensitives operations (create/update/delete)
This commit is contained in:
commit
4fdb165a5f
20 changed files with 4314 additions and 1261 deletions
|
|
@ -243,11 +243,20 @@ async def create_surfsense_deep_agent(
|
||||||
"available_document_types": available_document_types,
|
"available_document_types": available_document_types,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Disable Notion action tools if no Notion connector is configured
|
||||||
|
modified_disabled_tools = list(disabled_tools) if disabled_tools else []
|
||||||
|
has_notion_connector = (
|
||||||
|
available_connectors is not None and "NOTION_CONNECTOR" in available_connectors
|
||||||
|
)
|
||||||
|
if not has_notion_connector:
|
||||||
|
notion_tools = ["create_notion_page", "update_notion_page", "delete_notion_page"]
|
||||||
|
modified_disabled_tools.extend(notion_tools)
|
||||||
|
|
||||||
# Build tools using the async registry (includes MCP tools)
|
# Build tools using the async registry (includes MCP tools)
|
||||||
tools = await build_tools_async(
|
tools = await build_tools_async(
|
||||||
dependencies=dependencies,
|
dependencies=dependencies,
|
||||||
enabled_tools=enabled_tools,
|
enabled_tools=enabled_tools,
|
||||||
disabled_tools=disabled_tools,
|
disabled_tools=modified_disabled_tools,
|
||||||
additional_tools=list(additional_tools) if additional_tools else None,
|
additional_tools=list(additional_tools) if additional_tools else None,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
"""Notion tools for creating, updating, and deleting pages."""
|
||||||
|
|
||||||
|
from .create_page import create_create_notion_page_tool
|
||||||
|
from .delete_page import create_delete_notion_page_tool
|
||||||
|
from .update_page import create_update_notion_page_tool
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"create_create_notion_page_tool",
|
||||||
|
"create_delete_notion_page_tool",
|
||||||
|
"create_update_notion_page_tool",
|
||||||
|
]
|
||||||
|
|
@ -0,0 +1,210 @@
|
||||||
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from langchain_core.tools import tool
|
||||||
|
from langgraph.types import interrupt
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.connectors.notion_history import NotionHistoryConnector
|
||||||
|
from app.services.notion import NotionToolMetadataService
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def create_create_notion_page_tool(
|
||||||
|
db_session: AsyncSession | None = None,
|
||||||
|
search_space_id: int | None = None,
|
||||||
|
user_id: str | None = None,
|
||||||
|
connector_id: int | None = None,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Factory function to create the create_notion_page tool.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db_session: Database session for accessing Notion connector
|
||||||
|
search_space_id: Search space ID to find the Notion connector
|
||||||
|
user_id: User ID for fetching user-specific context
|
||||||
|
connector_id: Optional specific connector ID (if known)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Configured create_notion_page tool
|
||||||
|
"""
|
||||||
|
|
||||||
|
@tool
|
||||||
|
async def create_notion_page(
|
||||||
|
title: str,
|
||||||
|
content: str,
|
||||||
|
parent_page_id: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Create a new page in Notion with the given title and content.
|
||||||
|
|
||||||
|
Use this tool when the user asks you to create, save, or publish
|
||||||
|
something to Notion. The page will be created in the user's
|
||||||
|
configured Notion workspace.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
title: The title of the Notion page.
|
||||||
|
content: The markdown content for the page body (supports headings, lists, paragraphs).
|
||||||
|
parent_page_id: Optional parent page ID to create as a subpage.
|
||||||
|
If not provided, will ask for one.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with:
|
||||||
|
- status: "success", "rejected", or "error"
|
||||||
|
- page_id: Created page ID (if success)
|
||||||
|
- url: URL to the created page (if success)
|
||||||
|
- title: Page title (if success)
|
||||||
|
- message: Result message
|
||||||
|
|
||||||
|
IMPORTANT: If status is "rejected", the user explicitly declined the action.
|
||||||
|
Respond with a brief acknowledgment (e.g., "Understood, I didn't create the page.")
|
||||||
|
and move on. Do NOT ask for parent page IDs, troubleshoot, or suggest alternatives.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
- "Create a Notion page titled 'Meeting Notes' with content 'Discussed project timeline'"
|
||||||
|
- "Save this to Notion with title 'Research Summary'"
|
||||||
|
"""
|
||||||
|
logger.info(f"create_notion_page called: title='{title}', parent_page_id={parent_page_id}")
|
||||||
|
|
||||||
|
if db_session is None or search_space_id is None or user_id is None:
|
||||||
|
logger.error("Notion tool not properly configured - missing required parameters")
|
||||||
|
return {
|
||||||
|
"status": "error",
|
||||||
|
"message": "Notion tool not properly configured. Please contact support.",
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
metadata_service = NotionToolMetadataService(db_session)
|
||||||
|
context = await metadata_service.get_creation_context(search_space_id, user_id)
|
||||||
|
|
||||||
|
if "error" in context:
|
||||||
|
logger.error(f"Failed to fetch creation context: {context['error']}")
|
||||||
|
return {
|
||||||
|
"status": "error",
|
||||||
|
"message": context["error"],
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(f"Requesting approval for creating Notion page: '{title}'")
|
||||||
|
approval = interrupt({
|
||||||
|
"type": "notion_page_creation",
|
||||||
|
"action": {
|
||||||
|
"tool": "create_notion_page",
|
||||||
|
"params": {
|
||||||
|
"title": title,
|
||||||
|
"content": content,
|
||||||
|
"parent_page_id": parent_page_id,
|
||||||
|
"connector_id": connector_id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"context": context,
|
||||||
|
})
|
||||||
|
|
||||||
|
decisions = approval.get("decisions", [])
|
||||||
|
if not decisions:
|
||||||
|
logger.warning("No approval decision received")
|
||||||
|
return {
|
||||||
|
"status": "error",
|
||||||
|
"message": "No approval decision received",
|
||||||
|
}
|
||||||
|
|
||||||
|
decision = decisions[0]
|
||||||
|
decision_type = decision.get("type") or decision.get("decision_type")
|
||||||
|
logger.info(f"User decision: {decision_type}")
|
||||||
|
|
||||||
|
if decision_type == "reject":
|
||||||
|
logger.info("Notion page creation rejected by user")
|
||||||
|
return {
|
||||||
|
"status": "rejected",
|
||||||
|
"message": "User declined. The page was not created. Do not ask again or suggest alternatives.",
|
||||||
|
}
|
||||||
|
|
||||||
|
edited_action = decision.get("edited_action", {})
|
||||||
|
final_params = edited_action.get("args", {}) if edited_action else {}
|
||||||
|
|
||||||
|
final_title = final_params.get("title", title)
|
||||||
|
final_content = final_params.get("content", content)
|
||||||
|
final_parent_page_id = final_params.get("parent_page_id", parent_page_id)
|
||||||
|
final_connector_id = final_params.get("connector_id", connector_id)
|
||||||
|
|
||||||
|
if not final_title or not final_title.strip():
|
||||||
|
logger.error("Title is empty or contains only whitespace")
|
||||||
|
return {
|
||||||
|
"status": "error",
|
||||||
|
"message": "Page title cannot be empty. Please provide a valid title.",
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(f"Creating Notion page with final params: title='{final_title}'")
|
||||||
|
|
||||||
|
from sqlalchemy.future import select
|
||||||
|
|
||||||
|
from app.db import SearchSourceConnector, SearchSourceConnectorType
|
||||||
|
|
||||||
|
actual_connector_id = final_connector_id
|
||||||
|
if actual_connector_id is None:
|
||||||
|
result = await db_session.execute(
|
||||||
|
select(SearchSourceConnector).filter(
|
||||||
|
SearchSourceConnector.search_space_id == search_space_id,
|
||||||
|
SearchSourceConnector.user_id == user_id,
|
||||||
|
SearchSourceConnector.connector_type
|
||||||
|
== SearchSourceConnectorType.NOTION_CONNECTOR,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
connector = result.scalars().first()
|
||||||
|
|
||||||
|
if not connector:
|
||||||
|
logger.warning(f"No Notion connector found for search_space_id={search_space_id}")
|
||||||
|
return {
|
||||||
|
"status": "error",
|
||||||
|
"message": "No Notion connector found. Please connect Notion in your workspace settings.",
|
||||||
|
}
|
||||||
|
|
||||||
|
actual_connector_id = connector.id
|
||||||
|
logger.info(f"Found Notion connector: id={actual_connector_id}")
|
||||||
|
else:
|
||||||
|
result = await db_session.execute(
|
||||||
|
select(SearchSourceConnector).filter(
|
||||||
|
SearchSourceConnector.id == actual_connector_id,
|
||||||
|
SearchSourceConnector.search_space_id == search_space_id,
|
||||||
|
SearchSourceConnector.user_id == user_id,
|
||||||
|
SearchSourceConnector.connector_type
|
||||||
|
== SearchSourceConnectorType.NOTION_CONNECTOR,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
connector = result.scalars().first()
|
||||||
|
|
||||||
|
if not connector:
|
||||||
|
logger.error(
|
||||||
|
f"Invalid connector_id={actual_connector_id} for search_space_id={search_space_id}"
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"status": "error",
|
||||||
|
"message": "Selected Notion account is invalid or has been disconnected. Please select a valid account.",
|
||||||
|
}
|
||||||
|
logger.info(f"Validated Notion connector: id={actual_connector_id}")
|
||||||
|
|
||||||
|
notion_connector = NotionHistoryConnector(
|
||||||
|
session=db_session,
|
||||||
|
connector_id=actual_connector_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await notion_connector.create_page(
|
||||||
|
title=final_title,
|
||||||
|
content=final_content,
|
||||||
|
parent_page_id=final_parent_page_id,
|
||||||
|
)
|
||||||
|
logger.info(f"create_page result: {result.get('status')} - {result.get('message', '')}")
|
||||||
|
return result
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
from langgraph.errors import GraphInterrupt
|
||||||
|
|
||||||
|
if isinstance(e, GraphInterrupt):
|
||||||
|
raise
|
||||||
|
|
||||||
|
logger.error(f"Error creating Notion page: {e}", exc_info=True)
|
||||||
|
return {
|
||||||
|
"status": "error",
|
||||||
|
"message": str(e) if isinstance(e, ValueError) else f"Unexpected error: {e!s}",
|
||||||
|
}
|
||||||
|
|
||||||
|
return create_notion_page
|
||||||
|
|
@ -0,0 +1,238 @@
|
||||||
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from langchain_core.tools import tool
|
||||||
|
from langgraph.types import interrupt
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.connectors.notion_history import NotionHistoryConnector
|
||||||
|
from app.services.notion.tool_metadata_service import NotionToolMetadataService
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def create_delete_notion_page_tool(
|
||||||
|
db_session: AsyncSession | None = None,
|
||||||
|
search_space_id: int | None = None,
|
||||||
|
user_id: str | None = None,
|
||||||
|
connector_id: int | None = None,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Factory function to create the delete_notion_page tool.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db_session: Database session for accessing Notion connector
|
||||||
|
search_space_id: Search space ID to find the Notion connector
|
||||||
|
user_id: User ID for finding the correct Notion connector
|
||||||
|
connector_id: Optional specific connector ID (if known)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Configured delete_notion_page tool
|
||||||
|
"""
|
||||||
|
|
||||||
|
@tool
|
||||||
|
async def delete_notion_page(
|
||||||
|
page_title: str,
|
||||||
|
delete_from_db: bool = False,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Delete (archive) a Notion page.
|
||||||
|
|
||||||
|
Use this tool when the user asks you to delete, remove, or archive
|
||||||
|
a Notion page. Note that Notion doesn't permanently delete pages,
|
||||||
|
it archives them (they can be restored from trash).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
page_title: The title of the Notion page to delete.
|
||||||
|
delete_from_db: Whether to also remove the page from the knowledge base.
|
||||||
|
Default is False (in Notion).
|
||||||
|
Set to True to permanently remove from both Notion and knowledge base.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with:
|
||||||
|
- status: "success", "rejected", "not_found", or "error"
|
||||||
|
- page_id: Deleted page ID (if success)
|
||||||
|
- message: Success or error message
|
||||||
|
- deleted_from_db: Whether the page was also removed from knowledge base (if success)
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
- "Delete the 'Meeting Notes' Notion page"
|
||||||
|
- "Remove the 'Old Project Plan' Notion page"
|
||||||
|
- "Archive the 'Draft Ideas' Notion page"
|
||||||
|
"""
|
||||||
|
logger.info(f"delete_notion_page called: page_title='{page_title}', delete_from_db={delete_from_db}")
|
||||||
|
|
||||||
|
if db_session is None or search_space_id is None or user_id is None:
|
||||||
|
logger.error("Notion tool not properly configured - missing required parameters")
|
||||||
|
return {
|
||||||
|
"status": "error",
|
||||||
|
"message": "Notion tool not properly configured. Please contact support.",
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Get page context (page_id, account, title) from indexed data
|
||||||
|
metadata_service = NotionToolMetadataService(db_session)
|
||||||
|
context = await metadata_service.get_delete_context(
|
||||||
|
search_space_id, user_id, page_title
|
||||||
|
)
|
||||||
|
|
||||||
|
if "error" in context:
|
||||||
|
error_msg = context["error"]
|
||||||
|
# Check if it's a "not found" error (softer handling for LLM)
|
||||||
|
if "not found" in error_msg.lower():
|
||||||
|
logger.warning(f"Page not found: {error_msg}")
|
||||||
|
return {
|
||||||
|
"status": "not_found",
|
||||||
|
"message": error_msg,
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
logger.error(f"Failed to fetch delete context: {error_msg}")
|
||||||
|
return {
|
||||||
|
"status": "error",
|
||||||
|
"message": error_msg,
|
||||||
|
}
|
||||||
|
|
||||||
|
page_id = context.get("page_id")
|
||||||
|
connector_id_from_context = context.get("account", {}).get("id")
|
||||||
|
document_id = context.get("document_id")
|
||||||
|
|
||||||
|
logger.info(f"Requesting approval for deleting Notion page: '{page_title}' (page_id={page_id}, delete_from_db={delete_from_db})")
|
||||||
|
|
||||||
|
# Request approval before deleting
|
||||||
|
approval = interrupt(
|
||||||
|
{
|
||||||
|
"type": "notion_page_deletion",
|
||||||
|
"action": {
|
||||||
|
"tool": "delete_notion_page",
|
||||||
|
"params": {
|
||||||
|
"page_id": page_id,
|
||||||
|
"connector_id": connector_id_from_context,
|
||||||
|
"delete_from_db": delete_from_db,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"context": context,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
decisions = approval.get("decisions", [])
|
||||||
|
if not decisions:
|
||||||
|
logger.warning("No approval decision received")
|
||||||
|
return {
|
||||||
|
"status": "error",
|
||||||
|
"message": "No approval decision received",
|
||||||
|
}
|
||||||
|
|
||||||
|
decision = decisions[0]
|
||||||
|
decision_type = decision.get("type") or decision.get("decision_type")
|
||||||
|
logger.info(f"User decision: {decision_type}")
|
||||||
|
|
||||||
|
if decision_type == "reject":
|
||||||
|
logger.info("Notion page deletion rejected by user")
|
||||||
|
return {
|
||||||
|
"status": "rejected",
|
||||||
|
"message": "User declined. The page was not deleted. Do not ask again or suggest alternatives.",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Extract edited action arguments (if user modified the checkbox)
|
||||||
|
edited_action = decision.get("edited_action", {})
|
||||||
|
final_params = edited_action.get("args", {}) if edited_action else {}
|
||||||
|
|
||||||
|
final_page_id = final_params.get("page_id", page_id)
|
||||||
|
final_connector_id = final_params.get("connector_id", connector_id_from_context)
|
||||||
|
final_delete_from_db = final_params.get("delete_from_db", delete_from_db)
|
||||||
|
|
||||||
|
logger.info(f"Deleting Notion page with final params: page_id={final_page_id}, connector_id={final_connector_id}, delete_from_db={final_delete_from_db}")
|
||||||
|
|
||||||
|
from sqlalchemy.future import select
|
||||||
|
|
||||||
|
from app.db import SearchSourceConnector, SearchSourceConnectorType
|
||||||
|
|
||||||
|
# Validate the connector
|
||||||
|
if final_connector_id:
|
||||||
|
result = await db_session.execute(
|
||||||
|
select(SearchSourceConnector).filter(
|
||||||
|
SearchSourceConnector.id == final_connector_id,
|
||||||
|
SearchSourceConnector.search_space_id == search_space_id,
|
||||||
|
SearchSourceConnector.user_id == user_id,
|
||||||
|
SearchSourceConnector.connector_type
|
||||||
|
== SearchSourceConnectorType.NOTION_CONNECTOR,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
connector = result.scalars().first()
|
||||||
|
|
||||||
|
if not connector:
|
||||||
|
logger.error(
|
||||||
|
f"Invalid connector_id={final_connector_id} for search_space_id={search_space_id}"
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"status": "error",
|
||||||
|
"message": "Selected Notion account is invalid or has been disconnected. Please select a valid account.",
|
||||||
|
}
|
||||||
|
actual_connector_id = connector.id
|
||||||
|
logger.info(f"Validated Notion connector: id={actual_connector_id}")
|
||||||
|
else:
|
||||||
|
logger.error("No connector found for this page")
|
||||||
|
return {
|
||||||
|
"status": "error",
|
||||||
|
"message": "No connector found for this page.",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Create connector instance
|
||||||
|
notion_connector = NotionHistoryConnector(
|
||||||
|
session=db_session,
|
||||||
|
connector_id=actual_connector_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Delete the page from Notion
|
||||||
|
result = await notion_connector.delete_page(page_id=final_page_id)
|
||||||
|
logger.info(f"delete_page result: {result.get('status')} - {result.get('message', '')}")
|
||||||
|
|
||||||
|
# If deletion was successful and user wants to delete from DB
|
||||||
|
deleted_from_db = False
|
||||||
|
if result.get("status") == "success" and final_delete_from_db and document_id:
|
||||||
|
try:
|
||||||
|
from sqlalchemy.future import select
|
||||||
|
|
||||||
|
from app.db import Document
|
||||||
|
|
||||||
|
# Get the document
|
||||||
|
doc_result = await db_session.execute(
|
||||||
|
select(Document).filter(Document.id == document_id)
|
||||||
|
)
|
||||||
|
document = doc_result.scalars().first()
|
||||||
|
|
||||||
|
if document:
|
||||||
|
await db_session.delete(document)
|
||||||
|
await db_session.commit()
|
||||||
|
deleted_from_db = True
|
||||||
|
logger.info(f"Deleted document {document_id} from knowledge base")
|
||||||
|
else:
|
||||||
|
logger.warning(f"Document {document_id} not found in DB")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to delete document from DB: {e}")
|
||||||
|
# Don't fail the whole operation if DB deletion fails
|
||||||
|
# The page is already deleted from Notion, so inform the user
|
||||||
|
result["warning"] = f"Page deleted from Notion, but failed to remove from knowledge base: {e!s}"
|
||||||
|
|
||||||
|
# Update result with DB deletion status
|
||||||
|
if result.get("status") == "success":
|
||||||
|
result["deleted_from_db"] = deleted_from_db
|
||||||
|
if deleted_from_db:
|
||||||
|
result["message"] = f"{result.get('message', '')} (also removed from knowledge base)"
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
from langgraph.errors import GraphInterrupt
|
||||||
|
|
||||||
|
if isinstance(e, GraphInterrupt):
|
||||||
|
raise
|
||||||
|
|
||||||
|
logger.error(f"Error deleting Notion page: {e}", exc_info=True)
|
||||||
|
return {
|
||||||
|
"status": "error",
|
||||||
|
"message": str(e)
|
||||||
|
if isinstance(e, ValueError)
|
||||||
|
else f"Unexpected error: {e!s}",
|
||||||
|
}
|
||||||
|
|
||||||
|
return delete_notion_page
|
||||||
|
|
@ -0,0 +1,212 @@
|
||||||
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from langchain_core.tools import tool
|
||||||
|
from langgraph.types import interrupt
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.connectors.notion_history import NotionHistoryConnector
|
||||||
|
from app.services.notion import NotionToolMetadataService
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def create_update_notion_page_tool(
|
||||||
|
db_session: AsyncSession | None = None,
|
||||||
|
search_space_id: int | None = None,
|
||||||
|
user_id: str | None = None,
|
||||||
|
connector_id: int | None = None,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Factory function to create the update_notion_page tool.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db_session: Database session for accessing Notion connector
|
||||||
|
search_space_id: Search space ID to find the Notion connector
|
||||||
|
user_id: User ID for fetching user-specific context
|
||||||
|
connector_id: Optional specific connector ID (if known)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Configured update_notion_page tool
|
||||||
|
"""
|
||||||
|
|
||||||
|
@tool
|
||||||
|
async def update_notion_page(
|
||||||
|
page_title: str,
|
||||||
|
content: str,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Update an existing Notion page by appending new content.
|
||||||
|
|
||||||
|
Use this tool when the user asks you to add content to, modify, or update
|
||||||
|
a Notion page. The new content will be appended to the existing page content.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
page_title: The title of the Notion page to update.
|
||||||
|
content: The markdown content to append to the page body (supports headings, lists, paragraphs).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with:
|
||||||
|
- status: "success", "rejected", "not_found", or "error"
|
||||||
|
- page_id: Updated page ID (if success)
|
||||||
|
- url: URL to the updated page (if success)
|
||||||
|
- title: Current page title (if success)
|
||||||
|
- message: Result message
|
||||||
|
|
||||||
|
IMPORTANT:
|
||||||
|
- If status is "rejected", the user explicitly declined the action.
|
||||||
|
Respond with a brief acknowledgment (e.g., "Understood, I didn't update the page.")
|
||||||
|
and move on. Do NOT ask for alternatives or troubleshoot.
|
||||||
|
- If status is "not_found", inform the user conversationally using the exact message provided.
|
||||||
|
Example: "I couldn't find the page '[page_title]' in your indexed Notion pages. [message details]"
|
||||||
|
Do NOT treat this as an error. Do NOT invent information. Simply relay the message and
|
||||||
|
ask the user to verify the page title or check if it's been indexed.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
- "Add 'New meeting notes from today' to the 'Meeting Notes' Notion page"
|
||||||
|
- "Append the following to the 'Project Plan' Notion page: '# Status Update\n\nCompleted phase 1'"
|
||||||
|
"""
|
||||||
|
logger.info(f"update_notion_page called: page_title='{page_title}', content_length={len(content) if content else 0}")
|
||||||
|
|
||||||
|
if db_session is None or search_space_id is None or user_id is None:
|
||||||
|
logger.error("Notion tool not properly configured - missing required parameters")
|
||||||
|
return {
|
||||||
|
"status": "error",
|
||||||
|
"message": "Notion tool not properly configured. Please contact support.",
|
||||||
|
}
|
||||||
|
|
||||||
|
if not content or not content.strip():
|
||||||
|
logger.error(f"Empty content provided for page '{page_title}'")
|
||||||
|
return {
|
||||||
|
"status": "error",
|
||||||
|
"message": "Content is required to update the page. Please provide the actual content you want to add.",
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
metadata_service = NotionToolMetadataService(db_session)
|
||||||
|
context = await metadata_service.get_update_context(
|
||||||
|
search_space_id, user_id, page_title
|
||||||
|
)
|
||||||
|
|
||||||
|
if "error" in context:
|
||||||
|
error_msg = context["error"]
|
||||||
|
# Check if it's a "not found" error (softer handling for LLM)
|
||||||
|
if "not found" in error_msg.lower():
|
||||||
|
logger.warning(f"Page not found: {error_msg}")
|
||||||
|
return {
|
||||||
|
"status": "not_found",
|
||||||
|
"message": error_msg,
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
logger.error(f"Failed to fetch update context: {error_msg}")
|
||||||
|
return {
|
||||||
|
"status": "error",
|
||||||
|
"message": error_msg,
|
||||||
|
}
|
||||||
|
|
||||||
|
page_id = context.get("page_id")
|
||||||
|
connector_id_from_context = context.get("account", {}).get("id")
|
||||||
|
|
||||||
|
logger.info(f"Requesting approval for updating Notion page: '{page_title}' (page_id={page_id})")
|
||||||
|
approval = interrupt(
|
||||||
|
{
|
||||||
|
"type": "notion_page_update",
|
||||||
|
"action": {
|
||||||
|
"tool": "update_notion_page",
|
||||||
|
"params": {
|
||||||
|
"page_id": page_id,
|
||||||
|
"content": content,
|
||||||
|
"connector_id": connector_id_from_context,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"context": context,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
decisions = approval.get("decisions", [])
|
||||||
|
if not decisions:
|
||||||
|
logger.warning("No approval decision received")
|
||||||
|
return {
|
||||||
|
"status": "error",
|
||||||
|
"message": "No approval decision received",
|
||||||
|
}
|
||||||
|
|
||||||
|
decision = decisions[0]
|
||||||
|
decision_type = decision.get("type") or decision.get("decision_type")
|
||||||
|
logger.info(f"User decision: {decision_type}")
|
||||||
|
|
||||||
|
if decision_type == "reject":
|
||||||
|
logger.info("Notion page update rejected by user")
|
||||||
|
return {
|
||||||
|
"status": "rejected",
|
||||||
|
"message": "User declined. The page was not updated. Do not ask again or suggest alternatives.",
|
||||||
|
}
|
||||||
|
|
||||||
|
edited_action = decision.get("edited_action", {})
|
||||||
|
final_params = edited_action.get("args", {}) if edited_action else {}
|
||||||
|
|
||||||
|
final_page_id = final_params.get("page_id", page_id)
|
||||||
|
final_content = final_params.get("content", content)
|
||||||
|
final_connector_id = final_params.get("connector_id", connector_id_from_context)
|
||||||
|
|
||||||
|
logger.info(f"Updating Notion page with final params: page_id={final_page_id}, has_content={final_content is not None}")
|
||||||
|
|
||||||
|
from sqlalchemy.future import select
|
||||||
|
|
||||||
|
from app.db import SearchSourceConnector, SearchSourceConnectorType
|
||||||
|
|
||||||
|
if final_connector_id:
|
||||||
|
result = await db_session.execute(
|
||||||
|
select(SearchSourceConnector).filter(
|
||||||
|
SearchSourceConnector.id == final_connector_id,
|
||||||
|
SearchSourceConnector.search_space_id == search_space_id,
|
||||||
|
SearchSourceConnector.user_id == user_id,
|
||||||
|
SearchSourceConnector.connector_type
|
||||||
|
== SearchSourceConnectorType.NOTION_CONNECTOR,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
connector = result.scalars().first()
|
||||||
|
|
||||||
|
if not connector:
|
||||||
|
logger.error(
|
||||||
|
f"Invalid connector_id={final_connector_id} for search_space_id={search_space_id}"
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"status": "error",
|
||||||
|
"message": "Selected Notion account is invalid or has been disconnected. Please select a valid account.",
|
||||||
|
}
|
||||||
|
actual_connector_id = connector.id
|
||||||
|
logger.info(f"Validated Notion connector: id={actual_connector_id}")
|
||||||
|
else:
|
||||||
|
logger.error("No connector found for this page")
|
||||||
|
return {
|
||||||
|
"status": "error",
|
||||||
|
"message": "No connector found for this page.",
|
||||||
|
}
|
||||||
|
|
||||||
|
notion_connector = NotionHistoryConnector(
|
||||||
|
session=db_session,
|
||||||
|
connector_id=actual_connector_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await notion_connector.update_page(
|
||||||
|
page_id=final_page_id,
|
||||||
|
content=final_content,
|
||||||
|
)
|
||||||
|
logger.info(f"update_page result: {result.get('status')} - {result.get('message', '')}")
|
||||||
|
return result
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
from langgraph.errors import GraphInterrupt
|
||||||
|
|
||||||
|
if isinstance(e, GraphInterrupt):
|
||||||
|
raise
|
||||||
|
|
||||||
|
logger.error(f"Error updating Notion page: {e}", exc_info=True)
|
||||||
|
return {
|
||||||
|
"status": "error",
|
||||||
|
"message": str(e)
|
||||||
|
if isinstance(e, ValueError)
|
||||||
|
else f"Unexpected error: {e!s}",
|
||||||
|
}
|
||||||
|
|
||||||
|
return update_notion_page
|
||||||
|
|
@ -50,6 +50,11 @@ from .generate_image import create_generate_image_tool
|
||||||
from .knowledge_base import create_search_knowledge_base_tool
|
from .knowledge_base import create_search_knowledge_base_tool
|
||||||
from .link_preview import create_link_preview_tool
|
from .link_preview import create_link_preview_tool
|
||||||
from .mcp_tool import load_mcp_tools
|
from .mcp_tool import load_mcp_tools
|
||||||
|
from .notion import (
|
||||||
|
create_create_notion_page_tool,
|
||||||
|
create_delete_notion_page_tool,
|
||||||
|
create_update_notion_page_tool,
|
||||||
|
)
|
||||||
from .podcast import create_generate_podcast_tool
|
from .podcast import create_generate_podcast_tool
|
||||||
from .report import create_generate_report_tool
|
from .report import create_generate_report_tool
|
||||||
from .scrape_webpage import create_scrape_webpage_tool
|
from .scrape_webpage import create_scrape_webpage_tool
|
||||||
|
|
@ -212,15 +217,38 @@ BUILTIN_TOOLS: list[ToolDefinition] = [
|
||||||
requires=["user_id", "search_space_id", "db_session", "thread_visibility"],
|
requires=["user_id", "search_space_id", "db_session", "thread_visibility"],
|
||||||
),
|
),
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
# ADD YOUR CUSTOM TOOLS BELOW
|
# NOTION TOOLS - create, update, delete pages
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
# Example:
|
ToolDefinition(
|
||||||
# ToolDefinition(
|
name="create_notion_page",
|
||||||
# name="my_custom_tool",
|
description="Create a new page in the user's Notion workspace",
|
||||||
# description="What my tool does",
|
factory=lambda deps: create_create_notion_page_tool(
|
||||||
# factory=lambda deps: create_my_custom_tool(...),
|
db_session=deps["db_session"],
|
||||||
# requires=["search_space_id"],
|
search_space_id=deps["search_space_id"],
|
||||||
# ),
|
user_id=deps["user_id"],
|
||||||
|
),
|
||||||
|
requires=["db_session", "search_space_id", "user_id"],
|
||||||
|
),
|
||||||
|
ToolDefinition(
|
||||||
|
name="update_notion_page",
|
||||||
|
description="Append new content to an existing Notion page",
|
||||||
|
factory=lambda deps: create_update_notion_page_tool(
|
||||||
|
db_session=deps["db_session"],
|
||||||
|
search_space_id=deps["search_space_id"],
|
||||||
|
user_id=deps["user_id"],
|
||||||
|
),
|
||||||
|
requires=["db_session", "search_space_id", "user_id"],
|
||||||
|
),
|
||||||
|
ToolDefinition(
|
||||||
|
name="delete_notion_page",
|
||||||
|
description="Delete an existing Notion page",
|
||||||
|
factory=lambda deps: create_delete_notion_page_tool(
|
||||||
|
db_session=deps["db_session"],
|
||||||
|
search_space_id=deps["search_space_id"],
|
||||||
|
user_id=deps["user_id"],
|
||||||
|
),
|
||||||
|
requires=["db_session", "search_space_id", "user_id"],
|
||||||
|
),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
|
import re
|
||||||
from collections.abc import Awaitable, Callable
|
from collections.abc import Awaitable, Callable
|
||||||
from typing import Any, TypeVar
|
from typing import Any, TypeVar
|
||||||
|
|
||||||
|
|
@ -10,7 +11,6 @@ from sqlalchemy.future import select
|
||||||
|
|
||||||
from app.config import config
|
from app.config import config
|
||||||
from app.db import SearchSourceConnector
|
from app.db import SearchSourceConnector
|
||||||
from app.routes.notion_add_connector_route import refresh_notion_token
|
|
||||||
from app.schemas.notion_auth_credentials import NotionAuthCredentialsBase
|
from app.schemas.notion_auth_credentials import NotionAuthCredentialsBase
|
||||||
from app.utils.oauth_security import TokenEncryption
|
from app.utils.oauth_security import TokenEncryption
|
||||||
|
|
||||||
|
|
@ -219,6 +219,7 @@ class NotionHistoryConnector:
|
||||||
)
|
)
|
||||||
|
|
||||||
# Refresh token
|
# Refresh token
|
||||||
|
from app.routes.notion_add_connector_route import refresh_notion_token
|
||||||
connector = await refresh_notion_token(self._session, connector)
|
connector = await refresh_notion_token(self._session, connector)
|
||||||
|
|
||||||
# Reload credentials after refresh
|
# Reload credentials after refresh
|
||||||
|
|
@ -777,3 +778,356 @@ class NotionHistoryConnector:
|
||||||
|
|
||||||
# Return empty string for unsupported block types
|
# Return empty string for unsupported block types
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# WRITE OPERATIONS (create, update, delete pages)
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
async def _get_first_accessible_parent(self) -> str | None:
|
||||||
|
"""
|
||||||
|
Get the first accessible page ID that can be used as a parent.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Page ID string, or None if no accessible pages found
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
notion = await self._get_client()
|
||||||
|
|
||||||
|
# Search for pages, get most recently edited first
|
||||||
|
response = await self._api_call_with_retry(
|
||||||
|
notion.search,
|
||||||
|
filter={"property": "object", "value": "page"},
|
||||||
|
sort={"direction": "descending", "timestamp": "last_edited_time"},
|
||||||
|
page_size=1, # We only need the first one
|
||||||
|
)
|
||||||
|
|
||||||
|
results = response.get("results", [])
|
||||||
|
if results:
|
||||||
|
return results[0]["id"]
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error finding accessible parent page: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _markdown_to_blocks(self, markdown: str) -> list[dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Convert markdown content to Notion blocks.
|
||||||
|
|
||||||
|
This is a simple converter that handles basic markdown.
|
||||||
|
For more complex markdown, consider using a proper markdown parser.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
markdown: Markdown content
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of Notion block objects
|
||||||
|
"""
|
||||||
|
blocks = []
|
||||||
|
lines = markdown.split("\n")
|
||||||
|
|
||||||
|
for line in lines:
|
||||||
|
line = line.strip()
|
||||||
|
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Heading 1
|
||||||
|
if line.startswith("# "):
|
||||||
|
blocks.append({
|
||||||
|
"object": "block",
|
||||||
|
"type": "heading_1",
|
||||||
|
"heading_1": {
|
||||||
|
"rich_text": [{"type": "text", "text": {"content": line[2:]}}]
|
||||||
|
},
|
||||||
|
})
|
||||||
|
# Heading 2
|
||||||
|
elif line.startswith("## "):
|
||||||
|
blocks.append({
|
||||||
|
"object": "block",
|
||||||
|
"type": "heading_2",
|
||||||
|
"heading_2": {
|
||||||
|
"rich_text": [{"type": "text", "text": {"content": line[3:]}}]
|
||||||
|
},
|
||||||
|
})
|
||||||
|
# Heading 3
|
||||||
|
elif line.startswith("### "):
|
||||||
|
blocks.append({
|
||||||
|
"object": "block",
|
||||||
|
"type": "heading_3",
|
||||||
|
"heading_3": {
|
||||||
|
"rich_text": [{"type": "text", "text": {"content": line[4:]}}]
|
||||||
|
},
|
||||||
|
})
|
||||||
|
# Bullet list
|
||||||
|
elif line.startswith("- ") or line.startswith("* "):
|
||||||
|
blocks.append({
|
||||||
|
"object": "block",
|
||||||
|
"type": "bulleted_list_item",
|
||||||
|
"bulleted_list_item": {
|
||||||
|
"rich_text": [{"type": "text", "text": {"content": line[2:]}}]
|
||||||
|
},
|
||||||
|
})
|
||||||
|
# Numbered list
|
||||||
|
elif (match := re.match(r'^(\d+)\.\s+(.*)$', line)):
|
||||||
|
content = match.group(2) # Extract text after "number. "
|
||||||
|
blocks.append({
|
||||||
|
"object": "block",
|
||||||
|
"type": "numbered_list_item",
|
||||||
|
"numbered_list_item": {
|
||||||
|
"rich_text": [{"type": "text", "text": {"content": content}}]
|
||||||
|
},
|
||||||
|
})
|
||||||
|
# Regular paragraph
|
||||||
|
else:
|
||||||
|
blocks.append({
|
||||||
|
"object": "block",
|
||||||
|
"type": "paragraph",
|
||||||
|
"paragraph": {
|
||||||
|
"rich_text": [{"type": "text", "text": {"content": line}}]
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return blocks
|
||||||
|
|
||||||
|
async def create_page(
|
||||||
|
self, title: str, content: str, parent_page_id: str | None = None
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Create a new Notion page.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
title: Page title
|
||||||
|
content: Page content (markdown format)
|
||||||
|
parent_page_id: Optional parent page ID (creates as subpage if provided)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with page details:
|
||||||
|
- page_id: Created page ID
|
||||||
|
- url: Page URL
|
||||||
|
- title: Page title
|
||||||
|
- status: "success" or "error"
|
||||||
|
- message: Success/error message
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
APIResponseError: If Notion API returns an error
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
logger.info(f"Creating Notion page: title='{title}', parent_page_id={parent_page_id}")
|
||||||
|
|
||||||
|
# Get Notion client
|
||||||
|
notion = await self._get_client()
|
||||||
|
|
||||||
|
# Convert markdown content to Notion blocks
|
||||||
|
children = self._markdown_to_blocks(content)
|
||||||
|
|
||||||
|
# Prepare parent - find first available page if not provided
|
||||||
|
if not parent_page_id:
|
||||||
|
logger.info("No parent_page_id provided, searching for first accessible page...")
|
||||||
|
parent_page_id = await self._get_first_accessible_parent()
|
||||||
|
if not parent_page_id:
|
||||||
|
logger.warning("No accessible parent pages found")
|
||||||
|
return {
|
||||||
|
"status": "error",
|
||||||
|
"message": "Could not find any accessible Notion pages to use as parent. "
|
||||||
|
"Please make sure your Notion integration has access to at least one page.",
|
||||||
|
}
|
||||||
|
logger.info(f"Using parent_page_id: {parent_page_id}")
|
||||||
|
|
||||||
|
parent = {"type": "page_id", "page_id": parent_page_id}
|
||||||
|
|
||||||
|
# Create the page with standard title property
|
||||||
|
properties = {
|
||||||
|
"title": {
|
||||||
|
"title": [{"type": "text", "text": {"content": title}}]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
response = await self._api_call_with_retry(
|
||||||
|
notion.pages.create,
|
||||||
|
parent=parent,
|
||||||
|
properties=properties,
|
||||||
|
children=children[:100], # Notion API limit: 100 blocks per request
|
||||||
|
)
|
||||||
|
|
||||||
|
page_id = response["id"]
|
||||||
|
page_url = response["url"]
|
||||||
|
|
||||||
|
# If content has more than 100 blocks, append them
|
||||||
|
if len(children) > 100:
|
||||||
|
for i in range(100, len(children), 100):
|
||||||
|
batch = children[i : i + 100]
|
||||||
|
await self._api_call_with_retry(
|
||||||
|
notion.blocks.children.append,
|
||||||
|
block_id=page_id,
|
||||||
|
children=batch
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "success",
|
||||||
|
"page_id": page_id,
|
||||||
|
"url": page_url,
|
||||||
|
"title": title,
|
||||||
|
"message": f"Created Notion page '{title}'",
|
||||||
|
}
|
||||||
|
|
||||||
|
except APIResponseError as e:
|
||||||
|
logger.error(f"Notion API error creating page: {e}")
|
||||||
|
error_msg = e.body.get("message", str(e)) if hasattr(e, "body") else str(e)
|
||||||
|
return {
|
||||||
|
"status": "error",
|
||||||
|
"message": f"Failed to create Notion page: {error_msg}",
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Unexpected error creating Notion page: {e}")
|
||||||
|
return {
|
||||||
|
"status": "error",
|
||||||
|
"message": f"Failed to create Notion page: {e!s}",
|
||||||
|
}
|
||||||
|
|
||||||
|
async def update_page(
|
||||||
|
self, page_id: str, content: str | None = None
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Update an existing Notion page by appending new content.
|
||||||
|
|
||||||
|
Note: Content is appended to the page, not replaced.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
page_id: Page ID to update
|
||||||
|
content: New markdown content to append to the page (optional)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with update result
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
APIResponseError: If Notion API returns an error
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
notion = await self._get_client()
|
||||||
|
|
||||||
|
# Append content if provided
|
||||||
|
if content:
|
||||||
|
# Convert new content to blocks
|
||||||
|
try:
|
||||||
|
children = self._markdown_to_blocks(content)
|
||||||
|
if not children:
|
||||||
|
logger.warning("No blocks generated from content, skipping append")
|
||||||
|
return {
|
||||||
|
"status": "error",
|
||||||
|
"message": "Content conversion failed: no valid blocks generated",
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to convert markdown to blocks: {e}")
|
||||||
|
return {
|
||||||
|
"status": "error",
|
||||||
|
"message": f"Failed to parse content: {e!s}",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Append new content blocks
|
||||||
|
try:
|
||||||
|
for i in range(0, len(children), 100):
|
||||||
|
batch = children[i : i + 100]
|
||||||
|
await self._api_call_with_retry(
|
||||||
|
notion.blocks.children.append,
|
||||||
|
block_id=page_id,
|
||||||
|
children=batch
|
||||||
|
)
|
||||||
|
logger.info(f"Successfully appended {len(children)} new blocks to page {page_id}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to append content blocks: {e}")
|
||||||
|
return {
|
||||||
|
"status": "error",
|
||||||
|
"message": f"Failed to append content: {e!s}",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Get updated page info
|
||||||
|
response = await self._api_call_with_retry(
|
||||||
|
notion.pages.retrieve,
|
||||||
|
page_id=page_id
|
||||||
|
)
|
||||||
|
page_url = response["url"]
|
||||||
|
page_title = response["properties"]["title"]["title"][0]["text"]["content"]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "success",
|
||||||
|
"page_id": page_id,
|
||||||
|
"url": page_url,
|
||||||
|
"title": page_title,
|
||||||
|
"message": f"Updated Notion page '{page_title}' (content appended)",
|
||||||
|
}
|
||||||
|
|
||||||
|
except APIResponseError as e:
|
||||||
|
logger.error(f"Notion API error updating page: {e}")
|
||||||
|
error_msg = e.body.get("message", str(e)) if hasattr(e, "body") else str(e)
|
||||||
|
return {
|
||||||
|
"status": "error",
|
||||||
|
"message": f"Failed to update Notion page: {error_msg}",
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Unexpected error updating Notion page: {e}")
|
||||||
|
return {
|
||||||
|
"status": "error",
|
||||||
|
"message": f"Failed to update Notion page: {e!s}",
|
||||||
|
}
|
||||||
|
|
||||||
|
async def delete_page(self, page_id: str) -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Delete (archive) a Notion page.
|
||||||
|
|
||||||
|
Note: Notion doesn't truly delete pages, it archives them.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
page_id: Page ID to delete
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with deletion result
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
APIResponseError: If Notion API returns an error
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
notion = await self._get_client()
|
||||||
|
|
||||||
|
# Archive the page (Notion's way of "deleting")
|
||||||
|
response = await self._api_call_with_retry(
|
||||||
|
notion.pages.update,
|
||||||
|
page_id=page_id,
|
||||||
|
archived=True
|
||||||
|
)
|
||||||
|
|
||||||
|
page_title = "Unknown"
|
||||||
|
try:
|
||||||
|
page_title = response["properties"]["title"]["title"][0]["text"][
|
||||||
|
"content"
|
||||||
|
]
|
||||||
|
except (KeyError, IndexError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "success",
|
||||||
|
"page_id": page_id,
|
||||||
|
"message": f"Deleted Notion page '{page_title}'",
|
||||||
|
}
|
||||||
|
|
||||||
|
except APIResponseError as e:
|
||||||
|
logger.error(f"Notion API error deleting page: {e}")
|
||||||
|
# Handle both dict and string body formats
|
||||||
|
if hasattr(e, "body"):
|
||||||
|
if isinstance(e.body, dict):
|
||||||
|
error_msg = e.body.get("message", str(e))
|
||||||
|
else:
|
||||||
|
error_msg = str(e.body) if e.body else str(e)
|
||||||
|
else:
|
||||||
|
error_msg = str(e)
|
||||||
|
return {
|
||||||
|
"status": "error",
|
||||||
|
"message": f"Failed to delete Notion page: {error_msg}",
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Unexpected error deleting Notion page: {e}")
|
||||||
|
return {
|
||||||
|
"status": "error",
|
||||||
|
"message": f"Failed to delete Notion page: {e!s}",
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -43,11 +43,12 @@ from app.schemas.new_chat import (
|
||||||
PublicChatSnapshotCreateResponse,
|
PublicChatSnapshotCreateResponse,
|
||||||
PublicChatSnapshotListResponse,
|
PublicChatSnapshotListResponse,
|
||||||
RegenerateRequest,
|
RegenerateRequest,
|
||||||
|
ResumeRequest,
|
||||||
ThreadHistoryLoadResponse,
|
ThreadHistoryLoadResponse,
|
||||||
ThreadListItem,
|
ThreadListItem,
|
||||||
ThreadListResponse,
|
ThreadListResponse,
|
||||||
)
|
)
|
||||||
from app.tasks.chat.stream_new_chat import stream_new_chat
|
from app.tasks.chat.stream_new_chat import stream_new_chat, stream_resume_chat
|
||||||
from app.users import current_active_user
|
from app.users import current_active_user
|
||||||
from app.utils.rbac import check_permission
|
from app.utils.rbac import check_permission
|
||||||
|
|
||||||
|
|
@ -1326,3 +1327,78 @@ async def regenerate_response(
|
||||||
status_code=500,
|
status_code=500,
|
||||||
detail=f"An unexpected error occurred during regeneration: {e!s}",
|
detail=f"An unexpected error occurred during regeneration: {e!s}",
|
||||||
) from None
|
) from None
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Resume Interrupted Chat Endpoint
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/threads/{thread_id}/resume")
|
||||||
|
async def resume_chat(
|
||||||
|
thread_id: int,
|
||||||
|
request: ResumeRequest,
|
||||||
|
session: AsyncSession = Depends(get_async_session),
|
||||||
|
user: User = Depends(current_active_user),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
result = await session.execute(
|
||||||
|
select(NewChatThread).filter(NewChatThread.id == thread_id)
|
||||||
|
)
|
||||||
|
thread = result.scalars().first()
|
||||||
|
|
||||||
|
if not thread:
|
||||||
|
raise HTTPException(status_code=404, detail="Thread not found")
|
||||||
|
|
||||||
|
await check_permission(
|
||||||
|
session,
|
||||||
|
user,
|
||||||
|
thread.search_space_id,
|
||||||
|
Permission.CHATS_CREATE.value,
|
||||||
|
"You don't have permission to chat in this search space",
|
||||||
|
)
|
||||||
|
|
||||||
|
await check_thread_access(session, thread, user)
|
||||||
|
|
||||||
|
search_space_result = await session.execute(
|
||||||
|
select(SearchSpace).filter(SearchSpace.id == request.search_space_id)
|
||||||
|
)
|
||||||
|
search_space = search_space_result.scalars().first()
|
||||||
|
|
||||||
|
if not search_space:
|
||||||
|
raise HTTPException(status_code=404, detail="Search space not found")
|
||||||
|
|
||||||
|
llm_config_id = (
|
||||||
|
search_space.agent_llm_id if search_space.agent_llm_id is not None else -1
|
||||||
|
)
|
||||||
|
|
||||||
|
decisions = [d.model_dump() for d in request.decisions]
|
||||||
|
|
||||||
|
return StreamingResponse(
|
||||||
|
stream_resume_chat(
|
||||||
|
chat_id=thread_id,
|
||||||
|
search_space_id=request.search_space_id,
|
||||||
|
decisions=decisions,
|
||||||
|
session=session,
|
||||||
|
user_id=str(user.id),
|
||||||
|
llm_config_id=llm_config_id,
|
||||||
|
thread_visibility=thread.visibility,
|
||||||
|
),
|
||||||
|
media_type="text/event-stream",
|
||||||
|
headers={
|
||||||
|
"Cache-Control": "no-cache",
|
||||||
|
"Connection": "keep-alive",
|
||||||
|
"X-Accel-Buffering": "no",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
traceback.print_exc()
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500,
|
||||||
|
detail=f"An unexpected error occurred during resume: {e!s}",
|
||||||
|
) from None
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ These schemas follow the assistant-ui ThreadHistoryAdapter pattern:
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Any
|
from typing import Any, Literal
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
@ -193,6 +193,16 @@ class RegenerateRequest(BaseModel):
|
||||||
mentioned_surfsense_doc_ids: list[int] | None = None
|
mentioned_surfsense_doc_ids: list[int] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ResumeDecision(BaseModel):
|
||||||
|
type: Literal["approve", "edit", "reject"]
|
||||||
|
edited_action: dict[str, Any] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ResumeRequest(BaseModel):
|
||||||
|
search_space_id: int
|
||||||
|
decisions: list[ResumeDecision]
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Public Chat Snapshot Schemas
|
# Public Chat Snapshot Schemas
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
|
||||||
|
|
@ -504,6 +504,61 @@ class VercelStreamingService:
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def format_interrupt_request(self, interrupt_value: dict[str, Any]) -> str:
|
||||||
|
"""Format an interrupt request for human-in-the-loop approval.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
interrupt_value: The interrupt payload from either:
|
||||||
|
- interrupt_on config: {action_requests: [...], review_configs: [...]}
|
||||||
|
- interrupt() primitive: {type: "...", message: "...", action: {...}, context: {...}}
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: SSE formatted interrupt request data part
|
||||||
|
"""
|
||||||
|
normalized_payload = self._normalize_interrupt_payload(interrupt_value)
|
||||||
|
return self.format_data("interrupt-request", normalized_payload)
|
||||||
|
|
||||||
|
def _normalize_interrupt_payload(self, interrupt_value: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""Normalize interrupt payloads from different sources into a consistent format.
|
||||||
|
|
||||||
|
Handles two interrupt sources:
|
||||||
|
1. interrupt_on config (Deep Agent built-in): Already has action_requests/review_configs
|
||||||
|
2. interrupt() primitive (custom tool code): Has type/action/context (message is optional)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
interrupt_value: Raw interrupt payload from Deep Agent
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: Normalized payload with action_requests, review_configs, and optional context/message
|
||||||
|
"""
|
||||||
|
if "action_requests" in interrupt_value and "review_configs" in interrupt_value:
|
||||||
|
return interrupt_value
|
||||||
|
|
||||||
|
interrupt_type = interrupt_value.get("type", "unknown")
|
||||||
|
message = interrupt_value.get("message")
|
||||||
|
action = interrupt_value.get("action", {})
|
||||||
|
context = interrupt_value.get("context", {})
|
||||||
|
|
||||||
|
normalized = {
|
||||||
|
"action_requests": [
|
||||||
|
{
|
||||||
|
"name": action.get("tool", "unknown_tool"),
|
||||||
|
"args": action.get("params", {}),
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"review_configs": [
|
||||||
|
{
|
||||||
|
"action_name": action.get("tool", "unknown_tool"),
|
||||||
|
"allowed_decisions": ["approve", "edit", "reject"],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"interrupt_type": interrupt_type,
|
||||||
|
"context": context,
|
||||||
|
}
|
||||||
|
if message:
|
||||||
|
normalized["message"] = message
|
||||||
|
return normalized
|
||||||
|
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
# Error Part
|
# Error Part
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
|
|
|
||||||
11
surfsense_backend/app/services/notion/__init__.py
Normal file
11
surfsense_backend/app/services/notion/__init__.py
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
from app.services.notion.tool_metadata_service import (
|
||||||
|
NotionAccount,
|
||||||
|
NotionPage,
|
||||||
|
NotionToolMetadataService,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"NotionAccount",
|
||||||
|
"NotionPage",
|
||||||
|
"NotionToolMetadataService",
|
||||||
|
]
|
||||||
200
surfsense_backend/app/services/notion/tool_metadata_service.py
Normal file
200
surfsense_backend/app/services/notion/tool_metadata_service.py
Normal file
|
|
@ -0,0 +1,200 @@
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from sqlalchemy import and_, func
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy.future import select
|
||||||
|
|
||||||
|
from app.db import (
|
||||||
|
Document,
|
||||||
|
DocumentType,
|
||||||
|
SearchSourceConnector,
|
||||||
|
SearchSourceConnectorType,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class NotionAccount:
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
|
workspace_id: str | None
|
||||||
|
workspace_name: str
|
||||||
|
workspace_icon: str
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_connector(cls, connector: SearchSourceConnector) -> "NotionAccount":
|
||||||
|
return cls(
|
||||||
|
id=connector.id,
|
||||||
|
name=connector.name,
|
||||||
|
workspace_id=connector.config.get("workspace_id"),
|
||||||
|
workspace_name=connector.config.get("workspace_name", "Unnamed Workspace"),
|
||||||
|
workspace_icon=connector.config.get("workspace_icon", "📄"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
return {
|
||||||
|
"id": self.id,
|
||||||
|
"name": self.name,
|
||||||
|
"workspace_id": self.workspace_id,
|
||||||
|
"workspace_name": self.workspace_name,
|
||||||
|
"workspace_icon": self.workspace_icon,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class NotionPage:
|
||||||
|
page_id: str
|
||||||
|
title: str
|
||||||
|
connector_id: int
|
||||||
|
document_id: int
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_document(cls, document: Document) -> "NotionPage":
|
||||||
|
return cls(
|
||||||
|
page_id=document.document_metadata.get("page_id"),
|
||||||
|
title=document.title,
|
||||||
|
connector_id=document.connector_id,
|
||||||
|
document_id=document.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
return {
|
||||||
|
"page_id": self.page_id,
|
||||||
|
"title": self.title,
|
||||||
|
"connector_id": self.connector_id,
|
||||||
|
"document_id": self.document_id,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class NotionToolMetadataService:
|
||||||
|
def __init__(self, db_session: AsyncSession):
|
||||||
|
self._db_session = db_session
|
||||||
|
|
||||||
|
async def get_creation_context(self, search_space_id: int, user_id: str) -> dict:
|
||||||
|
accounts = await self._get_notion_accounts(search_space_id, user_id)
|
||||||
|
|
||||||
|
if not accounts:
|
||||||
|
return {
|
||||||
|
"accounts": [],
|
||||||
|
"parent_pages": {},
|
||||||
|
"error": "No Notion accounts connected",
|
||||||
|
}
|
||||||
|
|
||||||
|
parent_pages = await self._get_parent_pages_by_account(
|
||||||
|
search_space_id, accounts
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"accounts": [acc.to_dict() for acc in accounts],
|
||||||
|
"parent_pages": parent_pages,
|
||||||
|
}
|
||||||
|
|
||||||
|
async def get_update_context(
|
||||||
|
self, search_space_id: int, user_id: str, page_title: str
|
||||||
|
) -> dict:
|
||||||
|
result = await self._db_session.execute(
|
||||||
|
select(Document)
|
||||||
|
.join(
|
||||||
|
SearchSourceConnector, Document.connector_id == SearchSourceConnector.id
|
||||||
|
)
|
||||||
|
.filter(
|
||||||
|
and_(
|
||||||
|
Document.search_space_id == search_space_id,
|
||||||
|
Document.document_type == DocumentType.NOTION_CONNECTOR,
|
||||||
|
func.lower(Document.title) == func.lower(page_title),
|
||||||
|
SearchSourceConnector.user_id == user_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
document = result.scalars().first()
|
||||||
|
|
||||||
|
if not document:
|
||||||
|
return {
|
||||||
|
"error": f"Page '{page_title}' not found in your indexed Notion pages. "
|
||||||
|
"This could mean: (1) the page doesn't exist, (2) it hasn't been indexed yet, "
|
||||||
|
"or (3) the page title is different. Please check the exact page title in Notion."
|
||||||
|
}
|
||||||
|
|
||||||
|
if not document.connector_id:
|
||||||
|
return {"error": "Document has no associated connector"}
|
||||||
|
|
||||||
|
result = await self._db_session.execute(
|
||||||
|
select(SearchSourceConnector).filter(
|
||||||
|
and_(
|
||||||
|
SearchSourceConnector.id == document.connector_id,
|
||||||
|
SearchSourceConnector.user_id == user_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
connector = result.scalars().first()
|
||||||
|
|
||||||
|
if not connector:
|
||||||
|
return {"error": "Connector not found or access denied"}
|
||||||
|
|
||||||
|
account = NotionAccount.from_connector(connector)
|
||||||
|
|
||||||
|
page_id = document.document_metadata.get("page_id")
|
||||||
|
if not page_id:
|
||||||
|
return {"error": "Page ID not found in document metadata"}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"account": account.to_dict(),
|
||||||
|
"page_id": page_id,
|
||||||
|
"current_title": document.title,
|
||||||
|
"document_id": document.id,
|
||||||
|
"indexed_at": document.document_metadata.get("indexed_at"),
|
||||||
|
}
|
||||||
|
|
||||||
|
async def get_delete_context(
|
||||||
|
self, search_space_id: int, user_id: str, page_title: str
|
||||||
|
) -> dict:
|
||||||
|
return await self.get_update_context(search_space_id, user_id, page_title)
|
||||||
|
|
||||||
|
async def _get_notion_accounts(
|
||||||
|
self, search_space_id: int, user_id: str
|
||||||
|
) -> list[NotionAccount]:
|
||||||
|
result = await self._db_session.execute(
|
||||||
|
select(SearchSourceConnector)
|
||||||
|
.filter(
|
||||||
|
and_(
|
||||||
|
SearchSourceConnector.search_space_id == search_space_id,
|
||||||
|
SearchSourceConnector.user_id == user_id,
|
||||||
|
SearchSourceConnector.connector_type
|
||||||
|
== SearchSourceConnectorType.NOTION_CONNECTOR,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.order_by(SearchSourceConnector.last_indexed_at.desc())
|
||||||
|
)
|
||||||
|
connectors = result.scalars().all()
|
||||||
|
return [NotionAccount.from_connector(conn) for conn in connectors]
|
||||||
|
|
||||||
|
async def _get_parent_pages_by_account(
|
||||||
|
self, search_space_id: int, accounts: list[NotionAccount]
|
||||||
|
) -> dict:
|
||||||
|
parent_pages = {}
|
||||||
|
|
||||||
|
for account in accounts:
|
||||||
|
result = await self._db_session.execute(
|
||||||
|
select(Document)
|
||||||
|
.filter(
|
||||||
|
and_(
|
||||||
|
Document.search_space_id == search_space_id,
|
||||||
|
Document.connector_id == account.id,
|
||||||
|
Document.document_type == DocumentType.NOTION_CONNECTOR,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.order_by(Document.updated_at.desc())
|
||||||
|
.limit(50)
|
||||||
|
)
|
||||||
|
documents = result.scalars().all()
|
||||||
|
|
||||||
|
parent_pages[account.id] = [
|
||||||
|
{
|
||||||
|
"page_id": doc.document_metadata.get("page_id"),
|
||||||
|
"title": doc.title,
|
||||||
|
"document_id": doc.id,
|
||||||
|
}
|
||||||
|
for doc in documents
|
||||||
|
if doc.document_metadata.get("page_id")
|
||||||
|
]
|
||||||
|
|
||||||
|
return parent_pages
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -33,13 +33,16 @@ import { membersAtom } from "@/atoms/members/members-query.atoms";
|
||||||
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
|
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
|
||||||
import { Thread } from "@/components/assistant-ui/thread";
|
import { Thread } from "@/components/assistant-ui/thread";
|
||||||
import { ChatHeader } from "@/components/new-chat/chat-header";
|
import { ChatHeader } from "@/components/new-chat/chat-header";
|
||||||
|
import { CreateNotionPageToolUI } from "@/components/tool-ui/create-notion-page";
|
||||||
import { ReportPanel } from "@/components/report-panel/report-panel";
|
import { ReportPanel } from "@/components/report-panel/report-panel";
|
||||||
import type { ThinkingStep } from "@/components/tool-ui/deepagent-thinking";
|
import type { ThinkingStep } from "@/components/tool-ui/deepagent-thinking";
|
||||||
|
import { DeleteNotionPageToolUI } from "@/components/tool-ui/delete-notion-page";
|
||||||
import { DisplayImageToolUI } from "@/components/tool-ui/display-image";
|
import { DisplayImageToolUI } from "@/components/tool-ui/display-image";
|
||||||
import { GeneratePodcastToolUI } from "@/components/tool-ui/generate-podcast";
|
import { GeneratePodcastToolUI } from "@/components/tool-ui/generate-podcast";
|
||||||
import { GenerateReportToolUI } from "@/components/tool-ui/generate-report";
|
import { GenerateReportToolUI } from "@/components/tool-ui/generate-report";
|
||||||
import { LinkPreviewToolUI } from "@/components/tool-ui/link-preview";
|
import { LinkPreviewToolUI } from "@/components/tool-ui/link-preview";
|
||||||
import { ScrapeWebpageToolUI } from "@/components/tool-ui/scrape-webpage";
|
import { ScrapeWebpageToolUI } from "@/components/tool-ui/scrape-webpage";
|
||||||
|
import { UpdateNotionPageToolUI } from "@/components/tool-ui/update-notion-page";
|
||||||
import { RecallMemoryToolUI, SaveMemoryToolUI } from "@/components/tool-ui/user-memory";
|
import { RecallMemoryToolUI, SaveMemoryToolUI } from "@/components/tool-ui/user-memory";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { useChatSessionStateSync } from "@/hooks/use-chat-session-state";
|
import { useChatSessionStateSync } from "@/hooks/use-chat-session-state";
|
||||||
|
|
@ -53,6 +56,17 @@ import {
|
||||||
looksLikePodcastRequest,
|
looksLikePodcastRequest,
|
||||||
setActivePodcastTaskId,
|
setActivePodcastTaskId,
|
||||||
} from "@/lib/chat/podcast-state";
|
} from "@/lib/chat/podcast-state";
|
||||||
|
import {
|
||||||
|
addToolCall,
|
||||||
|
appendText,
|
||||||
|
buildContentForPersistence,
|
||||||
|
buildContentForUI,
|
||||||
|
type ContentPart,
|
||||||
|
type ContentPartsState,
|
||||||
|
readSSEStream,
|
||||||
|
type ThinkingStepData,
|
||||||
|
updateToolCall,
|
||||||
|
} from "@/lib/chat/streaming-state";
|
||||||
import {
|
import {
|
||||||
appendMessage,
|
appendMessage,
|
||||||
createThread,
|
createThread,
|
||||||
|
|
@ -123,20 +137,13 @@ const TOOLS_WITH_UI = new Set([
|
||||||
"generate_report",
|
"generate_report",
|
||||||
"link_preview",
|
"link_preview",
|
||||||
"display_image",
|
"display_image",
|
||||||
|
"delete_notion_page",
|
||||||
"scrape_webpage",
|
"scrape_webpage",
|
||||||
|
"create_notion_page",
|
||||||
|
"update_notion_page",
|
||||||
// "write_todos", // Disabled for now
|
// "write_todos", // Disabled for now
|
||||||
]);
|
]);
|
||||||
|
|
||||||
/**
|
|
||||||
* Type for thinking step data from the backend
|
|
||||||
*/
|
|
||||||
interface ThinkingStepData {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
status: "pending" | "in_progress" | "completed";
|
|
||||||
items: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function NewChatPage() {
|
export default function NewChatPage() {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
@ -151,6 +158,11 @@ export default function NewChatPage() {
|
||||||
new Map()
|
new Map()
|
||||||
);
|
);
|
||||||
const abortControllerRef = useRef<AbortController | null>(null);
|
const abortControllerRef = useRef<AbortController | null>(null);
|
||||||
|
const [pendingInterrupt, setPendingInterrupt] = useState<{
|
||||||
|
threadId: number;
|
||||||
|
assistantMsgId: string;
|
||||||
|
interruptData: Record<string, unknown>;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
// Get mentioned document IDs from the composer
|
// Get mentioned document IDs from the composer
|
||||||
const mentionedDocumentIds = useAtomValue(mentionedDocumentIdsAtom);
|
const mentionedDocumentIds = useAtomValue(mentionedDocumentIdsAtom);
|
||||||
|
|
@ -385,6 +397,16 @@ export default function NewChatPage() {
|
||||||
}));
|
}));
|
||||||
}, [currentThread, setCurrentThreadState]);
|
}, [currentThread, setCurrentThreadState]);
|
||||||
|
|
||||||
|
// Cleanup on unmount - abort any in-flight requests
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (abortControllerRef.current) {
|
||||||
|
abortControllerRef.current.abort();
|
||||||
|
abortControllerRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Cancel ongoing request
|
// Cancel ongoing request
|
||||||
const cancelRun = useCallback(async () => {
|
const cancelRun = useCallback(async () => {
|
||||||
if (abortControllerRef.current) {
|
if (abortControllerRef.current) {
|
||||||
|
|
@ -540,101 +562,13 @@ export default function NewChatPage() {
|
||||||
const assistantMsgId = `msg-assistant-${Date.now()}`;
|
const assistantMsgId = `msg-assistant-${Date.now()}`;
|
||||||
const currentThinkingSteps = new Map<string, ThinkingStepData>();
|
const currentThinkingSteps = new Map<string, ThinkingStepData>();
|
||||||
|
|
||||||
// Ordered content parts to preserve inline tool call positions
|
const contentPartsState: ContentPartsState = {
|
||||||
// Each part is either a text segment or a tool call
|
contentParts: [],
|
||||||
type ContentPart =
|
currentTextPartIndex: -1,
|
||||||
| { type: "text"; text: string }
|
toolCallIndices: new Map(),
|
||||||
| {
|
|
||||||
type: "tool-call";
|
|
||||||
toolCallId: string;
|
|
||||||
toolName: string;
|
|
||||||
args: Record<string, unknown>;
|
|
||||||
result?: unknown;
|
|
||||||
};
|
|
||||||
const contentParts: ContentPart[] = [];
|
|
||||||
|
|
||||||
// Track the current text segment index (for appending text deltas)
|
|
||||||
let currentTextPartIndex = -1;
|
|
||||||
|
|
||||||
// Map to track tool call indices for updating results
|
|
||||||
const toolCallIndices = new Map<string, number>();
|
|
||||||
|
|
||||||
// Helper to get or create the current text part for appending text
|
|
||||||
const appendText = (delta: string) => {
|
|
||||||
if (currentTextPartIndex >= 0 && contentParts[currentTextPartIndex]?.type === "text") {
|
|
||||||
// Append to existing text part
|
|
||||||
(contentParts[currentTextPartIndex] as { type: "text"; text: string }).text += delta;
|
|
||||||
} else {
|
|
||||||
// Create new text part
|
|
||||||
contentParts.push({ type: "text", text: delta });
|
|
||||||
currentTextPartIndex = contentParts.length - 1;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Helper to add a tool call (this "breaks" the current text segment)
|
|
||||||
const addToolCall = (toolCallId: string, toolName: string, args: Record<string, unknown>) => {
|
|
||||||
if (TOOLS_WITH_UI.has(toolName)) {
|
|
||||||
contentParts.push({
|
|
||||||
type: "tool-call",
|
|
||||||
toolCallId,
|
|
||||||
toolName,
|
|
||||||
args,
|
|
||||||
});
|
|
||||||
toolCallIndices.set(toolCallId, contentParts.length - 1);
|
|
||||||
// Reset text part index so next text creates a new segment
|
|
||||||
currentTextPartIndex = -1;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Helper to update a tool call's args or result
|
|
||||||
const updateToolCall = (
|
|
||||||
toolCallId: string,
|
|
||||||
update: { args?: Record<string, unknown>; result?: unknown }
|
|
||||||
) => {
|
|
||||||
const index = toolCallIndices.get(toolCallId);
|
|
||||||
if (index !== undefined && contentParts[index]?.type === "tool-call") {
|
|
||||||
const tc = contentParts[index] as ContentPart & { type: "tool-call" };
|
|
||||||
if (update.args) tc.args = update.args;
|
|
||||||
if (update.result !== undefined) tc.result = update.result;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Helper to build content for UI (without thinking-steps to avoid assistant-ui errors)
|
|
||||||
const buildContentForUI = (): ThreadMessageLike["content"] => {
|
|
||||||
// Filter to only include text parts with content and tool-calls with UI
|
|
||||||
const filtered = contentParts.filter((part) => {
|
|
||||||
if (part.type === "text") return part.text.length > 0;
|
|
||||||
if (part.type === "tool-call") return TOOLS_WITH_UI.has(part.toolName);
|
|
||||||
return false;
|
|
||||||
});
|
|
||||||
return filtered.length > 0
|
|
||||||
? (filtered as ThreadMessageLike["content"])
|
|
||||||
: [{ type: "text", text: "" }];
|
|
||||||
};
|
|
||||||
|
|
||||||
// Helper to build content for persistence (includes thinking-steps for restoration)
|
|
||||||
const buildContentForPersistence = (): unknown[] => {
|
|
||||||
const parts: unknown[] = [];
|
|
||||||
|
|
||||||
// Include thinking steps for persistence
|
|
||||||
if (currentThinkingSteps.size > 0) {
|
|
||||||
parts.push({
|
|
||||||
type: "thinking-steps",
|
|
||||||
steps: Array.from(currentThinkingSteps.values()),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add content parts (filtered)
|
|
||||||
for (const part of contentParts) {
|
|
||||||
if (part.type === "text" && part.text.length > 0) {
|
|
||||||
parts.push(part);
|
|
||||||
} else if (part.type === "tool-call" && TOOLS_WITH_UI.has(part.toolName)) {
|
|
||||||
parts.push(part);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return parts.length > 0 ? parts : [{ type: "text", text: "" }];
|
|
||||||
};
|
};
|
||||||
|
const { contentParts, toolCallIndices } = contentPartsState;
|
||||||
|
let wasInterrupted = false;
|
||||||
|
|
||||||
// Add placeholder assistant message
|
// Add placeholder assistant message
|
||||||
setMessages((prev) => [
|
setMessages((prev) => [
|
||||||
|
|
@ -700,50 +634,27 @@ export default function NewChatPage() {
|
||||||
throw new Error(`Backend error: ${response.status}`);
|
throw new Error(`Backend error: ${response.status}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!response.body) {
|
for await (const parsed of readSSEStream(response)) {
|
||||||
throw new Error("No response body");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse SSE stream
|
|
||||||
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 {
|
|
||||||
const parsed = JSON.parse(data);
|
|
||||||
|
|
||||||
switch (parsed.type) {
|
switch (parsed.type) {
|
||||||
case "text-delta":
|
case "text-delta":
|
||||||
appendText(parsed.delta);
|
appendText(contentPartsState, parsed.delta);
|
||||||
setMessages((prev) =>
|
setMessages((prev) =>
|
||||||
prev.map((m) =>
|
prev.map((m) =>
|
||||||
m.id === assistantMsgId ? { ...m, content: buildContentForUI() } : m
|
m.id === assistantMsgId
|
||||||
|
? { ...m, content: buildContentForUI(contentPartsState, TOOLS_WITH_UI) }
|
||||||
|
: m
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "tool-input-start":
|
case "tool-input-start":
|
||||||
// Add tool call inline - this breaks the current text segment
|
// Add tool call inline - this breaks the current text segment
|
||||||
addToolCall(parsed.toolCallId, parsed.toolName, {});
|
addToolCall(contentPartsState, TOOLS_WITH_UI, parsed.toolCallId, parsed.toolName, {});
|
||||||
setMessages((prev) =>
|
setMessages((prev) =>
|
||||||
prev.map((m) =>
|
prev.map((m) =>
|
||||||
m.id === assistantMsgId ? { ...m, content: buildContentForUI() } : m
|
m.id === assistantMsgId
|
||||||
|
? { ...m, content: buildContentForUI(contentPartsState, TOOLS_WITH_UI) }
|
||||||
|
: m
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
|
|
@ -751,13 +662,21 @@ export default function NewChatPage() {
|
||||||
case "tool-input-available": {
|
case "tool-input-available": {
|
||||||
// Update existing tool call's args, or add if not exists
|
// Update existing tool call's args, or add if not exists
|
||||||
if (toolCallIndices.has(parsed.toolCallId)) {
|
if (toolCallIndices.has(parsed.toolCallId)) {
|
||||||
updateToolCall(parsed.toolCallId, { args: parsed.input || {} });
|
updateToolCall(contentPartsState, parsed.toolCallId, { args: parsed.input || {} });
|
||||||
} else {
|
} else {
|
||||||
addToolCall(parsed.toolCallId, parsed.toolName, parsed.input || {});
|
addToolCall(
|
||||||
|
contentPartsState,
|
||||||
|
TOOLS_WITH_UI,
|
||||||
|
parsed.toolCallId,
|
||||||
|
parsed.toolName,
|
||||||
|
parsed.input || {}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
setMessages((prev) =>
|
setMessages((prev) =>
|
||||||
prev.map((m) =>
|
prev.map((m) =>
|
||||||
m.id === assistantMsgId ? { ...m, content: buildContentForUI() } : m
|
m.id === assistantMsgId
|
||||||
|
? { ...m, content: buildContentForUI(contentPartsState, TOOLS_WITH_UI) }
|
||||||
|
: m
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
|
|
@ -765,7 +684,7 @@ export default function NewChatPage() {
|
||||||
|
|
||||||
case "tool-output-available": {
|
case "tool-output-available": {
|
||||||
// Update the tool call with its result
|
// Update the tool call with its result
|
||||||
updateToolCall(parsed.toolCallId, { result: parsed.output });
|
updateToolCall(contentPartsState, parsed.toolCallId, { result: parsed.output });
|
||||||
// Handle podcast-specific logic
|
// Handle podcast-specific logic
|
||||||
if (parsed.output?.status === "pending" && parsed.output?.podcast_id) {
|
if (parsed.output?.status === "pending" && parsed.output?.podcast_id) {
|
||||||
// Check if this is a podcast tool by looking at the content part
|
// Check if this is a podcast tool by looking at the content part
|
||||||
|
|
@ -779,7 +698,9 @@ export default function NewChatPage() {
|
||||||
}
|
}
|
||||||
setMessages((prev) =>
|
setMessages((prev) =>
|
||||||
prev.map((m) =>
|
prev.map((m) =>
|
||||||
m.id === assistantMsgId ? { ...m, content: buildContentForUI() } : m
|
m.id === assistantMsgId
|
||||||
|
? { ...m, content: buildContentForUI(contentPartsState, TOOLS_WITH_UI) }
|
||||||
|
: m
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
|
|
@ -807,9 +728,7 @@ export default function NewChatPage() {
|
||||||
const titleData = parsed.data as { threadId: number; title: string };
|
const titleData = parsed.data as { threadId: number; title: string };
|
||||||
if (titleData?.title && titleData?.threadId === currentThreadId) {
|
if (titleData?.title && titleData?.threadId === currentThreadId) {
|
||||||
// Update current thread state with new title
|
// Update current thread state with new title
|
||||||
setCurrentThread((prev) =>
|
setCurrentThread((prev) => (prev ? { ...prev, title: titleData.title } : prev));
|
||||||
prev ? { ...prev, title: titleData.title } : prev
|
|
||||||
);
|
|
||||||
// Invalidate thread list to refresh sidebar
|
// Invalidate thread list to refresh sidebar
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: ["threads", String(searchSpaceId)],
|
queryKey: ["threads", String(searchSpaceId)],
|
||||||
|
|
@ -827,23 +746,60 @@ export default function NewChatPage() {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case "data-interrupt-request": {
|
||||||
|
wasInterrupted = true;
|
||||||
|
const interruptData = parsed.data as Record<string, unknown>;
|
||||||
|
const actionRequests = (interruptData.action_requests ?? []) as Array<{
|
||||||
|
name: string;
|
||||||
|
args: Record<string, unknown>;
|
||||||
|
}>;
|
||||||
|
for (const action of actionRequests) {
|
||||||
|
const existingIdx = Array.from(toolCallIndices.entries()).find(([, idx]) => {
|
||||||
|
const part = contentParts[idx];
|
||||||
|
return part?.type === "tool-call" && part.toolName === action.name;
|
||||||
|
});
|
||||||
|
if (existingIdx) {
|
||||||
|
updateToolCall(contentPartsState, existingIdx[0], {
|
||||||
|
result: { __interrupt__: true, ...interruptData },
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const tcId = `interrupt-${action.name}`;
|
||||||
|
addToolCall(contentPartsState, TOOLS_WITH_UI, tcId, action.name, action.args);
|
||||||
|
updateToolCall(contentPartsState, tcId, {
|
||||||
|
result: { __interrupt__: true, ...interruptData },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setMessages((prev) =>
|
||||||
|
prev.map((m) =>
|
||||||
|
m.id === assistantMsgId
|
||||||
|
? { ...m, content: buildContentForUI(contentPartsState, TOOLS_WITH_UI) }
|
||||||
|
: m
|
||||||
|
)
|
||||||
|
);
|
||||||
|
if (currentThreadId) {
|
||||||
|
setPendingInterrupt({
|
||||||
|
threadId: currentThreadId,
|
||||||
|
assistantMsgId,
|
||||||
|
interruptData,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
case "error":
|
case "error":
|
||||||
throw new Error(parsed.errorText || "Server error");
|
throw new Error(parsed.errorText || "Server error");
|
||||||
}
|
}
|
||||||
} catch (e) {
|
|
||||||
if (e instanceof SyntaxError) continue;
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
reader.releaseLock();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Persist assistant message (with thinking steps for restoration on refresh)
|
// Persist assistant message (with thinking steps for restoration on refresh)
|
||||||
const finalContent = buildContentForPersistence();
|
// Skip persistence for interrupted messages -- handleResume will persist the final version
|
||||||
if (contentParts.length > 0) {
|
const finalContent = buildContentForPersistence(
|
||||||
|
contentPartsState,
|
||||||
|
TOOLS_WITH_UI,
|
||||||
|
currentThinkingSteps
|
||||||
|
);
|
||||||
|
if (contentParts.length > 0 && !wasInterrupted) {
|
||||||
try {
|
try {
|
||||||
const savedMessage = await appendMessage(currentThreadId, {
|
const savedMessage = await appendMessage(currentThreadId, {
|
||||||
role: "assistant",
|
role: "assistant",
|
||||||
|
|
@ -856,6 +812,13 @@ export default function NewChatPage() {
|
||||||
prev.map((m) => (m.id === assistantMsgId ? { ...m, id: newMsgId } : m))
|
prev.map((m) => (m.id === assistantMsgId ? { ...m, id: newMsgId } : m))
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Update pending interrupt with the new persisted message ID
|
||||||
|
setPendingInterrupt((prev) =>
|
||||||
|
prev && prev.assistantMsgId === assistantMsgId
|
||||||
|
? { ...prev, assistantMsgId: newMsgId }
|
||||||
|
: prev
|
||||||
|
);
|
||||||
|
|
||||||
// Also update thinking steps map with new ID
|
// Also update thinking steps map with new ID
|
||||||
setMessageThinkingSteps((prev) => {
|
setMessageThinkingSteps((prev) => {
|
||||||
const steps = prev.get(assistantMsgId);
|
const steps = prev.get(assistantMsgId);
|
||||||
|
|
@ -883,7 +846,11 @@ export default function NewChatPage() {
|
||||||
(part.type === "tool-call" && TOOLS_WITH_UI.has(part.toolName))
|
(part.type === "tool-call" && TOOLS_WITH_UI.has(part.toolName))
|
||||||
);
|
);
|
||||||
if (hasContent && currentThreadId) {
|
if (hasContent && currentThreadId) {
|
||||||
const partialContent = buildContentForPersistence();
|
const partialContent = buildContentForPersistence(
|
||||||
|
contentPartsState,
|
||||||
|
TOOLS_WITH_UI,
|
||||||
|
currentThinkingSteps
|
||||||
|
);
|
||||||
try {
|
try {
|
||||||
const savedMessage = await appendMessage(currentThreadId, {
|
const savedMessage = await appendMessage(currentThreadId, {
|
||||||
role: "assistant",
|
role: "assistant",
|
||||||
|
|
@ -948,6 +915,330 @@ export default function NewChatPage() {
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleResume = useCallback(
|
||||||
|
async (
|
||||||
|
decisions: Array<{
|
||||||
|
type: string;
|
||||||
|
message?: string;
|
||||||
|
edited_action?: { name: string; args: Record<string, unknown> };
|
||||||
|
}>
|
||||||
|
) => {
|
||||||
|
if (!pendingInterrupt) return;
|
||||||
|
const { threadId: resumeThreadId, assistantMsgId } = pendingInterrupt;
|
||||||
|
setPendingInterrupt(null);
|
||||||
|
setIsRunning(true);
|
||||||
|
|
||||||
|
const token = getBearerToken();
|
||||||
|
if (!token) {
|
||||||
|
toast.error("Not authenticated. Please log in again.");
|
||||||
|
setIsRunning(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
abortControllerRef.current = controller;
|
||||||
|
|
||||||
|
const currentThinkingSteps = new Map<string, ThinkingStepData>(
|
||||||
|
(messageThinkingSteps.get(assistantMsgId) ?? []).map((s) => [s.id, s])
|
||||||
|
);
|
||||||
|
|
||||||
|
const contentPartsState: ContentPartsState = {
|
||||||
|
contentParts: [],
|
||||||
|
currentTextPartIndex: -1,
|
||||||
|
toolCallIndices: new Map(),
|
||||||
|
};
|
||||||
|
const { contentParts, toolCallIndices } = contentPartsState;
|
||||||
|
|
||||||
|
const existingMsg = messages.find((m) => m.id === assistantMsgId);
|
||||||
|
if (existingMsg && Array.isArray(existingMsg.content)) {
|
||||||
|
for (const part of existingMsg.content) {
|
||||||
|
if (typeof part === "object" && part !== null) {
|
||||||
|
const p = part as Record<string, unknown>;
|
||||||
|
if (p.type === "text") {
|
||||||
|
contentParts.push({ type: "text", text: String(p.text ?? "") });
|
||||||
|
contentPartsState.currentTextPartIndex = contentParts.length - 1;
|
||||||
|
} else if (p.type === "tool-call") {
|
||||||
|
toolCallIndices.set(String(p.toolCallId), contentParts.length);
|
||||||
|
contentParts.push({
|
||||||
|
type: "tool-call",
|
||||||
|
toolCallId: String(p.toolCallId),
|
||||||
|
toolName: String(p.toolName),
|
||||||
|
args: (p.args as Record<string, unknown>) ?? {},
|
||||||
|
result: p.result as unknown,
|
||||||
|
});
|
||||||
|
contentPartsState.currentTextPartIndex = -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge edited args if present to fix race condition
|
||||||
|
if (decisions.length > 0 && decisions[0].type === "edit" && decisions[0].edited_action) {
|
||||||
|
const editedAction = decisions[0].edited_action;
|
||||||
|
for (const part of contentParts) {
|
||||||
|
if (part.type === "tool-call" && part.toolName === editedAction.name) {
|
||||||
|
part.args = { ...part.args, ...editedAction.args };
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const decisionType = decisions[0]?.type as "approve" | "reject" | undefined;
|
||||||
|
if (decisionType) {
|
||||||
|
for (const part of contentParts) {
|
||||||
|
if (
|
||||||
|
part.type === "tool-call" &&
|
||||||
|
typeof part.result === "object" &&
|
||||||
|
part.result !== null &&
|
||||||
|
"__interrupt__" in (part.result as Record<string, unknown>)
|
||||||
|
) {
|
||||||
|
part.result = {
|
||||||
|
...(part.result as Record<string, unknown>),
|
||||||
|
__decided__: decisionType,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const backendUrl = process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL || "http://localhost:8000";
|
||||||
|
const response = await fetch(`${backendUrl}/api/v1/threads/${resumeThreadId}/resume`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
search_space_id: searchSpaceId,
|
||||||
|
decisions,
|
||||||
|
}),
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Backend error: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
for await (const parsed of readSSEStream(response)) {
|
||||||
|
switch (parsed.type) {
|
||||||
|
case "text-delta":
|
||||||
|
appendText(contentPartsState, parsed.delta);
|
||||||
|
setMessages((prev) =>
|
||||||
|
prev.map((m) =>
|
||||||
|
m.id === assistantMsgId
|
||||||
|
? { ...m, content: buildContentForUI(contentPartsState, TOOLS_WITH_UI) }
|
||||||
|
: m
|
||||||
|
)
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "tool-input-start":
|
||||||
|
addToolCall(contentPartsState, TOOLS_WITH_UI, parsed.toolCallId, parsed.toolName, {});
|
||||||
|
setMessages((prev) =>
|
||||||
|
prev.map((m) =>
|
||||||
|
m.id === assistantMsgId
|
||||||
|
? { ...m, content: buildContentForUI(contentPartsState, TOOLS_WITH_UI) }
|
||||||
|
: m
|
||||||
|
)
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "tool-input-available":
|
||||||
|
if (toolCallIndices.has(parsed.toolCallId)) {
|
||||||
|
updateToolCall(contentPartsState, parsed.toolCallId, {
|
||||||
|
args: parsed.input || {},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
addToolCall(
|
||||||
|
contentPartsState,
|
||||||
|
TOOLS_WITH_UI,
|
||||||
|
parsed.toolCallId,
|
||||||
|
parsed.toolName,
|
||||||
|
parsed.input || {}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
setMessages((prev) =>
|
||||||
|
prev.map((m) =>
|
||||||
|
m.id === assistantMsgId
|
||||||
|
? { ...m, content: buildContentForUI(contentPartsState, TOOLS_WITH_UI) }
|
||||||
|
: m
|
||||||
|
)
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "tool-output-available":
|
||||||
|
updateToolCall(contentPartsState, parsed.toolCallId, {
|
||||||
|
result: parsed.output,
|
||||||
|
});
|
||||||
|
setMessages((prev) =>
|
||||||
|
prev.map((m) =>
|
||||||
|
m.id === assistantMsgId
|
||||||
|
? { ...m, content: buildContentForUI(contentPartsState, TOOLS_WITH_UI) }
|
||||||
|
: m
|
||||||
|
)
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "data-thinking-step": {
|
||||||
|
const stepData = parsed.data as ThinkingStepData;
|
||||||
|
if (stepData?.id) {
|
||||||
|
currentThinkingSteps.set(stepData.id, stepData);
|
||||||
|
setMessageThinkingSteps((prev) => {
|
||||||
|
const newMap = new Map(prev);
|
||||||
|
newMap.set(assistantMsgId, Array.from(currentThinkingSteps.values()));
|
||||||
|
return newMap;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "data-interrupt-request": {
|
||||||
|
const interruptData = parsed.data as Record<string, unknown>;
|
||||||
|
const actionRequests = (interruptData.action_requests ?? []) as Array<{
|
||||||
|
name: string;
|
||||||
|
args: Record<string, unknown>;
|
||||||
|
}>;
|
||||||
|
for (const action of actionRequests) {
|
||||||
|
const existingIdx = Array.from(toolCallIndices.entries()).find(([, idx]) => {
|
||||||
|
const part = contentParts[idx];
|
||||||
|
return part?.type === "tool-call" && part.toolName === action.name;
|
||||||
|
});
|
||||||
|
if (existingIdx) {
|
||||||
|
updateToolCall(contentPartsState, existingIdx[0], {
|
||||||
|
result: {
|
||||||
|
__interrupt__: true,
|
||||||
|
...interruptData,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const tcId = `interrupt-${action.name}`;
|
||||||
|
addToolCall(contentPartsState, TOOLS_WITH_UI, tcId, action.name, action.args);
|
||||||
|
updateToolCall(contentPartsState, tcId, {
|
||||||
|
result: {
|
||||||
|
__interrupt__: true,
|
||||||
|
...interruptData,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setMessages((prev) =>
|
||||||
|
prev.map((m) =>
|
||||||
|
m.id === assistantMsgId
|
||||||
|
? { ...m, content: buildContentForUI(contentPartsState, TOOLS_WITH_UI) }
|
||||||
|
: m
|
||||||
|
)
|
||||||
|
);
|
||||||
|
setPendingInterrupt({
|
||||||
|
threadId: resumeThreadId,
|
||||||
|
assistantMsgId,
|
||||||
|
interruptData,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "error":
|
||||||
|
throw new Error(parsed.errorText || "Server error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const finalContent = buildContentForPersistence(
|
||||||
|
contentPartsState,
|
||||||
|
TOOLS_WITH_UI,
|
||||||
|
currentThinkingSteps
|
||||||
|
);
|
||||||
|
if (contentParts.length > 0) {
|
||||||
|
try {
|
||||||
|
const savedMessage = await appendMessage(resumeThreadId, {
|
||||||
|
role: "assistant",
|
||||||
|
content: finalContent,
|
||||||
|
});
|
||||||
|
const newMsgId = `msg-${savedMessage.id}`;
|
||||||
|
setMessages((prev) =>
|
||||||
|
prev.map((m) => (m.id === assistantMsgId ? { ...m, id: newMsgId } : m))
|
||||||
|
);
|
||||||
|
setMessageThinkingSteps((prev) => {
|
||||||
|
const steps = prev.get(assistantMsgId);
|
||||||
|
if (steps) {
|
||||||
|
const newMap = new Map(prev);
|
||||||
|
newMap.delete(assistantMsgId);
|
||||||
|
newMap.set(newMsgId, steps);
|
||||||
|
return newMap;
|
||||||
|
}
|
||||||
|
return prev;
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to persist resumed assistant message:", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error && error.name === "AbortError") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.error("[NewChatPage] Resume error:", error);
|
||||||
|
toast.error("Failed to resume. Please try again.");
|
||||||
|
} finally {
|
||||||
|
setIsRunning(false);
|
||||||
|
abortControllerRef.current = null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[pendingInterrupt, messages, searchSpaceId, messageThinkingSteps]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = (e: Event) => {
|
||||||
|
const detail = (e as CustomEvent).detail as {
|
||||||
|
decisions: Array<{
|
||||||
|
type: string;
|
||||||
|
message?: string;
|
||||||
|
edited_action?: { name: string; args: Record<string, unknown> };
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
if (detail?.decisions && pendingInterrupt) {
|
||||||
|
const decision = detail.decisions[0];
|
||||||
|
const decisionType = decision?.type as "approve" | "reject" | "edit";
|
||||||
|
|
||||||
|
setMessages((prev) =>
|
||||||
|
prev.map((m) => {
|
||||||
|
if (m.id !== pendingInterrupt.assistantMsgId) return m;
|
||||||
|
const parts = m.content as unknown as Array<Record<string, unknown>>;
|
||||||
|
const newContent = parts.map((part) => {
|
||||||
|
if (
|
||||||
|
part.type === "tool-call" &&
|
||||||
|
typeof part.result === "object" &&
|
||||||
|
part.result !== null &&
|
||||||
|
"__interrupt__" in part.result
|
||||||
|
) {
|
||||||
|
// For edit decisions, also update the displayed args
|
||||||
|
if (decisionType === "edit" && decision.edited_action) {
|
||||||
|
return {
|
||||||
|
...part,
|
||||||
|
args: decision.edited_action.args, // Update displayed args
|
||||||
|
result: {
|
||||||
|
...(part.result as Record<string, unknown>),
|
||||||
|
__decided__: decisionType,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...part,
|
||||||
|
result: {
|
||||||
|
...(part.result as Record<string, unknown>),
|
||||||
|
__decided__: decisionType,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return part;
|
||||||
|
});
|
||||||
|
return { ...m, content: newContent as unknown as ThreadMessageLike["content"] };
|
||||||
|
})
|
||||||
|
);
|
||||||
|
handleResume(detail.decisions);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener("hitl-decision", handler);
|
||||||
|
return () => window.removeEventListener("hitl-decision", handler);
|
||||||
|
}, [handleResume, pendingInterrupt]);
|
||||||
|
|
||||||
// Convert message (pass through since already in correct format)
|
// Convert message (pass through since already in correct format)
|
||||||
const convertMessage = useCallback(
|
const convertMessage = useCallback(
|
||||||
(message: ThreadMessageLike): ThreadMessageLike => message,
|
(message: ThreadMessageLike): ThreadMessageLike => message,
|
||||||
|
|
@ -1033,77 +1324,12 @@ export default function NewChatPage() {
|
||||||
const assistantMsgId = `msg-assistant-${Date.now()}`;
|
const assistantMsgId = `msg-assistant-${Date.now()}`;
|
||||||
const currentThinkingSteps = new Map<string, ThinkingStepData>();
|
const currentThinkingSteps = new Map<string, ThinkingStepData>();
|
||||||
|
|
||||||
// Content parts tracking (same as onNew)
|
const contentPartsState: ContentPartsState = {
|
||||||
type ContentPart =
|
contentParts: [],
|
||||||
| { type: "text"; text: string }
|
currentTextPartIndex: -1,
|
||||||
| {
|
toolCallIndices: new Map(),
|
||||||
type: "tool-call";
|
|
||||||
toolCallId: string;
|
|
||||||
toolName: string;
|
|
||||||
args: Record<string, unknown>;
|
|
||||||
result?: unknown;
|
|
||||||
};
|
|
||||||
const contentParts: ContentPart[] = [];
|
|
||||||
let currentTextPartIndex = -1;
|
|
||||||
const toolCallIndices = new Map<string, number>();
|
|
||||||
|
|
||||||
const appendText = (delta: string) => {
|
|
||||||
if (currentTextPartIndex >= 0 && contentParts[currentTextPartIndex]?.type === "text") {
|
|
||||||
(contentParts[currentTextPartIndex] as { type: "text"; text: string }).text += delta;
|
|
||||||
} else {
|
|
||||||
contentParts.push({ type: "text", text: delta });
|
|
||||||
currentTextPartIndex = contentParts.length - 1;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const addToolCall = (toolCallId: string, toolName: string, args: Record<string, unknown>) => {
|
|
||||||
if (TOOLS_WITH_UI.has(toolName)) {
|
|
||||||
contentParts.push({ type: "tool-call", toolCallId, toolName, args });
|
|
||||||
toolCallIndices.set(toolCallId, contentParts.length - 1);
|
|
||||||
currentTextPartIndex = -1;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateToolCall = (
|
|
||||||
toolCallId: string,
|
|
||||||
update: { args?: Record<string, unknown>; result?: unknown }
|
|
||||||
) => {
|
|
||||||
const index = toolCallIndices.get(toolCallId);
|
|
||||||
if (index !== undefined && contentParts[index]?.type === "tool-call") {
|
|
||||||
const tc = contentParts[index] as ContentPart & { type: "tool-call" };
|
|
||||||
if (update.args) tc.args = update.args;
|
|
||||||
if (update.result !== undefined) tc.result = update.result;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const buildContentForUI = (): ThreadMessageLike["content"] => {
|
|
||||||
const filtered = contentParts.filter((part) => {
|
|
||||||
if (part.type === "text") return part.text.length > 0;
|
|
||||||
if (part.type === "tool-call") return TOOLS_WITH_UI.has(part.toolName);
|
|
||||||
return false;
|
|
||||||
});
|
|
||||||
return filtered.length > 0
|
|
||||||
? (filtered as ThreadMessageLike["content"])
|
|
||||||
: [{ type: "text", text: "" }];
|
|
||||||
};
|
|
||||||
|
|
||||||
const buildContentForPersistence = (): unknown[] => {
|
|
||||||
const parts: unknown[] = [];
|
|
||||||
if (currentThinkingSteps.size > 0) {
|
|
||||||
parts.push({
|
|
||||||
type: "thinking-steps",
|
|
||||||
steps: Array.from(currentThinkingSteps.values()),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
for (const part of contentParts) {
|
|
||||||
if (part.type === "text" && part.text.length > 0) {
|
|
||||||
parts.push(part);
|
|
||||||
} else if (part.type === "tool-call" && TOOLS_WITH_UI.has(part.toolName)) {
|
|
||||||
parts.push(part);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return parts.length > 0 ? parts : [{ type: "text", text: "" }];
|
|
||||||
};
|
};
|
||||||
|
const { contentParts, toolCallIndices } = contentPartsState;
|
||||||
|
|
||||||
// Add placeholder messages to UI
|
// Add placeholder messages to UI
|
||||||
// Always add back the user message (with new query for edit, or original content for reload)
|
// Always add back the user message (with new query for edit, or original content for reload)
|
||||||
|
|
@ -1147,68 +1373,53 @@ export default function NewChatPage() {
|
||||||
throw new Error(`Backend error: ${response.status}`);
|
throw new Error(`Backend error: ${response.status}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!response.body) {
|
for await (const parsed of readSSEStream(response)) {
|
||||||
throw new Error("No response body");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse SSE stream (same logic as onNew)
|
|
||||||
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 {
|
|
||||||
const parsed = JSON.parse(data);
|
|
||||||
|
|
||||||
switch (parsed.type) {
|
switch (parsed.type) {
|
||||||
case "text-delta":
|
case "text-delta":
|
||||||
appendText(parsed.delta);
|
appendText(contentPartsState, parsed.delta);
|
||||||
setMessages((prev) =>
|
setMessages((prev) =>
|
||||||
prev.map((m) =>
|
prev.map((m) =>
|
||||||
m.id === assistantMsgId ? { ...m, content: buildContentForUI() } : m
|
m.id === assistantMsgId
|
||||||
|
? { ...m, content: buildContentForUI(contentPartsState, TOOLS_WITH_UI) }
|
||||||
|
: m
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "tool-input-start":
|
case "tool-input-start":
|
||||||
addToolCall(parsed.toolCallId, parsed.toolName, {});
|
addToolCall(contentPartsState, TOOLS_WITH_UI, parsed.toolCallId, parsed.toolName, {});
|
||||||
setMessages((prev) =>
|
setMessages((prev) =>
|
||||||
prev.map((m) =>
|
prev.map((m) =>
|
||||||
m.id === assistantMsgId ? { ...m, content: buildContentForUI() } : m
|
m.id === assistantMsgId
|
||||||
|
? { ...m, content: buildContentForUI(contentPartsState, TOOLS_WITH_UI) }
|
||||||
|
: m
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "tool-input-available":
|
case "tool-input-available":
|
||||||
if (toolCallIndices.has(parsed.toolCallId)) {
|
if (toolCallIndices.has(parsed.toolCallId)) {
|
||||||
updateToolCall(parsed.toolCallId, { args: parsed.input || {} });
|
updateToolCall(contentPartsState, parsed.toolCallId, { args: parsed.input || {} });
|
||||||
} else {
|
} else {
|
||||||
addToolCall(parsed.toolCallId, parsed.toolName, parsed.input || {});
|
addToolCall(
|
||||||
|
contentPartsState,
|
||||||
|
TOOLS_WITH_UI,
|
||||||
|
parsed.toolCallId,
|
||||||
|
parsed.toolName,
|
||||||
|
parsed.input || {}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
setMessages((prev) =>
|
setMessages((prev) =>
|
||||||
prev.map((m) =>
|
prev.map((m) =>
|
||||||
m.id === assistantMsgId ? { ...m, content: buildContentForUI() } : m
|
m.id === assistantMsgId
|
||||||
|
? { ...m, content: buildContentForUI(contentPartsState, TOOLS_WITH_UI) }
|
||||||
|
: m
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "tool-output-available":
|
case "tool-output-available":
|
||||||
updateToolCall(parsed.toolCallId, { result: parsed.output });
|
updateToolCall(contentPartsState, parsed.toolCallId, { result: parsed.output });
|
||||||
if (parsed.output?.status === "pending" && parsed.output?.podcast_id) {
|
if (parsed.output?.status === "pending" && parsed.output?.podcast_id) {
|
||||||
const idx = toolCallIndices.get(parsed.toolCallId);
|
const idx = toolCallIndices.get(parsed.toolCallId);
|
||||||
if (idx !== undefined) {
|
if (idx !== undefined) {
|
||||||
|
|
@ -1220,7 +1431,9 @@ export default function NewChatPage() {
|
||||||
}
|
}
|
||||||
setMessages((prev) =>
|
setMessages((prev) =>
|
||||||
prev.map((m) =>
|
prev.map((m) =>
|
||||||
m.id === assistantMsgId ? { ...m, content: buildContentForUI() } : m
|
m.id === assistantMsgId
|
||||||
|
? { ...m, content: buildContentForUI(contentPartsState, TOOLS_WITH_UI) }
|
||||||
|
: m
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
|
|
@ -1241,19 +1454,14 @@ export default function NewChatPage() {
|
||||||
case "error":
|
case "error":
|
||||||
throw new Error(parsed.errorText || "Server error");
|
throw new Error(parsed.errorText || "Server error");
|
||||||
}
|
}
|
||||||
} catch (e) {
|
|
||||||
if (e instanceof SyntaxError) continue;
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
reader.releaseLock();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Persist messages after streaming completes
|
// Persist messages after streaming completes
|
||||||
const finalContent = buildContentForPersistence();
|
const finalContent = buildContentForPersistence(
|
||||||
|
contentPartsState,
|
||||||
|
TOOLS_WITH_UI,
|
||||||
|
currentThinkingSteps
|
||||||
|
);
|
||||||
if (contentParts.length > 0) {
|
if (contentParts.length > 0) {
|
||||||
try {
|
try {
|
||||||
// Persist user message (for both edit and reload modes, since backend deleted it)
|
// Persist user message (for both edit and reload modes, since backend deleted it)
|
||||||
|
|
@ -1440,6 +1648,9 @@ export default function NewChatPage() {
|
||||||
<ScrapeWebpageToolUI />
|
<ScrapeWebpageToolUI />
|
||||||
<SaveMemoryToolUI />
|
<SaveMemoryToolUI />
|
||||||
<RecallMemoryToolUI />
|
<RecallMemoryToolUI />
|
||||||
|
<CreateNotionPageToolUI />
|
||||||
|
<UpdateNotionPageToolUI />
|
||||||
|
<DeleteNotionPageToolUI />
|
||||||
{/* <WriteTodosToolUI /> Disabled for now */}
|
{/* <WriteTodosToolUI /> Disabled for now */}
|
||||||
<div className="flex h-[calc(100dvh-64px)] overflow-hidden">
|
<div className="flex h-[calc(100dvh-64px)] overflow-hidden">
|
||||||
<div className="flex-1 flex flex-col min-w-0 overflow-hidden">
|
<div className="flex-1 flex flex-col min-w-0 overflow-hidden">
|
||||||
|
|
|
||||||
|
|
@ -187,6 +187,23 @@ button {
|
||||||
background-color: hsl(var(--muted-foreground) / 0.4);
|
background-color: hsl(var(--muted-foreground) / 0.4);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Human-in-the-loop approval card animations */
|
||||||
|
@keyframes pulse-subtle {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
box-shadow: 0 0 0 0 rgb(0 0 0 / 0.15);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 1;
|
||||||
|
box-shadow: 0 0 20px 4px rgb(0 0 0 / 0.12);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-pulse-subtle {
|
||||||
|
animation: pulse-subtle 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||||
|
}
|
||||||
|
|
||||||
/* Integrations section — vertical column auto-scroll */
|
/* Integrations section — vertical column auto-scroll */
|
||||||
@keyframes integrations-scroll-up {
|
@keyframes integrations-scroll-up {
|
||||||
0% {
|
0% {
|
||||||
|
|
|
||||||
503
surfsense_web/components/tool-ui/create-notion-page.tsx
Normal file
503
surfsense_web/components/tool-ui/create-notion-page.tsx
Normal file
|
|
@ -0,0 +1,503 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { makeAssistantToolUI } from "@assistant-ui/react";
|
||||||
|
import { AlertTriangleIcon, CheckIcon, Loader2Icon, PencilIcon, XIcon } from "lucide-react";
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
|
||||||
|
interface InterruptResult {
|
||||||
|
__interrupt__: true;
|
||||||
|
__decided__?: "approve" | "reject" | "edit";
|
||||||
|
action_requests: Array<{
|
||||||
|
name: string;
|
||||||
|
args: Record<string, unknown>;
|
||||||
|
description?: string;
|
||||||
|
}>;
|
||||||
|
review_configs: Array<{
|
||||||
|
action_name: string;
|
||||||
|
allowed_decisions: Array<"approve" | "edit" | "reject">;
|
||||||
|
}>;
|
||||||
|
interrupt_type?: string;
|
||||||
|
message?: string;
|
||||||
|
context?: {
|
||||||
|
accounts?: Array<{
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
workspace_id: string | null;
|
||||||
|
workspace_name: string;
|
||||||
|
workspace_icon: string;
|
||||||
|
}>;
|
||||||
|
parent_pages?: Record<
|
||||||
|
number,
|
||||||
|
Array<{
|
||||||
|
page_id: string;
|
||||||
|
title: string;
|
||||||
|
document_id: number;
|
||||||
|
}>
|
||||||
|
>;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SuccessResult {
|
||||||
|
status: "success";
|
||||||
|
page_id: string;
|
||||||
|
title: string;
|
||||||
|
url: string;
|
||||||
|
content_preview?: string;
|
||||||
|
content_length?: number;
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ErrorResult {
|
||||||
|
status: "error";
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type CreateNotionPageResult = InterruptResult | SuccessResult | ErrorResult;
|
||||||
|
|
||||||
|
function isInterruptResult(result: unknown): result is InterruptResult {
|
||||||
|
return (
|
||||||
|
typeof result === "object" &&
|
||||||
|
result !== null &&
|
||||||
|
"__interrupt__" in result &&
|
||||||
|
(result as InterruptResult).__interrupt__ === true
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isErrorResult(result: unknown): result is ErrorResult {
|
||||||
|
return (
|
||||||
|
typeof result === "object" &&
|
||||||
|
result !== null &&
|
||||||
|
"status" in result &&
|
||||||
|
(result as ErrorResult).status === "error"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ApprovalCard({
|
||||||
|
args,
|
||||||
|
interruptData,
|
||||||
|
onDecision,
|
||||||
|
}: {
|
||||||
|
args: Record<string, unknown>;
|
||||||
|
interruptData: InterruptResult;
|
||||||
|
onDecision: (decision: {
|
||||||
|
type: "approve" | "reject" | "edit";
|
||||||
|
message?: string;
|
||||||
|
edited_action?: { name: string; args: Record<string, unknown> };
|
||||||
|
}) => void;
|
||||||
|
}) {
|
||||||
|
const [decided, setDecided] = useState<"approve" | "reject" | "edit" | null>(
|
||||||
|
interruptData.__decided__ ?? null
|
||||||
|
);
|
||||||
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
|
const [editedArgs, setEditedArgs] = useState<Record<string, unknown>>(args);
|
||||||
|
|
||||||
|
const accounts = interruptData.context?.accounts ?? [];
|
||||||
|
const parentPages = interruptData.context?.parent_pages ?? {};
|
||||||
|
|
||||||
|
const defaultAccountId = useMemo(() => {
|
||||||
|
if (args.connector_id) return String(args.connector_id);
|
||||||
|
if (accounts.length === 1) return String(accounts[0].id);
|
||||||
|
return "";
|
||||||
|
}, [args.connector_id, accounts]);
|
||||||
|
|
||||||
|
const [selectedAccountId, setSelectedAccountId] = useState<string>(defaultAccountId);
|
||||||
|
const [selectedParentPageId, setSelectedParentPageId] = useState<string>(
|
||||||
|
args.parent_page_id ? String(args.parent_page_id) : "__none__"
|
||||||
|
);
|
||||||
|
|
||||||
|
const availableParentPages = useMemo(() => {
|
||||||
|
if (!selectedAccountId) return [];
|
||||||
|
return parentPages[Number(selectedAccountId)] ?? [];
|
||||||
|
}, [selectedAccountId, parentPages]);
|
||||||
|
|
||||||
|
const isTitleValid = useMemo(() => {
|
||||||
|
const currentTitle = isEditing ? editedArgs.title : args.title;
|
||||||
|
return currentTitle && typeof currentTitle === "string" && currentTitle.trim().length > 0;
|
||||||
|
}, [isEditing, editedArgs.title, args.title]);
|
||||||
|
|
||||||
|
const reviewConfig = interruptData.review_configs[0];
|
||||||
|
const allowedDecisions = reviewConfig?.allowed_decisions ?? ["approve", "reject"];
|
||||||
|
const canEdit = allowedDecisions.includes("edit");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`my-4 max-w-full overflow-hidden rounded-xl transition-all duration-300 ${
|
||||||
|
decided
|
||||||
|
? "border border-border bg-card shadow-sm"
|
||||||
|
: "border-2 border-foreground/20 bg-muted/30 dark:bg-muted/10 shadow-lg animate-pulse-subtle"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`flex items-center gap-3 border-b ${
|
||||||
|
decided ? "border-border bg-card" : "border-foreground/15 bg-muted/40 dark:bg-muted/20"
|
||||||
|
} px-4 py-3`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`flex size-9 shrink-0 items-center justify-center rounded-lg ${
|
||||||
|
decided ? "bg-muted" : "bg-muted animate-pulse"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<AlertTriangleIcon
|
||||||
|
className={`size-4 ${decided ? "text-muted-foreground" : "text-foreground"}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className={`text-sm font-medium ${decided ? "text-foreground" : "text-foreground"}`}>
|
||||||
|
Create Notion Page
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
className={`truncate text-xs ${
|
||||||
|
decided ? "text-muted-foreground" : "text-muted-foreground"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isEditing ? "You can edit the arguments below" : "Requires your approval to proceed"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Context section - account and parent page selection */}
|
||||||
|
{!decided && interruptData.context && (
|
||||||
|
<div className="border-b border-border px-4 py-3 bg-muted/30 space-y-3">
|
||||||
|
{interruptData.context.error ? (
|
||||||
|
<p className="text-sm text-destructive">{interruptData.context.error}</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{accounts.length > 0 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="text-xs font-medium text-muted-foreground">
|
||||||
|
Notion Account <span className="text-destructive">*</span>
|
||||||
|
</div>
|
||||||
|
<Select
|
||||||
|
value={selectedAccountId}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
setSelectedAccountId(value);
|
||||||
|
setSelectedParentPageId("__none__");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-full">
|
||||||
|
<SelectValue placeholder="Select an account" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{accounts.map((account) => (
|
||||||
|
<SelectItem key={account.id} value={String(account.id)}>
|
||||||
|
{account.workspace_name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selectedAccountId && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="text-xs font-medium text-muted-foreground">
|
||||||
|
Parent Page (optional)
|
||||||
|
</div>
|
||||||
|
<Select value={selectedParentPageId} onValueChange={setSelectedParentPageId}>
|
||||||
|
<SelectTrigger className="w-full">
|
||||||
|
<SelectValue placeholder="None" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="__none__">None</SelectItem>
|
||||||
|
{availableParentPages.map((page) => (
|
||||||
|
<SelectItem key={page.page_id} value={page.page_id}>
|
||||||
|
📄 {page.title}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
{availableParentPages.length === 0 && selectedAccountId && (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
No pages available. Page will be created at workspace root.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Display mode - show args as read-only */}
|
||||||
|
{!isEditing && (
|
||||||
|
<div className="space-y-2 px-4 py-3 bg-card">
|
||||||
|
{args.title != null && (
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-medium text-muted-foreground">Title</p>
|
||||||
|
<p className="text-sm text-foreground">{String(args.title)}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{args.content != null && (
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-medium text-muted-foreground">Content</p>
|
||||||
|
<p className="line-clamp-4 text-sm whitespace-pre-wrap text-foreground">
|
||||||
|
{String(args.content)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Edit mode - show editable form fields */}
|
||||||
|
{isEditing && !decided && (
|
||||||
|
<div className="space-y-3 px-4 py-3 bg-card">
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="notion-title"
|
||||||
|
className="text-xs font-medium text-muted-foreground mb-1.5 block"
|
||||||
|
>
|
||||||
|
Title <span className="text-destructive">*</span>
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="notion-title"
|
||||||
|
value={String(editedArgs.title ?? "")}
|
||||||
|
onChange={(e) => setEditedArgs({ ...editedArgs, title: e.target.value })}
|
||||||
|
placeholder="Enter page title"
|
||||||
|
className={!isTitleValid ? "border-destructive" : ""}
|
||||||
|
/>
|
||||||
|
{!isTitleValid && (
|
||||||
|
<p className="text-xs text-destructive mt-1">Title is required and cannot be empty</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="notion-content"
|
||||||
|
className="text-xs font-medium text-muted-foreground mb-1.5 block"
|
||||||
|
>
|
||||||
|
Content
|
||||||
|
</label>
|
||||||
|
<Textarea
|
||||||
|
id="notion-content"
|
||||||
|
value={String(editedArgs.content ?? "")}
|
||||||
|
onChange={(e) => setEditedArgs({ ...editedArgs, content: e.target.value })}
|
||||||
|
placeholder="Enter page content"
|
||||||
|
rows={6}
|
||||||
|
className="resize-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Action buttons */}
|
||||||
|
<div
|
||||||
|
className={`flex items-center gap-2 border-t ${
|
||||||
|
decided ? "border-border bg-card" : "border-foreground/15 bg-muted/20 dark:bg-muted/10"
|
||||||
|
} px-4 py-3`}
|
||||||
|
>
|
||||||
|
{decided ? (
|
||||||
|
<p className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||||
|
{decided === "approve" || decided === "edit" ? (
|
||||||
|
<>
|
||||||
|
<CheckIcon className="size-3.5 text-green-500" />
|
||||||
|
{decided === "edit" ? "Approved with Changes" : "Approved"}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<XIcon className="size-3.5 text-destructive" />
|
||||||
|
Rejected
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
) : isEditing ? (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setDecided("edit");
|
||||||
|
setIsEditing(false);
|
||||||
|
onDecision({
|
||||||
|
type: "edit",
|
||||||
|
edited_action: {
|
||||||
|
name: interruptData.action_requests[0].name,
|
||||||
|
args: {
|
||||||
|
...editedArgs,
|
||||||
|
connector_id: selectedAccountId ? Number(selectedAccountId) : null,
|
||||||
|
parent_page_id:
|
||||||
|
selectedParentPageId === "__none__" ? null : selectedParentPageId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
disabled={!selectedAccountId || !isTitleValid}
|
||||||
|
>
|
||||||
|
<CheckIcon />
|
||||||
|
Approve with Changes
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
setIsEditing(false);
|
||||||
|
setEditedArgs(args); // Reset to original args
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{allowedDecisions.includes("approve") && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setDecided("approve");
|
||||||
|
onDecision({
|
||||||
|
type: "approve",
|
||||||
|
edited_action: {
|
||||||
|
name: interruptData.action_requests[0].name,
|
||||||
|
args: {
|
||||||
|
...args,
|
||||||
|
connector_id: selectedAccountId ? Number(selectedAccountId) : null,
|
||||||
|
parent_page_id:
|
||||||
|
selectedParentPageId === "__none__" ? null : selectedParentPageId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
disabled={!selectedAccountId || !isTitleValid}
|
||||||
|
>
|
||||||
|
<CheckIcon />
|
||||||
|
Approve
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{canEdit && (
|
||||||
|
<Button size="sm" variant="outline" onClick={() => setIsEditing(true)}>
|
||||||
|
<PencilIcon />
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{allowedDecisions.includes("reject") && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
setDecided("reject");
|
||||||
|
onDecision({ type: "reject", message: "User rejected the action." });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<XIcon />
|
||||||
|
Reject
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ErrorCard({ result }: { result: ErrorResult }) {
|
||||||
|
return (
|
||||||
|
<div className="my-4 max-w-md overflow-hidden rounded-xl border border-destructive/50 bg-card">
|
||||||
|
<div className="flex items-center gap-3 border-b border-destructive/50 px-4 py-3">
|
||||||
|
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-destructive/10">
|
||||||
|
<XIcon className="size-4 text-destructive" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-sm font-medium text-destructive">Failed to create Notion page</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="px-4 py-3">
|
||||||
|
<p className="text-sm text-muted-foreground">{result.message}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SuccessCard({ result }: { result: SuccessResult }) {
|
||||||
|
return (
|
||||||
|
<div className="my-4 max-w-md overflow-hidden rounded-xl border border-border bg-card">
|
||||||
|
<div className="flex items-center gap-3 border-b border-border px-4 py-3">
|
||||||
|
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-green-500/10">
|
||||||
|
<CheckIcon className="size-4 text-green-500" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-[.8rem] text-muted-foreground">
|
||||||
|
{result.message || "Notion page created successfully"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2 px-4 py-3 text-xs">
|
||||||
|
<div>
|
||||||
|
<span className="font-medium text-muted-foreground">Title: </span>
|
||||||
|
<span>{result.title}</span>
|
||||||
|
</div>
|
||||||
|
{result.url && (
|
||||||
|
<div>
|
||||||
|
<a
|
||||||
|
href={result.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-primary hover:underline"
|
||||||
|
>
|
||||||
|
Open in Notion
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CreateNotionPageToolUI = makeAssistantToolUI<
|
||||||
|
{ title: string; content: string },
|
||||||
|
CreateNotionPageResult
|
||||||
|
>({
|
||||||
|
toolName: "create_notion_page",
|
||||||
|
render: function CreateNotionPageUI({ args, result, status }) {
|
||||||
|
if (status.type === "running") {
|
||||||
|
return (
|
||||||
|
<div className="my-4 flex max-w-md items-center gap-3 rounded-xl border border-border bg-card px-4 py-3">
|
||||||
|
<Loader2Icon className="size-4 animate-spin text-muted-foreground" />
|
||||||
|
<p className="text-sm text-muted-foreground">Preparing Notion page...</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!result) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isInterruptResult(result)) {
|
||||||
|
return (
|
||||||
|
<ApprovalCard
|
||||||
|
args={args}
|
||||||
|
interruptData={result}
|
||||||
|
onDecision={(decision) => {
|
||||||
|
const event = new CustomEvent("hitl-decision", {
|
||||||
|
detail: { decisions: [decision] },
|
||||||
|
});
|
||||||
|
window.dispatchEvent(event);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
typeof result === "object" &&
|
||||||
|
result !== null &&
|
||||||
|
"status" in result &&
|
||||||
|
(result as { status: string }).status === "rejected"
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isErrorResult(result)) {
|
||||||
|
return <ErrorCard result={result} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <SuccessCard result={result as SuccessResult} />;
|
||||||
|
},
|
||||||
|
});
|
||||||
421
surfsense_web/components/tool-ui/delete-notion-page.tsx
Normal file
421
surfsense_web/components/tool-ui/delete-notion-page.tsx
Normal file
|
|
@ -0,0 +1,421 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { makeAssistantToolUI } from "@assistant-ui/react";
|
||||||
|
import {
|
||||||
|
AlertTriangleIcon,
|
||||||
|
CheckIcon,
|
||||||
|
InfoIcon,
|
||||||
|
Loader2Icon,
|
||||||
|
TriangleAlertIcon,
|
||||||
|
XIcon,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
|
interface InterruptResult {
|
||||||
|
__interrupt__: true;
|
||||||
|
__decided__?: "approve" | "reject";
|
||||||
|
action_requests: Array<{
|
||||||
|
name: string;
|
||||||
|
args: Record<string, unknown>;
|
||||||
|
description?: string;
|
||||||
|
}>;
|
||||||
|
review_configs: Array<{
|
||||||
|
action_name: string;
|
||||||
|
allowed_decisions: Array<"approve" | "reject">;
|
||||||
|
}>;
|
||||||
|
interrupt_type?: string;
|
||||||
|
message?: string;
|
||||||
|
context?: {
|
||||||
|
account?: {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
workspace_id: string | null;
|
||||||
|
workspace_name: string;
|
||||||
|
workspace_icon: string;
|
||||||
|
};
|
||||||
|
page_id?: string;
|
||||||
|
current_title?: string;
|
||||||
|
document_id?: number;
|
||||||
|
indexed_at?: string;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SuccessResult {
|
||||||
|
status: "success";
|
||||||
|
page_id: string;
|
||||||
|
title?: string;
|
||||||
|
message?: string;
|
||||||
|
deleted_from_db?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ErrorResult {
|
||||||
|
status: "error";
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface InfoResult {
|
||||||
|
status: "not_found";
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WarningResult {
|
||||||
|
status: "success";
|
||||||
|
warning: string;
|
||||||
|
page_id?: string;
|
||||||
|
title?: string;
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type DeleteNotionPageResult = InterruptResult | SuccessResult | ErrorResult | InfoResult | WarningResult;
|
||||||
|
|
||||||
|
function isInterruptResult(result: unknown): result is InterruptResult {
|
||||||
|
return (
|
||||||
|
typeof result === "object" &&
|
||||||
|
result !== null &&
|
||||||
|
"__interrupt__" in result &&
|
||||||
|
(result as InterruptResult).__interrupt__ === true
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isErrorResult(result: unknown): result is ErrorResult {
|
||||||
|
return (
|
||||||
|
typeof result === "object" &&
|
||||||
|
result !== null &&
|
||||||
|
"status" in result &&
|
||||||
|
(result as ErrorResult).status === "error"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isInfoResult(result: unknown): result is InfoResult {
|
||||||
|
return (
|
||||||
|
typeof result === "object" &&
|
||||||
|
result !== null &&
|
||||||
|
"status" in result &&
|
||||||
|
(result as InfoResult).status === "not_found"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isWarningResult(result: unknown): result is WarningResult {
|
||||||
|
return (
|
||||||
|
typeof result === "object" &&
|
||||||
|
result !== null &&
|
||||||
|
"status" in result &&
|
||||||
|
(result as WarningResult).status === "success" &&
|
||||||
|
"warning" in result &&
|
||||||
|
typeof (result as WarningResult).warning === "string"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ApprovalCard({
|
||||||
|
args,
|
||||||
|
interruptData,
|
||||||
|
onDecision,
|
||||||
|
}: {
|
||||||
|
args: Record<string, unknown>;
|
||||||
|
interruptData: InterruptResult;
|
||||||
|
onDecision: (decision: {
|
||||||
|
type: "approve" | "reject";
|
||||||
|
message?: string;
|
||||||
|
edited_action?: { name: string; args: Record<string, unknown> };
|
||||||
|
}) => void;
|
||||||
|
}) {
|
||||||
|
const [decided, setDecided] = useState<"approve" | "reject" | null>(
|
||||||
|
interruptData.__decided__ ?? null
|
||||||
|
);
|
||||||
|
const [deleteFromDb, setDeleteFromDb] = useState(false);
|
||||||
|
|
||||||
|
const account = interruptData.context?.account;
|
||||||
|
const currentTitle = interruptData.context?.current_title;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`my-4 max-w-full overflow-hidden rounded-xl transition-all duration-300 ${
|
||||||
|
decided
|
||||||
|
? "border border-border bg-card shadow-sm"
|
||||||
|
: "border-2 border-foreground/20 bg-muted/30 dark:bg-muted/10 shadow-lg animate-pulse-subtle"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`flex items-center gap-3 border-b ${
|
||||||
|
decided ? "border-border bg-card" : "border-foreground/15 bg-muted/40 dark:bg-muted/20"
|
||||||
|
} px-4 py-3`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`flex size-9 shrink-0 items-center justify-center rounded-lg ${
|
||||||
|
decided ? "bg-muted" : "bg-muted animate-pulse"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<AlertTriangleIcon
|
||||||
|
className={`size-4 ${decided ? "text-muted-foreground" : "text-foreground"}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className={`text-sm font-medium ${decided ? "text-foreground" : "text-foreground"}`}>
|
||||||
|
Delete Notion Page
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
className={`truncate text-xs ${decided ? "text-muted-foreground" : "text-muted-foreground"}`}
|
||||||
|
>
|
||||||
|
Requires your approval to proceed
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Context section - READ ONLY account and page info */}
|
||||||
|
{!decided && interruptData.context && (
|
||||||
|
<div className="border-b border-border px-4 py-3 bg-muted/30 space-y-3">
|
||||||
|
{interruptData.context.error ? (
|
||||||
|
<p className="text-sm text-destructive">{interruptData.context.error}</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{account && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="text-xs font-medium text-muted-foreground">Notion Account</div>
|
||||||
|
<div className="w-full rounded-md border border-input bg-muted/50 px-3 py-2 text-sm">
|
||||||
|
{account.workspace_icon} {account.workspace_name}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{currentTitle && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="text-xs font-medium text-muted-foreground">Page to Delete</div>
|
||||||
|
<div className="w-full rounded-md border border-input bg-muted/50 px-3 py-2 text-sm">
|
||||||
|
📄 {currentTitle}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Checkbox for deleting from knowledge base */}
|
||||||
|
{!decided && (
|
||||||
|
<div className="px-4 py-3 border-t border-border bg-muted/20">
|
||||||
|
<label className="flex items-start gap-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={deleteFromDb}
|
||||||
|
onChange={(e) => setDeleteFromDb(e.target.checked)}
|
||||||
|
className="mt-0.5"
|
||||||
|
/>
|
||||||
|
<div className="flex-1">
|
||||||
|
<span className="text-sm text-foreground">Also remove from knowledge base</span>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
⚠️ This will permanently delete the page from your knowledge base (cannot be undone)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={`flex items-center gap-2 border-t ${
|
||||||
|
decided ? "border-border bg-card" : "border-foreground/15 bg-muted/20 dark:bg-muted/10"
|
||||||
|
} px-4 py-3`}
|
||||||
|
>
|
||||||
|
{decided ? (
|
||||||
|
<p className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||||
|
{decided === "approve" ? (
|
||||||
|
<>
|
||||||
|
<CheckIcon className="size-3.5 text-green-500" />
|
||||||
|
Approved
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<XIcon className="size-3.5 text-destructive" />
|
||||||
|
Rejected
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setDecided("approve");
|
||||||
|
onDecision({
|
||||||
|
type: "approve",
|
||||||
|
edited_action: {
|
||||||
|
name: interruptData.action_requests[0].name,
|
||||||
|
args: {
|
||||||
|
page_id: interruptData.context?.page_id,
|
||||||
|
connector_id: account?.id,
|
||||||
|
delete_from_db: deleteFromDb,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CheckIcon />
|
||||||
|
Approve
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
setDecided("reject");
|
||||||
|
onDecision({ type: "reject", message: "User rejected the action." });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<XIcon />
|
||||||
|
Reject
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ErrorCard({ result }: { result: ErrorResult }) {
|
||||||
|
return (
|
||||||
|
<div className="my-4 max-w-md overflow-hidden rounded-xl border border-destructive/50 bg-card">
|
||||||
|
<div className="flex items-center gap-3 border-b border-destructive/50 px-4 py-3">
|
||||||
|
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-destructive/10">
|
||||||
|
<XIcon className="size-4 text-destructive" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-sm font-medium text-destructive">Failed to delete Notion page</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="px-4 py-3">
|
||||||
|
<p className="text-sm text-muted-foreground">{result.message}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function InfoCard({ result }: { result: InfoResult }) {
|
||||||
|
return (
|
||||||
|
<div className="my-4 max-w-md overflow-hidden rounded-xl border border-amber-500/50 bg-card">
|
||||||
|
<div className="flex items-start gap-3 px-4 py-3">
|
||||||
|
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-amber-500/10">
|
||||||
|
<InfoIcon className="size-4 text-amber-500" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1 pt-2">
|
||||||
|
<p className="text-sm text-muted-foreground">{result.message}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function WarningCard({ result }: { result: WarningResult }) {
|
||||||
|
return (
|
||||||
|
<div className="my-4 max-w-md overflow-hidden rounded-xl border border-amber-500/50 bg-card">
|
||||||
|
<div className="flex items-center gap-3 border-b border-amber-500/50 px-4 py-3">
|
||||||
|
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-amber-500/10">
|
||||||
|
<TriangleAlertIcon className="size-4 text-amber-500" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-sm font-medium text-amber-600 dark:text-amber-500">Partial success</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2 px-4 py-3 text-xs">
|
||||||
|
<p className="text-sm text-muted-foreground">{result.warning}</p>
|
||||||
|
{result.title && (
|
||||||
|
<div className="pt-2">
|
||||||
|
<span className="font-medium text-muted-foreground">Deleted page: </span>
|
||||||
|
<span>{result.title}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SuccessCard({ result }: { result: SuccessResult }) {
|
||||||
|
return (
|
||||||
|
<div className="my-4 max-w-md overflow-hidden rounded-xl border border-border bg-card">
|
||||||
|
<div className="flex items-center gap-3 border-b border-border px-4 py-3">
|
||||||
|
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-green-500/10">
|
||||||
|
<CheckIcon className="size-4 text-green-500" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-[.8rem] text-muted-foreground">
|
||||||
|
{result.message || "Notion page deleted successfully"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{(result.deleted_from_db || result.title) && (
|
||||||
|
<div className="space-y-2 px-4 py-3 text-xs">
|
||||||
|
{result.title && (
|
||||||
|
<div>
|
||||||
|
<span className="font-medium text-muted-foreground">Deleted page: </span>
|
||||||
|
<span>{result.title}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{result.deleted_from_db && (
|
||||||
|
<div className="pt-1">
|
||||||
|
<span className="text-green-600 dark:text-green-500">
|
||||||
|
✓ Also removed from knowledge base
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DeleteNotionPageToolUI = makeAssistantToolUI<
|
||||||
|
{ page_title: string; delete_from_db?: boolean },
|
||||||
|
DeleteNotionPageResult
|
||||||
|
>({
|
||||||
|
toolName: "delete_notion_page",
|
||||||
|
render: function DeleteNotionPageUI({ args, result, status }) {
|
||||||
|
if (status.type === "running") {
|
||||||
|
return (
|
||||||
|
<div className="my-4 flex max-w-md items-center gap-3 rounded-xl border border-border bg-card px-4 py-3">
|
||||||
|
<Loader2Icon className="size-4 animate-spin text-muted-foreground" />
|
||||||
|
<p className="text-sm text-muted-foreground">Deleting Notion page...</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!result) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isInterruptResult(result)) {
|
||||||
|
return (
|
||||||
|
<ApprovalCard
|
||||||
|
args={args}
|
||||||
|
interruptData={result}
|
||||||
|
onDecision={(decision) => {
|
||||||
|
const event = new CustomEvent("hitl-decision", {
|
||||||
|
detail: { decisions: [decision] },
|
||||||
|
});
|
||||||
|
window.dispatchEvent(event);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
typeof result === "object" &&
|
||||||
|
result !== null &&
|
||||||
|
"status" in result &&
|
||||||
|
(result as { status: string }).status === "rejected"
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isInfoResult(result)) {
|
||||||
|
return <InfoCard result={result} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isWarningResult(result)) {
|
||||||
|
return <WarningCard result={result} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isErrorResult(result)) {
|
||||||
|
return <ErrorCard result={result} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <SuccessCard result={result as SuccessResult} />;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
@ -16,6 +16,7 @@ export {
|
||||||
type SerializableArticle,
|
type SerializableArticle,
|
||||||
} from "./article";
|
} from "./article";
|
||||||
export { Audio } from "./audio";
|
export { Audio } from "./audio";
|
||||||
|
export { CreateNotionPageToolUI } from "./create-notion-page";
|
||||||
export {
|
export {
|
||||||
type DeepAgentThinkingArgs,
|
type DeepAgentThinkingArgs,
|
||||||
type DeepAgentThinkingResult,
|
type DeepAgentThinkingResult,
|
||||||
|
|
@ -78,6 +79,7 @@ export {
|
||||||
ScrapeWebpageResultSchema,
|
ScrapeWebpageResultSchema,
|
||||||
ScrapeWebpageToolUI,
|
ScrapeWebpageToolUI,
|
||||||
} from "./scrape-webpage";
|
} from "./scrape-webpage";
|
||||||
|
export { UpdateNotionPageToolUI } from "./update-notion-page";
|
||||||
export {
|
export {
|
||||||
type MemoryItem,
|
type MemoryItem,
|
||||||
type RecallMemoryArgs,
|
type RecallMemoryArgs,
|
||||||
|
|
|
||||||
486
surfsense_web/components/tool-ui/update-notion-page.tsx
Normal file
486
surfsense_web/components/tool-ui/update-notion-page.tsx
Normal file
|
|
@ -0,0 +1,486 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { makeAssistantToolUI } from "@assistant-ui/react";
|
||||||
|
import {
|
||||||
|
AlertTriangleIcon,
|
||||||
|
CheckIcon,
|
||||||
|
InfoIcon,
|
||||||
|
Loader2Icon,
|
||||||
|
MaximizeIcon,
|
||||||
|
MinimizeIcon,
|
||||||
|
PencilIcon,
|
||||||
|
XIcon,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
|
||||||
|
interface InterruptResult {
|
||||||
|
__interrupt__: true;
|
||||||
|
__decided__?: "approve" | "reject" | "edit";
|
||||||
|
action_requests: Array<{
|
||||||
|
name: string;
|
||||||
|
args: Record<string, unknown>;
|
||||||
|
description?: string;
|
||||||
|
}>;
|
||||||
|
review_configs: Array<{
|
||||||
|
action_name: string;
|
||||||
|
allowed_decisions: Array<"approve" | "edit" | "reject">;
|
||||||
|
}>;
|
||||||
|
interrupt_type?: string;
|
||||||
|
message?: string;
|
||||||
|
context?: {
|
||||||
|
account?: {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
workspace_id: string | null;
|
||||||
|
workspace_name: string;
|
||||||
|
workspace_icon: string;
|
||||||
|
};
|
||||||
|
page_id?: string;
|
||||||
|
current_title?: string;
|
||||||
|
document_id?: number;
|
||||||
|
indexed_at?: string;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SuccessResult {
|
||||||
|
status: "success";
|
||||||
|
page_id: string;
|
||||||
|
title: string;
|
||||||
|
url: string;
|
||||||
|
content_preview?: string;
|
||||||
|
content_length?: number;
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ErrorResult {
|
||||||
|
status: "error";
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface InfoResult {
|
||||||
|
status: "not_found";
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type UpdateNotionPageResult = InterruptResult | SuccessResult | ErrorResult | InfoResult;
|
||||||
|
|
||||||
|
function isInterruptResult(result: unknown): result is InterruptResult {
|
||||||
|
return (
|
||||||
|
typeof result === "object" &&
|
||||||
|
result !== null &&
|
||||||
|
"__interrupt__" in result &&
|
||||||
|
(result as InterruptResult).__interrupt__ === true
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isErrorResult(result: unknown): result is ErrorResult {
|
||||||
|
return (
|
||||||
|
typeof result === "object" &&
|
||||||
|
result !== null &&
|
||||||
|
"status" in result &&
|
||||||
|
(result as ErrorResult).status === "error"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isInfoResult(result: unknown): result is InfoResult {
|
||||||
|
return (
|
||||||
|
typeof result === "object" &&
|
||||||
|
result !== null &&
|
||||||
|
"status" in result &&
|
||||||
|
(result as InfoResult).status === "not_found"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ApprovalCard({
|
||||||
|
args,
|
||||||
|
interruptData,
|
||||||
|
onDecision,
|
||||||
|
}: {
|
||||||
|
args: Record<string, unknown>;
|
||||||
|
interruptData: InterruptResult;
|
||||||
|
onDecision: (decision: {
|
||||||
|
type: "approve" | "reject" | "edit";
|
||||||
|
message?: string;
|
||||||
|
edited_action?: { name: string; args: Record<string, unknown> };
|
||||||
|
}) => void;
|
||||||
|
}) {
|
||||||
|
const [decided, setDecided] = useState<"approve" | "reject" | "edit" | null>(
|
||||||
|
interruptData.__decided__ ?? null
|
||||||
|
);
|
||||||
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
|
const [isFullScreen, setIsFullScreen] = useState(false);
|
||||||
|
const [editedArgs, setEditedArgs] = useState<Record<string, unknown>>(args);
|
||||||
|
|
||||||
|
const account = interruptData.context?.account;
|
||||||
|
const currentTitle = interruptData.context?.current_title;
|
||||||
|
|
||||||
|
const reviewConfig = interruptData.review_configs[0];
|
||||||
|
const allowedDecisions = reviewConfig?.allowed_decisions ?? ["approve", "reject"];
|
||||||
|
const canEdit = allowedDecisions.includes("edit");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Backdrop for full-screen mode */}
|
||||||
|
{isFullScreen && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 bg-black/50 backdrop-blur-sm"
|
||||||
|
onClick={() => setIsFullScreen(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={`${
|
||||||
|
isFullScreen
|
||||||
|
? "fixed left-1/2 top-1/2 z-50 h-[90vh] flex max-h-300 w-[90vw] max-w-350 -translate-x-1/2 -translate-y-1/2 flex-col"
|
||||||
|
: "my-4 max-w-full"
|
||||||
|
} overflow-hidden rounded-xl bg-background shadow-xl transition-all duration-300 ${
|
||||||
|
decided
|
||||||
|
? "border border-border bg-card shadow-sm"
|
||||||
|
: "border-2 border-foreground/20 bg-muted/30 dark:bg-muted/10 shadow-lg animate-pulse-subtle"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`flex items-center gap-3 border-b ${
|
||||||
|
decided ? "border-border bg-card" : "border-foreground/15 bg-muted/40 dark:bg-muted/20"
|
||||||
|
} px-4 py-3`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`flex size-9 shrink-0 items-center justify-center rounded-lg ${
|
||||||
|
decided ? "bg-muted" : "bg-muted animate-pulse"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<AlertTriangleIcon
|
||||||
|
className={`size-4 ${decided ? "text-muted-foreground" : "text-foreground"}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className={`text-sm font-medium ${decided ? "text-foreground" : "text-foreground"}`}>
|
||||||
|
Update Notion Page
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
className={`truncate text-xs ${
|
||||||
|
decided ? "text-muted-foreground" : "text-muted-foreground"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isEditing ? "You can edit the arguments below" : "Requires your approval to proceed"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{isEditing && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => setIsFullScreen(!isFullScreen)}
|
||||||
|
className="shrink-0"
|
||||||
|
>
|
||||||
|
{isFullScreen ? (
|
||||||
|
<MinimizeIcon className="size-4" />
|
||||||
|
) : (
|
||||||
|
<MaximizeIcon className="size-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Context section - READ ONLY account and page info */}
|
||||||
|
{!decided && interruptData.context && (
|
||||||
|
<div className="border-b border-border px-4 py-3 bg-muted/30 space-y-3">
|
||||||
|
{interruptData.context.error ? (
|
||||||
|
<p className="text-sm text-destructive">{interruptData.context.error}</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{account && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="text-xs font-medium text-muted-foreground">Notion Account</div>
|
||||||
|
<div className="w-full rounded-md border border-input bg-muted/50 px-3 py-2 text-sm">
|
||||||
|
{account.workspace_icon} {account.workspace_name}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{currentTitle && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="text-xs font-medium text-muted-foreground">Current Page</div>
|
||||||
|
<div className="w-full rounded-md border border-input bg-muted/50 px-3 py-2 text-sm">
|
||||||
|
📄 {currentTitle}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Display mode - show proposed changes as read-only */}
|
||||||
|
{!isEditing && (
|
||||||
|
<div
|
||||||
|
className={`space-y-2 px-4 py-3 bg-card ${isFullScreen ? "flex-1 overflow-y-auto" : ""}`}
|
||||||
|
>
|
||||||
|
{args.content != null && (
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-medium text-muted-foreground">New Content</p>
|
||||||
|
<p className="line-clamp-4 text-sm whitespace-pre-wrap text-foreground">
|
||||||
|
{String(args.content)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{args.content == null && (
|
||||||
|
<p className="text-sm text-muted-foreground italic">No content update specified</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Edit mode - show editable form fields */}
|
||||||
|
{isEditing && !decided && (
|
||||||
|
<div
|
||||||
|
className={`px-4 py-3 bg-card ${isFullScreen ? "flex-1 flex flex-col overflow-hidden" : ""}`}
|
||||||
|
>
|
||||||
|
<label
|
||||||
|
htmlFor="notion-content"
|
||||||
|
className="text-xs font-medium text-muted-foreground mb-1.5 block"
|
||||||
|
>
|
||||||
|
New Content
|
||||||
|
</label>
|
||||||
|
<Textarea
|
||||||
|
id="notion-content"
|
||||||
|
value={String(editedArgs.content ?? "")}
|
||||||
|
onChange={(e) => setEditedArgs({ ...editedArgs, content: e.target.value || null })}
|
||||||
|
placeholder="Enter content to append to the page"
|
||||||
|
rows={isFullScreen ? undefined : 12}
|
||||||
|
className={`resize-none ${isFullScreen ? "flex-1 min-h-0" : ""}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Action buttons */}
|
||||||
|
<div
|
||||||
|
className={`flex items-center gap-2 border-t ${
|
||||||
|
decided ? "border-border bg-card" : "border-foreground/15 bg-muted/20 dark:bg-muted/10"
|
||||||
|
} px-4 py-3`}
|
||||||
|
>
|
||||||
|
{decided ? (
|
||||||
|
<p className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||||
|
{decided === "approve" || decided === "edit" ? (
|
||||||
|
<>
|
||||||
|
<CheckIcon className="size-3.5 text-green-500" />
|
||||||
|
{decided === "edit" ? "Approved with Changes" : "Approved"}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<XIcon className="size-3.5 text-destructive" />
|
||||||
|
Rejected
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
) : isEditing ? (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setDecided("edit");
|
||||||
|
setIsEditing(false);
|
||||||
|
setIsFullScreen(false);
|
||||||
|
onDecision({
|
||||||
|
type: "edit",
|
||||||
|
edited_action: {
|
||||||
|
name: interruptData.action_requests[0].name,
|
||||||
|
args: {
|
||||||
|
page_id: args.page_id,
|
||||||
|
content: editedArgs.content,
|
||||||
|
connector_id: account?.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CheckIcon />
|
||||||
|
Approve with Changes
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
setIsEditing(false);
|
||||||
|
setIsFullScreen(false);
|
||||||
|
setEditedArgs(args); // Reset to original args
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{allowedDecisions.includes("approve") && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setDecided("approve");
|
||||||
|
onDecision({
|
||||||
|
type: "approve",
|
||||||
|
edited_action: {
|
||||||
|
name: interruptData.action_requests[0].name,
|
||||||
|
args: {
|
||||||
|
page_id: args.page_id,
|
||||||
|
content: args.content,
|
||||||
|
connector_id: account?.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CheckIcon />
|
||||||
|
Approve
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{canEdit && (
|
||||||
|
<Button size="sm" variant="outline" onClick={() => setIsEditing(true)}>
|
||||||
|
<PencilIcon />
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{allowedDecisions.includes("reject") && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
setDecided("reject");
|
||||||
|
onDecision({ type: "reject", message: "User rejected the action." });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<XIcon />
|
||||||
|
Reject
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ErrorCard({ result }: { result: ErrorResult }) {
|
||||||
|
return (
|
||||||
|
<div className="my-4 max-w-md overflow-hidden rounded-xl border border-destructive/50 bg-card">
|
||||||
|
<div className="flex items-center gap-3 border-b border-destructive/50 px-4 py-3">
|
||||||
|
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-destructive/10">
|
||||||
|
<XIcon className="size-4 text-destructive" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-sm font-medium text-destructive">Failed to update Notion page</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="px-4 py-3">
|
||||||
|
<p className="text-sm text-muted-foreground">{result.message}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function InfoCard({ result }: { result: InfoResult }) {
|
||||||
|
return (
|
||||||
|
<div className="my-4 max-w-md overflow-hidden rounded-xl border border-amber-500/50 bg-card">
|
||||||
|
<div className="flex items-start gap-3 px-4 py-3">
|
||||||
|
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-amber-500/10">
|
||||||
|
<InfoIcon className="size-4 text-amber-500" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1 pt-2">
|
||||||
|
<p className="text-sm text-muted-foreground">{result.message}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SuccessCard({ result }: { result: SuccessResult }) {
|
||||||
|
return (
|
||||||
|
<div className="my-4 max-w-md overflow-hidden rounded-xl border border-border bg-card">
|
||||||
|
<div className="flex items-center gap-3 border-b border-border px-4 py-3">
|
||||||
|
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-green-500/10">
|
||||||
|
<CheckIcon className="size-4 text-green-500" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-[.8rem] text-muted-foreground">
|
||||||
|
{result.message || "Notion page updated successfully"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2 px-4 py-3 text-xs">
|
||||||
|
<div>
|
||||||
|
<span className="font-medium text-muted-foreground">Title: </span>
|
||||||
|
<span>{result.title}</span>
|
||||||
|
</div>
|
||||||
|
{result.url && (
|
||||||
|
<div>
|
||||||
|
<a
|
||||||
|
href={result.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-primary hover:underline"
|
||||||
|
>
|
||||||
|
Open in Notion
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const UpdateNotionPageToolUI = makeAssistantToolUI<
|
||||||
|
{ page_title: string; content: string },
|
||||||
|
UpdateNotionPageResult
|
||||||
|
>({
|
||||||
|
toolName: "update_notion_page",
|
||||||
|
render: function UpdateNotionPageUI({ args, result, status }) {
|
||||||
|
if (status.type === "running") {
|
||||||
|
return (
|
||||||
|
<div className="my-4 flex max-w-md items-center gap-3 rounded-xl border border-border bg-card px-4 py-3">
|
||||||
|
<Loader2Icon className="size-4 animate-spin text-muted-foreground" />
|
||||||
|
<p className="text-sm text-muted-foreground">Updating Notion page...</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!result) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isInterruptResult(result)) {
|
||||||
|
return (
|
||||||
|
<ApprovalCard
|
||||||
|
args={args}
|
||||||
|
interruptData={result}
|
||||||
|
onDecision={(decision) => {
|
||||||
|
const event = new CustomEvent("hitl-decision", {
|
||||||
|
detail: { decisions: [decision] },
|
||||||
|
});
|
||||||
|
window.dispatchEvent(event);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
typeof result === "object" &&
|
||||||
|
result !== null &&
|
||||||
|
"status" in result &&
|
||||||
|
(result as { status: string }).status === "rejected"
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isInfoResult(result)) {
|
||||||
|
return <InfoCard result={result} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isErrorResult(result)) {
|
||||||
|
return <ErrorCard result={result} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <SuccessCard result={result as SuccessResult} />;
|
||||||
|
},
|
||||||
|
});
|
||||||
151
surfsense_web/lib/chat/streaming-state.ts
Normal file
151
surfsense_web/lib/chat/streaming-state.ts
Normal file
|
|
@ -0,0 +1,151 @@
|
||||||
|
import type { ThreadMessageLike } from "@assistant-ui/react";
|
||||||
|
|
||||||
|
export interface ThinkingStepData {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
status: "pending" | "in_progress" | "completed";
|
||||||
|
items: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ContentPart =
|
||||||
|
| { type: "text"; text: string }
|
||||||
|
| {
|
||||||
|
type: "tool-call";
|
||||||
|
toolCallId: string;
|
||||||
|
toolName: string;
|
||||||
|
args: Record<string, unknown>;
|
||||||
|
result?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface ContentPartsState {
|
||||||
|
contentParts: ContentPart[];
|
||||||
|
currentTextPartIndex: number;
|
||||||
|
toolCallIndices: Map<string, number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function appendText(state: ContentPartsState, delta: string): void {
|
||||||
|
if (
|
||||||
|
state.currentTextPartIndex >= 0 &&
|
||||||
|
state.contentParts[state.currentTextPartIndex]?.type === "text"
|
||||||
|
) {
|
||||||
|
(state.contentParts[state.currentTextPartIndex] as { type: "text"; text: string }).text +=
|
||||||
|
delta;
|
||||||
|
} else {
|
||||||
|
state.contentParts.push({ type: "text", text: delta });
|
||||||
|
state.currentTextPartIndex = state.contentParts.length - 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addToolCall(
|
||||||
|
state: ContentPartsState,
|
||||||
|
toolsWithUI: Set<string>,
|
||||||
|
toolCallId: string,
|
||||||
|
toolName: string,
|
||||||
|
args: Record<string, unknown>
|
||||||
|
): void {
|
||||||
|
if (toolsWithUI.has(toolName)) {
|
||||||
|
state.contentParts.push({
|
||||||
|
type: "tool-call",
|
||||||
|
toolCallId,
|
||||||
|
toolName,
|
||||||
|
args,
|
||||||
|
});
|
||||||
|
state.toolCallIndices.set(toolCallId, state.contentParts.length - 1);
|
||||||
|
state.currentTextPartIndex = -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateToolCall(
|
||||||
|
state: ContentPartsState,
|
||||||
|
toolCallId: string,
|
||||||
|
update: { args?: Record<string, unknown>; result?: unknown }
|
||||||
|
): void {
|
||||||
|
const index = state.toolCallIndices.get(toolCallId);
|
||||||
|
if (index !== undefined && state.contentParts[index]?.type === "tool-call") {
|
||||||
|
const tc = state.contentParts[index] as ContentPart & { type: "tool-call" };
|
||||||
|
if (update.args) tc.args = update.args;
|
||||||
|
if (update.result !== undefined) tc.result = update.result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildContentForUI(
|
||||||
|
state: ContentPartsState,
|
||||||
|
toolsWithUI: Set<string>
|
||||||
|
): ThreadMessageLike["content"] {
|
||||||
|
const filtered = state.contentParts.filter((part) => {
|
||||||
|
if (part.type === "text") return part.text.length > 0;
|
||||||
|
if (part.type === "tool-call") return toolsWithUI.has(part.toolName);
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
return filtered.length > 0
|
||||||
|
? (filtered as ThreadMessageLike["content"])
|
||||||
|
: [{ type: "text", text: "" }];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildContentForPersistence(
|
||||||
|
state: ContentPartsState,
|
||||||
|
toolsWithUI: Set<string>,
|
||||||
|
currentThinkingSteps: Map<string, ThinkingStepData>
|
||||||
|
): unknown[] {
|
||||||
|
const parts: unknown[] = [];
|
||||||
|
|
||||||
|
if (currentThinkingSteps.size > 0) {
|
||||||
|
parts.push({
|
||||||
|
type: "thinking-steps",
|
||||||
|
steps: Array.from(currentThinkingSteps.values()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const part of state.contentParts) {
|
||||||
|
if (part.type === "text" && part.text.length > 0) {
|
||||||
|
parts.push(part);
|
||||||
|
} else if (part.type === "tool-call" && toolsWithUI.has(part.toolName)) {
|
||||||
|
parts.push(part);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts.length > 0 ? parts : [{ type: "text", text: "" }];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Async generator that reads an SSE stream and yields parsed JSON objects.
|
||||||
|
* Handles buffering, event splitting, and skips malformed JSON / [DONE] lines.
|
||||||
|
*/
|
||||||
|
export async function* readSSEStream(response: Response): AsyncGenerator<any> {
|
||||||
|
if (!response.body) {
|
||||||
|
throw new Error("No response body");
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = response.body.getReader();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let buffer = "";
|
||||||
|
|
||||||
|
try {
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
|
||||||
|
buffer += decoder.decode(value, { stream: true });
|
||||||
|
const events = buffer.split(/\r?\n\r?\n/);
|
||||||
|
buffer = events.pop() || "";
|
||||||
|
|
||||||
|
for (const event of events) {
|
||||||
|
const lines = event.split(/\r?\n/);
|
||||||
|
for (const line of lines) {
|
||||||
|
if (!line.startsWith("data: ")) continue;
|
||||||
|
const data = line.slice(6).trim();
|
||||||
|
if (!data || data === "[DONE]") continue;
|
||||||
|
|
||||||
|
try {
|
||||||
|
yield JSON.parse(data);
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof SyntaxError) continue;
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
reader.releaseLock();
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue