feat: add initial logic for HITL for gmail and google calendar connectors

This commit is contained in:
Anish Sarkar 2026-03-20 18:47:06 +05:30
parent 6d1c7731f0
commit f4c0c8c945
27 changed files with 6280 additions and 5 deletions

View file

@ -305,6 +305,32 @@ async def create_surfsense_deep_agent(
]
modified_disabled_tools.extend(google_drive_tools)
# Disable Google Calendar action tools if no Google Calendar connector is configured
has_google_calendar_connector = (
available_connectors is not None
and "GOOGLE_CALENDAR_CONNECTOR" in available_connectors
)
if not has_google_calendar_connector:
calendar_tools = [
"create_calendar_event",
"update_calendar_event",
"delete_calendar_event",
]
modified_disabled_tools.extend(calendar_tools)
# Disable Gmail action tools if no Gmail connector is configured
has_gmail_connector = (
available_connectors is not None
and "GOOGLE_GMAIL_CONNECTOR" in available_connectors
)
if not has_gmail_connector:
gmail_tools = [
"create_gmail_draft",
"send_gmail_email",
"trash_gmail_email",
]
modified_disabled_tools.extend(gmail_tools)
# Build tools using the async registry (includes MCP tools)
_t0 = time.perf_counter()
tools = await build_tools_async(

View file

@ -0,0 +1,15 @@
from app.agents.new_chat.tools.gmail.create_draft import (
create_create_gmail_draft_tool,
)
from app.agents.new_chat.tools.gmail.send_email import (
create_send_gmail_email_tool,
)
from app.agents.new_chat.tools.gmail.trash_email import (
create_trash_gmail_email_tool,
)
__all__ = [
"create_create_gmail_draft_tool",
"create_send_gmail_email_tool",
"create_trash_gmail_email_tool",
]

View file

@ -0,0 +1,320 @@
import asyncio
import base64
import logging
from datetime import datetime
from email.mime.text import MIMEText
from typing import Any
from langchain_core.tools import tool
from langgraph.types import interrupt
from sqlalchemy.ext.asyncio import AsyncSession
from app.services.gmail import GmailToolMetadataService
logger = logging.getLogger(__name__)
def create_create_gmail_draft_tool(
db_session: AsyncSession | None = None,
search_space_id: int | None = None,
user_id: str | None = None,
):
@tool
async def create_gmail_draft(
to: str,
subject: str,
body: str,
cc: str | None = None,
bcc: str | None = None,
) -> dict[str, Any]:
"""Create a draft email in Gmail.
Use when the user asks to draft, compose, or prepare an email without
sending it.
Args:
to: Recipient email address.
subject: Email subject line.
body: Email body content.
cc: Optional CC recipient(s), comma-separated.
bcc: Optional BCC recipient(s), comma-separated.
Returns:
Dictionary with:
- status: "success", "rejected", or "error"
- draft_id: Gmail draft ID (if success)
- message: Result message
IMPORTANT:
- If status is "rejected", the user explicitly declined the action.
Respond with a brief acknowledgment and do NOT retry or suggest alternatives.
- If status is "insufficient_permissions", the connector lacks the required OAuth scope.
Inform the user they need to re-authenticate and do NOT retry the action.
Examples:
- "Draft an email to alice@example.com about the meeting"
- "Compose a reply to Bob about the project update"
"""
logger.info(
f"create_gmail_draft called: to='{to}', subject='{subject}'"
)
if db_session is None or search_space_id is None or user_id is None:
return {
"status": "error",
"message": "Gmail tool not properly configured. Please contact support.",
}
try:
metadata_service = GmailToolMetadataService(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"]}
accounts = context.get("accounts", [])
if accounts and all(a.get("auth_expired") for a in accounts):
logger.warning("All Gmail accounts have expired authentication")
return {
"status": "auth_error",
"message": "All connected Gmail accounts need re-authentication. Please re-authenticate in your connector settings.",
"connector_type": "gmail",
}
logger.info(
f"Requesting approval for creating Gmail draft: to='{to}', subject='{subject}'"
)
approval = interrupt(
{
"type": "gmail_draft_creation",
"action": {
"tool": "create_gmail_draft",
"params": {
"to": to,
"subject": subject,
"body": body,
"cc": cc,
"bcc": bcc,
"connector_id": None,
},
},
"context": context,
}
)
decisions_raw = (
approval.get("decisions", []) if isinstance(approval, dict) else []
)
decisions = (
decisions_raw if isinstance(decisions_raw, list) else [decisions_raw]
)
decisions = [d for d in decisions if isinstance(d, dict)]
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":
return {
"status": "rejected",
"message": "User declined. The draft was not created. Do not ask again or suggest alternatives.",
}
final_params: dict[str, Any] = {}
edited_action = decision.get("edited_action")
if isinstance(edited_action, dict):
edited_args = edited_action.get("args")
if isinstance(edited_args, dict):
final_params = edited_args
elif isinstance(decision.get("args"), dict):
final_params = decision["args"]
final_to = final_params.get("to", to)
final_subject = final_params.get("subject", subject)
final_body = final_params.get("body", body)
final_cc = final_params.get("cc", cc)
final_bcc = final_params.get("bcc", bcc)
final_connector_id = final_params.get("connector_id")
from sqlalchemy.future import select
from app.db import SearchSourceConnector, SearchSourceConnectorType
_GMAIL_TYPES = [
SearchSourceConnectorType.GOOGLE_GMAIL_CONNECTOR,
SearchSourceConnectorType.COMPOSIO_GMAIL_CONNECTOR,
]
if final_connector_id is not None:
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.in_(_GMAIL_TYPES),
)
)
connector = result.scalars().first()
if not connector:
return {
"status": "error",
"message": "Selected Gmail connector is invalid or has been disconnected.",
}
actual_connector_id = connector.id
else:
result = await db_session.execute(
select(SearchSourceConnector).filter(
SearchSourceConnector.search_space_id == search_space_id,
SearchSourceConnector.user_id == user_id,
SearchSourceConnector.connector_type.in_(_GMAIL_TYPES),
)
)
connector = result.scalars().first()
if not connector:
return {
"status": "error",
"message": "No Gmail connector found. Please connect Gmail in your workspace settings.",
}
actual_connector_id = connector.id
logger.info(
f"Creating Gmail draft: to='{final_to}', subject='{final_subject}', connector={actual_connector_id}"
)
if connector.connector_type == SearchSourceConnectorType.COMPOSIO_GMAIL_CONNECTOR:
from app.utils.google_credentials import build_composio_credentials
cca_id = connector.config.get("composio_connected_account_id")
if cca_id:
creds = build_composio_credentials(cca_id)
else:
return {
"status": "error",
"message": "Composio connected account ID not found for this Gmail connector.",
}
else:
from google.oauth2.credentials import Credentials
from app.config import config
from app.utils.oauth_security import TokenEncryption
config_data = dict(connector.config)
token_encrypted = config_data.get("_token_encrypted", False)
if token_encrypted and config.SECRET_KEY:
token_encryption = TokenEncryption(config.SECRET_KEY)
if config_data.get("token"):
config_data["token"] = token_encryption.decrypt_token(
config_data["token"]
)
if config_data.get("refresh_token"):
config_data["refresh_token"] = token_encryption.decrypt_token(
config_data["refresh_token"]
)
if config_data.get("client_secret"):
config_data["client_secret"] = token_encryption.decrypt_token(
config_data["client_secret"]
)
exp = config_data.get("expiry", "")
if exp:
exp = exp.replace("Z", "")
creds = Credentials(
token=config_data.get("token"),
refresh_token=config_data.get("refresh_token"),
token_uri=config_data.get("token_uri"),
client_id=config_data.get("client_id"),
client_secret=config_data.get("client_secret"),
scopes=config_data.get("scopes", []),
expiry=datetime.fromisoformat(exp) if exp else None,
)
from googleapiclient.discovery import build
gmail_service = build("gmail", "v1", credentials=creds)
message = MIMEText(final_body)
message["to"] = final_to
message["subject"] = final_subject
if final_cc:
message["cc"] = final_cc
if final_bcc:
message["bcc"] = final_bcc
raw = base64.urlsafe_b64encode(message.as_bytes()).decode()
try:
created = await asyncio.get_event_loop().run_in_executor(
None,
lambda: gmail_service.users()
.drafts()
.create(userId="me", body={"message": {"raw": raw}})
.execute(),
)
except Exception as api_err:
from googleapiclient.errors import HttpError
if isinstance(api_err, HttpError) and api_err.resp.status == 403:
logger.warning(
f"Insufficient permissions for connector {actual_connector_id}: {api_err}"
)
return {
"status": "insufficient_permissions",
"connector_id": actual_connector_id,
"message": "This Gmail account needs additional permissions. Please re-authenticate.",
}
raise
logger.info(
f"Gmail draft created: id={created.get('id')}"
)
kb_message_suffix = ""
try:
from app.services.gmail import GmailKBSyncService
kb_service = GmailKBSyncService(db_session)
draft_message = created.get("message", {})
kb_result = await kb_service.sync_after_create(
message_id=draft_message.get("id", ""),
thread_id=draft_message.get("threadId", ""),
subject=final_subject,
sender="me",
date_str=datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
body_text=final_body,
connector_id=actual_connector_id,
search_space_id=search_space_id,
user_id=user_id,
)
if kb_result["status"] == "success":
kb_message_suffix = " Your knowledge base has also been updated."
else:
kb_message_suffix = " This draft will be added to your knowledge base in the next scheduled sync."
except Exception as kb_err:
logger.warning(f"KB sync after create failed: {kb_err}")
kb_message_suffix = " This draft will be added to your knowledge base in the next scheduled sync."
return {
"status": "success",
"draft_id": created.get("id"),
"message": f"Successfully created Gmail draft with subject '{final_subject}'.{kb_message_suffix}",
}
except Exception as e:
from langgraph.errors import GraphInterrupt
if isinstance(e, GraphInterrupt):
raise
logger.error(f"Error creating Gmail draft: {e}", exc_info=True)
return {
"status": "error",
"message": "Something went wrong while creating the draft. Please try again.",
}
return create_gmail_draft

View file

@ -0,0 +1,321 @@
import asyncio
import base64
import logging
from datetime import datetime
from email.mime.text import MIMEText
from typing import Any
from langchain_core.tools import tool
from langgraph.types import interrupt
from sqlalchemy.ext.asyncio import AsyncSession
from app.services.gmail import GmailToolMetadataService
logger = logging.getLogger(__name__)
def create_send_gmail_email_tool(
db_session: AsyncSession | None = None,
search_space_id: int | None = None,
user_id: str | None = None,
):
@tool
async def send_gmail_email(
to: str,
subject: str,
body: str,
cc: str | None = None,
bcc: str | None = None,
) -> dict[str, Any]:
"""Send an email via Gmail.
Use when the user explicitly asks to send an email. This sends the
email immediately - it cannot be unsent.
Args:
to: Recipient email address.
subject: Email subject line.
body: Email body content.
cc: Optional CC recipient(s), comma-separated.
bcc: Optional BCC recipient(s), comma-separated.
Returns:
Dictionary with:
- status: "success", "rejected", or "error"
- message_id: Gmail message ID (if success)
- thread_id: Gmail thread ID (if success)
- message: Result message
IMPORTANT:
- If status is "rejected", the user explicitly declined the action.
Respond with a brief acknowledgment and do NOT retry or suggest alternatives.
- If status is "insufficient_permissions", the connector lacks the required OAuth scope.
Inform the user they need to re-authenticate and do NOT retry the action.
Examples:
- "Send an email to alice@example.com about the meeting"
- "Email Bob the project update"
"""
logger.info(
f"send_gmail_email called: to='{to}', subject='{subject}'"
)
if db_session is None or search_space_id is None or user_id is None:
return {
"status": "error",
"message": "Gmail tool not properly configured. Please contact support.",
}
try:
metadata_service = GmailToolMetadataService(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"]}
accounts = context.get("accounts", [])
if accounts and all(a.get("auth_expired") for a in accounts):
logger.warning("All Gmail accounts have expired authentication")
return {
"status": "auth_error",
"message": "All connected Gmail accounts need re-authentication. Please re-authenticate in your connector settings.",
"connector_type": "gmail",
}
logger.info(
f"Requesting approval for sending Gmail email: to='{to}', subject='{subject}'"
)
approval = interrupt(
{
"type": "gmail_email_send",
"action": {
"tool": "send_gmail_email",
"params": {
"to": to,
"subject": subject,
"body": body,
"cc": cc,
"bcc": bcc,
"connector_id": None,
},
},
"context": context,
}
)
decisions_raw = (
approval.get("decisions", []) if isinstance(approval, dict) else []
)
decisions = (
decisions_raw if isinstance(decisions_raw, list) else [decisions_raw]
)
decisions = [d for d in decisions if isinstance(d, dict)]
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":
return {
"status": "rejected",
"message": "User declined. The email was not sent. Do not ask again or suggest alternatives.",
}
final_params: dict[str, Any] = {}
edited_action = decision.get("edited_action")
if isinstance(edited_action, dict):
edited_args = edited_action.get("args")
if isinstance(edited_args, dict):
final_params = edited_args
elif isinstance(decision.get("args"), dict):
final_params = decision["args"]
final_to = final_params.get("to", to)
final_subject = final_params.get("subject", subject)
final_body = final_params.get("body", body)
final_cc = final_params.get("cc", cc)
final_bcc = final_params.get("bcc", bcc)
final_connector_id = final_params.get("connector_id")
from sqlalchemy.future import select
from app.db import SearchSourceConnector, SearchSourceConnectorType
_GMAIL_TYPES = [
SearchSourceConnectorType.GOOGLE_GMAIL_CONNECTOR,
SearchSourceConnectorType.COMPOSIO_GMAIL_CONNECTOR,
]
if final_connector_id is not None:
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.in_(_GMAIL_TYPES),
)
)
connector = result.scalars().first()
if not connector:
return {
"status": "error",
"message": "Selected Gmail connector is invalid or has been disconnected.",
}
actual_connector_id = connector.id
else:
result = await db_session.execute(
select(SearchSourceConnector).filter(
SearchSourceConnector.search_space_id == search_space_id,
SearchSourceConnector.user_id == user_id,
SearchSourceConnector.connector_type.in_(_GMAIL_TYPES),
)
)
connector = result.scalars().first()
if not connector:
return {
"status": "error",
"message": "No Gmail connector found. Please connect Gmail in your workspace settings.",
}
actual_connector_id = connector.id
logger.info(
f"Sending Gmail email: to='{final_to}', subject='{final_subject}', connector={actual_connector_id}"
)
if connector.connector_type == SearchSourceConnectorType.COMPOSIO_GMAIL_CONNECTOR:
from app.utils.google_credentials import build_composio_credentials
cca_id = connector.config.get("composio_connected_account_id")
if cca_id:
creds = build_composio_credentials(cca_id)
else:
return {
"status": "error",
"message": "Composio connected account ID not found for this Gmail connector.",
}
else:
from google.oauth2.credentials import Credentials
from app.config import config
from app.utils.oauth_security import TokenEncryption
config_data = dict(connector.config)
token_encrypted = config_data.get("_token_encrypted", False)
if token_encrypted and config.SECRET_KEY:
token_encryption = TokenEncryption(config.SECRET_KEY)
if config_data.get("token"):
config_data["token"] = token_encryption.decrypt_token(
config_data["token"]
)
if config_data.get("refresh_token"):
config_data["refresh_token"] = token_encryption.decrypt_token(
config_data["refresh_token"]
)
if config_data.get("client_secret"):
config_data["client_secret"] = token_encryption.decrypt_token(
config_data["client_secret"]
)
exp = config_data.get("expiry", "")
if exp:
exp = exp.replace("Z", "")
creds = Credentials(
token=config_data.get("token"),
refresh_token=config_data.get("refresh_token"),
token_uri=config_data.get("token_uri"),
client_id=config_data.get("client_id"),
client_secret=config_data.get("client_secret"),
scopes=config_data.get("scopes", []),
expiry=datetime.fromisoformat(exp) if exp else None,
)
from googleapiclient.discovery import build
gmail_service = build("gmail", "v1", credentials=creds)
message = MIMEText(final_body)
message["to"] = final_to
message["subject"] = final_subject
if final_cc:
message["cc"] = final_cc
if final_bcc:
message["bcc"] = final_bcc
raw = base64.urlsafe_b64encode(message.as_bytes()).decode()
try:
sent = await asyncio.get_event_loop().run_in_executor(
None,
lambda: gmail_service.users()
.messages()
.send(userId="me", body={"raw": raw})
.execute(),
)
except Exception as api_err:
from googleapiclient.errors import HttpError
if isinstance(api_err, HttpError) and api_err.resp.status == 403:
logger.warning(
f"Insufficient permissions for connector {actual_connector_id}: {api_err}"
)
return {
"status": "insufficient_permissions",
"connector_id": actual_connector_id,
"message": "This Gmail account needs additional permissions. Please re-authenticate.",
}
raise
logger.info(
f"Gmail email sent: id={sent.get('id')}, threadId={sent.get('threadId')}"
)
kb_message_suffix = ""
try:
from app.services.gmail import GmailKBSyncService
kb_service = GmailKBSyncService(db_session)
kb_result = await kb_service.sync_after_create(
message_id=sent.get("id", ""),
thread_id=sent.get("threadId", ""),
subject=final_subject,
sender="me",
date_str=datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
body_text=final_body,
connector_id=actual_connector_id,
search_space_id=search_space_id,
user_id=user_id,
)
if kb_result["status"] == "success":
kb_message_suffix = " Your knowledge base has also been updated."
else:
kb_message_suffix = " This email will be added to your knowledge base in the next scheduled sync."
except Exception as kb_err:
logger.warning(f"KB sync after send failed: {kb_err}")
kb_message_suffix = " This email will be added to your knowledge base in the next scheduled sync."
return {
"status": "success",
"message_id": sent.get("id"),
"thread_id": sent.get("threadId"),
"message": f"Successfully sent email to '{final_to}' with subject '{final_subject}'.{kb_message_suffix}",
}
except Exception as e:
from langgraph.errors import GraphInterrupt
if isinstance(e, GraphInterrupt):
raise
logger.error(f"Error sending Gmail email: {e}", exc_info=True)
return {
"status": "error",
"message": "Something went wrong while sending the email. Please try again.",
}
return send_gmail_email

View file

@ -0,0 +1,319 @@
import asyncio
import logging
from datetime import datetime
from typing import Any
from langchain_core.tools import tool
from langgraph.types import interrupt
from sqlalchemy.ext.asyncio import AsyncSession
from app.services.gmail import GmailToolMetadataService
logger = logging.getLogger(__name__)
def create_trash_gmail_email_tool(
db_session: AsyncSession | None = None,
search_space_id: int | None = None,
user_id: str | None = None,
):
@tool
async def trash_gmail_email(
email_subject_or_id: str,
delete_from_kb: bool = False,
) -> dict[str, Any]:
"""Move an email to trash in Gmail.
Use when the user asks to delete, remove, or trash an email.
Args:
email_subject_or_id: The exact subject line or message ID of the
email to trash (as it appears in the inbox).
delete_from_kb: Whether to also remove the email from the knowledge base.
Default is False.
Set to True to remove from both Gmail and knowledge base.
Returns:
Dictionary with:
- status: "success", "rejected", "not_found", or "error"
- message_id: Gmail message ID (if success)
- deleted_from_kb: whether the document was removed from the knowledge base
- message: Result message
IMPORTANT:
- If status is "rejected", the user explicitly declined. Respond with a brief
acknowledgment and do NOT retry or suggest alternatives.
- If status is "not_found", relay the exact message to the user and ask them
to verify the email subject or check if it has been indexed.
- If status is "insufficient_permissions", the connector lacks the required OAuth scope.
Inform the user they need to re-authenticate and do NOT retry this tool.
Examples:
- "Delete the email about 'Meeting Cancelled'"
- "Trash the email from Bob about the project"
"""
logger.info(
f"trash_gmail_email called: email_subject_or_id='{email_subject_or_id}', delete_from_kb={delete_from_kb}"
)
if db_session is None or search_space_id is None or user_id is None:
return {
"status": "error",
"message": "Gmail tool not properly configured. Please contact support.",
}
try:
metadata_service = GmailToolMetadataService(db_session)
context = await metadata_service.get_trash_context(
search_space_id, user_id, email_subject_or_id
)
if "error" in context:
error_msg = context["error"]
if "not found" in error_msg.lower():
logger.warning(f"Email not found: {error_msg}")
return {"status": "not_found", "message": error_msg}
logger.error(f"Failed to fetch trash context: {error_msg}")
return {"status": "error", "message": error_msg}
account = context.get("account", {})
if account.get("auth_expired"):
logger.warning(
"Gmail account %s has expired authentication",
account.get("id"),
)
return {
"status": "auth_error",
"message": "The Gmail account for this email needs re-authentication. Please re-authenticate in your connector settings.",
"connector_type": "gmail",
}
email = context["email"]
message_id = email["message_id"]
document_id = email.get("document_id")
connector_id_from_context = context["account"]["id"]
if not message_id:
return {
"status": "error",
"message": "Message ID is missing from the indexed document. Please re-index the email and try again.",
}
logger.info(
f"Requesting approval for trashing Gmail email: '{email_subject_or_id}' (message_id={message_id}, delete_from_kb={delete_from_kb})"
)
approval = interrupt(
{
"type": "gmail_email_trash",
"action": {
"tool": "trash_gmail_email",
"params": {
"message_id": message_id,
"connector_id": connector_id_from_context,
"delete_from_kb": delete_from_kb,
},
},
"context": context,
}
)
decisions_raw = (
approval.get("decisions", []) if isinstance(approval, dict) else []
)
decisions = (
decisions_raw if isinstance(decisions_raw, list) else [decisions_raw]
)
decisions = [d for d in decisions if isinstance(d, dict)]
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":
return {
"status": "rejected",
"message": "User declined. The email was not trashed. Do not ask again or suggest alternatives.",
}
edited_action = decision.get("edited_action")
final_params: dict[str, Any] = {}
if isinstance(edited_action, dict):
edited_args = edited_action.get("args")
if isinstance(edited_args, dict):
final_params = edited_args
elif isinstance(decision.get("args"), dict):
final_params = decision["args"]
final_message_id = final_params.get("message_id", message_id)
final_connector_id = final_params.get(
"connector_id", connector_id_from_context
)
final_delete_from_kb = final_params.get("delete_from_kb", delete_from_kb)
if not final_connector_id:
return {
"status": "error",
"message": "No connector found for this email.",
}
from sqlalchemy.future import select
from app.db import SearchSourceConnector, SearchSourceConnectorType
_GMAIL_TYPES = [
SearchSourceConnectorType.GOOGLE_GMAIL_CONNECTOR,
SearchSourceConnectorType.COMPOSIO_GMAIL_CONNECTOR,
]
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.in_(_GMAIL_TYPES),
)
)
connector = result.scalars().first()
if not connector:
return {
"status": "error",
"message": "Selected Gmail connector is invalid or has been disconnected.",
}
logger.info(
f"Trashing Gmail email: message_id='{final_message_id}', connector={final_connector_id}"
)
if connector.connector_type == SearchSourceConnectorType.COMPOSIO_GMAIL_CONNECTOR:
from app.utils.google_credentials import build_composio_credentials
cca_id = connector.config.get("composio_connected_account_id")
if cca_id:
creds = build_composio_credentials(cca_id)
else:
return {
"status": "error",
"message": "Composio connected account ID not found for this Gmail connector.",
}
else:
from google.oauth2.credentials import Credentials
from app.config import config
from app.utils.oauth_security import TokenEncryption
config_data = dict(connector.config)
token_encrypted = config_data.get("_token_encrypted", False)
if token_encrypted and config.SECRET_KEY:
token_encryption = TokenEncryption(config.SECRET_KEY)
if config_data.get("token"):
config_data["token"] = token_encryption.decrypt_token(
config_data["token"]
)
if config_data.get("refresh_token"):
config_data["refresh_token"] = token_encryption.decrypt_token(
config_data["refresh_token"]
)
if config_data.get("client_secret"):
config_data["client_secret"] = token_encryption.decrypt_token(
config_data["client_secret"]
)
exp = config_data.get("expiry", "")
if exp:
exp = exp.replace("Z", "")
creds = Credentials(
token=config_data.get("token"),
refresh_token=config_data.get("refresh_token"),
token_uri=config_data.get("token_uri"),
client_id=config_data.get("client_id"),
client_secret=config_data.get("client_secret"),
scopes=config_data.get("scopes", []),
expiry=datetime.fromisoformat(exp) if exp else None,
)
from googleapiclient.discovery import build
gmail_service = build("gmail", "v1", credentials=creds)
try:
await asyncio.get_event_loop().run_in_executor(
None,
lambda: gmail_service.users()
.messages()
.trash(userId="me", id=final_message_id)
.execute(),
)
except Exception as api_err:
from googleapiclient.errors import HttpError
if isinstance(api_err, HttpError) and api_err.resp.status == 403:
logger.warning(
f"Insufficient permissions for connector {connector.id}: {api_err}"
)
return {
"status": "insufficient_permissions",
"connector_id": connector.id,
"message": "This Gmail account needs additional permissions. Please re-authenticate.",
}
raise
logger.info(
f"Gmail email trashed: message_id={final_message_id}"
)
trash_result: dict[str, Any] = {
"status": "success",
"message_id": final_message_id,
"message": f"Successfully moved email '{email.get('subject', email_subject_or_id)}' to trash.",
}
deleted_from_kb = False
if final_delete_from_kb and document_id:
try:
from app.db import 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_kb = True
logger.info(
f"Deleted document {document_id} from knowledge base"
)
else:
logger.warning(f"Document {document_id} not found in KB")
except Exception as e:
logger.error(f"Failed to delete document from KB: {e}")
await db_session.rollback()
trash_result["warning"] = (
f"Email trashed, but failed to remove from knowledge base: {e!s}"
)
trash_result["deleted_from_kb"] = deleted_from_kb
if deleted_from_kb:
trash_result["message"] = (
f"{trash_result.get('message', '')} (also removed from knowledge base)"
)
return trash_result
except Exception as e:
from langgraph.errors import GraphInterrupt
if isinstance(e, GraphInterrupt):
raise
logger.error(f"Error trashing Gmail email: {e}", exc_info=True)
return {
"status": "error",
"message": "Something went wrong while trashing the email. Please try again.",
}
return trash_gmail_email

View file

@ -0,0 +1,15 @@
from app.agents.new_chat.tools.google_calendar.create_event import (
create_create_calendar_event_tool,
)
from app.agents.new_chat.tools.google_calendar.update_event import (
create_update_calendar_event_tool,
)
from app.agents.new_chat.tools.google_calendar.delete_event import (
create_delete_calendar_event_tool,
)
__all__ = [
"create_create_calendar_event_tool",
"create_update_calendar_event_tool",
"create_delete_calendar_event_tool",
]

View file

@ -0,0 +1,310 @@
import asyncio
import logging
from datetime import datetime
from typing import Any
from google.oauth2.credentials import Credentials
from googleapiclient.discovery import build
from langchain_core.tools import tool
from langgraph.types import interrupt
from sqlalchemy.ext.asyncio import AsyncSession
from app.services.google_calendar import GoogleCalendarToolMetadataService
logger = logging.getLogger(__name__)
def create_create_calendar_event_tool(
db_session: AsyncSession | None = None,
search_space_id: int | None = None,
user_id: str | None = None,
):
@tool
async def create_calendar_event(
summary: str,
start_datetime: str,
end_datetime: str,
description: str | None = None,
location: str | None = None,
attendees: list[str] | None = None,
) -> dict[str, Any]:
"""Create a new event on Google Calendar.
Use when the user asks to schedule, create, or add a calendar event.
Ask for event details if not provided.
Args:
summary: The event title.
start_datetime: Start time in ISO 8601 format (e.g. "2026-03-20T10:00:00").
end_datetime: End time in ISO 8601 format (e.g. "2026-03-20T11:00:00").
description: Optional event description.
location: Optional event location.
attendees: Optional list of attendee email addresses.
Returns:
Dictionary with:
- status: "success", "rejected", "auth_error", or "error"
- event_id: Google Calendar event ID (if success)
- html_link: URL to open the event (if success)
- message: Result message
IMPORTANT:
- If status is "rejected", the user explicitly declined the action.
Respond with a brief acknowledgment and do NOT retry or suggest alternatives.
Examples:
- "Schedule a meeting with John tomorrow at 10am"
- "Create a calendar event for the team standup"
"""
logger.info(
f"create_calendar_event called: summary='{summary}', start='{start_datetime}', end='{end_datetime}'"
)
if db_session is None or search_space_id is None or user_id is None:
return {
"status": "error",
"message": "Google Calendar tool not properly configured. Please contact support.",
}
try:
metadata_service = GoogleCalendarToolMetadataService(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"]}
accounts = context.get("accounts", [])
if accounts and all(a.get("auth_expired") for a in accounts):
logger.warning("All Google Calendar accounts have expired authentication")
return {
"status": "auth_error",
"message": "All connected Google Calendar accounts need re-authentication. Please re-authenticate in your connector settings.",
"connector_type": "google_calendar",
}
logger.info(
f"Requesting approval for creating calendar event: summary='{summary}'"
)
approval = interrupt(
{
"type": "google_calendar_event_creation",
"action": {
"tool": "create_calendar_event",
"params": {
"summary": summary,
"start_datetime": start_datetime,
"end_datetime": end_datetime,
"description": description,
"location": location,
"attendees": attendees,
"timezone": context.get("timezone"),
"connector_id": None,
},
},
"context": context,
}
)
decisions_raw = (
approval.get("decisions", []) if isinstance(approval, dict) else []
)
decisions = (
decisions_raw if isinstance(decisions_raw, list) else [decisions_raw]
)
decisions = [d for d in decisions if isinstance(d, dict)]
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":
return {
"status": "rejected",
"message": "User declined. The event was not created. Do not ask again or suggest alternatives.",
}
final_params: dict[str, Any] = {}
edited_action = decision.get("edited_action")
if isinstance(edited_action, dict):
edited_args = edited_action.get("args")
if isinstance(edited_args, dict):
final_params = edited_args
elif isinstance(decision.get("args"), dict):
final_params = decision["args"]
final_summary = final_params.get("summary", summary)
final_start_datetime = final_params.get("start_datetime", start_datetime)
final_end_datetime = final_params.get("end_datetime", end_datetime)
final_description = final_params.get("description", description)
final_location = final_params.get("location", location)
final_attendees = final_params.get("attendees", attendees)
final_connector_id = final_params.get("connector_id")
if not final_summary or not final_summary.strip():
return {"status": "error", "message": "Event summary cannot be empty."}
from sqlalchemy.future import select
from app.db import SearchSourceConnector, SearchSourceConnectorType
_CALENDAR_TYPES = [
SearchSourceConnectorType.GOOGLE_CALENDAR_CONNECTOR,
SearchSourceConnectorType.COMPOSIO_GOOGLE_CALENDAR_CONNECTOR,
]
if final_connector_id is not None:
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.in_(_CALENDAR_TYPES),
)
)
connector = result.scalars().first()
if not connector:
return {
"status": "error",
"message": "Selected Google Calendar connector is invalid or has been disconnected.",
}
actual_connector_id = connector.id
else:
result = await db_session.execute(
select(SearchSourceConnector).filter(
SearchSourceConnector.search_space_id == search_space_id,
SearchSourceConnector.user_id == user_id,
SearchSourceConnector.connector_type.in_(_CALENDAR_TYPES),
)
)
connector = result.scalars().first()
if not connector:
return {
"status": "error",
"message": "No Google Calendar connector found. Please connect Google Calendar in your workspace settings.",
}
actual_connector_id = connector.id
logger.info(
f"Creating calendar event: summary='{final_summary}', connector={actual_connector_id}"
)
if connector.connector_type == SearchSourceConnectorType.COMPOSIO_GOOGLE_CALENDAR_CONNECTOR:
from app.utils.google_credentials import build_composio_credentials
cca_id = connector.config.get("composio_connected_account_id")
if cca_id:
creds = build_composio_credentials(cca_id)
else:
return {
"status": "error",
"message": "Composio connected account ID not found for this connector.",
}
else:
config_data = dict(connector.config)
from app.config import config as app_config
from app.utils.oauth_security import TokenEncryption
token_encrypted = config_data.get("_token_encrypted", False)
if token_encrypted and app_config.SECRET_KEY:
token_encryption = TokenEncryption(app_config.SECRET_KEY)
for key in ("token", "refresh_token", "client_secret"):
if config_data.get(key):
config_data[key] = token_encryption.decrypt_token(config_data[key])
exp = config_data.get("expiry", "")
if exp:
exp = exp.replace("Z", "")
creds = Credentials(
token=config_data.get("token"),
refresh_token=config_data.get("refresh_token"),
token_uri=config_data.get("token_uri"),
client_id=config_data.get("client_id"),
client_secret=config_data.get("client_secret"),
scopes=config_data.get("scopes", []),
expiry=datetime.fromisoformat(exp) if exp else None,
)
service = await asyncio.get_event_loop().run_in_executor(
None, lambda: build("calendar", "v3", credentials=creds)
)
tz = context.get("timezone", "UTC")
event_body: dict[str, Any] = {
"summary": final_summary,
"start": {"dateTime": final_start_datetime, "timeZone": tz},
"end": {"dateTime": final_end_datetime, "timeZone": tz},
}
if final_description:
event_body["description"] = final_description
if final_location:
event_body["location"] = final_location
if final_attendees:
event_body["attendees"] = [
{"email": e.strip()} for e in final_attendees if e.strip()
]
created = await asyncio.get_event_loop().run_in_executor(
None,
lambda: service.events()
.insert(calendarId="primary", body=event_body)
.execute(),
)
logger.info(
f"Calendar event created: id={created.get('id')}, summary={created.get('summary')}"
)
kb_message_suffix = ""
try:
from app.services.google_calendar import GoogleCalendarKBSyncService
kb_service = GoogleCalendarKBSyncService(db_session)
kb_result = await kb_service.sync_after_create(
event_id=created.get("id"),
event_summary=final_summary,
calendar_id="primary",
start_time=final_start_datetime,
end_time=final_end_datetime,
location=final_location,
html_link=created.get("htmlLink"),
description=final_description,
connector_id=actual_connector_id,
search_space_id=search_space_id,
user_id=user_id,
)
if kb_result["status"] == "success":
kb_message_suffix = " Your knowledge base has also been updated."
else:
kb_message_suffix = " This event will be added to your knowledge base in the next scheduled sync."
except Exception as kb_err:
logger.warning(f"KB sync after create failed: {kb_err}")
kb_message_suffix = " This event will be added to your knowledge base in the next scheduled sync."
return {
"status": "success",
"event_id": created.get("id"),
"html_link": created.get("htmlLink"),
"message": f"Successfully created '{final_summary}' on Google Calendar.{kb_message_suffix}",
}
except Exception as e:
from langgraph.errors import GraphInterrupt
if isinstance(e, GraphInterrupt):
raise
logger.error(f"Error creating calendar event: {e}", exc_info=True)
return {
"status": "error",
"message": "Something went wrong while creating the event. Please try again.",
}
return create_calendar_event

View file

@ -0,0 +1,295 @@
import asyncio
import logging
from datetime import datetime
from typing import Any
from google.oauth2.credentials import Credentials
from googleapiclient.discovery import build
from langchain_core.tools import tool
from langgraph.types import interrupt
from sqlalchemy.ext.asyncio import AsyncSession
from app.services.google_calendar import GoogleCalendarToolMetadataService
logger = logging.getLogger(__name__)
def create_delete_calendar_event_tool(
db_session: AsyncSession | None = None,
search_space_id: int | None = None,
user_id: str | None = None,
):
@tool
async def delete_calendar_event(
event_title_or_id: str,
delete_from_kb: bool = False,
) -> dict[str, Any]:
"""Delete a Google Calendar event.
Use when the user asks to delete, remove, or cancel a calendar event.
Args:
event_title_or_id: The exact title or event ID of the event to delete.
delete_from_kb: Whether to also remove the event from the knowledge base.
Default is False.
Set to True to remove from both Google Calendar and knowledge base.
Returns:
Dictionary with:
- status: "success", "rejected", "not_found", "auth_error", or "error"
- event_id: Google Calendar event ID (if success)
- deleted_from_kb: whether the document was removed from the knowledge base
- message: Result message
IMPORTANT:
- If status is "rejected", the user explicitly declined. Respond with a brief
acknowledgment and do NOT retry or suggest alternatives.
- If status is "not_found", relay the exact message to the user and ask them
to verify the event name or check if it has been indexed.
Examples:
- "Delete the team standup event"
- "Cancel my dentist appointment on Friday"
"""
logger.info(
f"delete_calendar_event called: event_ref='{event_title_or_id}', delete_from_kb={delete_from_kb}"
)
if db_session is None or search_space_id is None or user_id is None:
return {
"status": "error",
"message": "Google Calendar tool not properly configured. Please contact support.",
}
try:
metadata_service = GoogleCalendarToolMetadataService(db_session)
context = await metadata_service.get_deletion_context(
search_space_id, user_id, event_title_or_id
)
if "error" in context:
error_msg = context["error"]
if "not found" in error_msg.lower():
logger.warning(f"Event not found: {error_msg}")
return {"status": "not_found", "message": error_msg}
logger.error(f"Failed to fetch deletion context: {error_msg}")
return {"status": "error", "message": error_msg}
account = context.get("account", {})
if account.get("auth_expired"):
logger.warning(
"Google Calendar account %s has expired authentication",
account.get("id"),
)
return {
"status": "auth_error",
"message": "The Google Calendar account for this event needs re-authentication. Please re-authenticate in your connector settings.",
"connector_type": "google_calendar",
}
event = context["event"]
event_id = event["event_id"]
document_id = event.get("document_id")
connector_id_from_context = context["account"]["id"]
if not event_id:
return {
"status": "error",
"message": "Event ID is missing from the indexed document. Please re-index the event and try again.",
}
logger.info(
f"Requesting approval for deleting calendar event: '{event_title_or_id}' (event_id={event_id}, delete_from_kb={delete_from_kb})"
)
approval = interrupt(
{
"type": "google_calendar_event_deletion",
"action": {
"tool": "delete_calendar_event",
"params": {
"event_id": event_id,
"connector_id": connector_id_from_context,
"delete_from_kb": delete_from_kb,
},
},
"context": context,
}
)
decisions_raw = (
approval.get("decisions", []) if isinstance(approval, dict) else []
)
decisions = (
decisions_raw if isinstance(decisions_raw, list) else [decisions_raw]
)
decisions = [d for d in decisions if isinstance(d, dict)]
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":
return {
"status": "rejected",
"message": "User declined. The event was not deleted. Do not ask again or suggest alternatives.",
}
edited_action = decision.get("edited_action")
final_params: dict[str, Any] = {}
if isinstance(edited_action, dict):
edited_args = edited_action.get("args")
if isinstance(edited_args, dict):
final_params = edited_args
elif isinstance(decision.get("args"), dict):
final_params = decision["args"]
final_event_id = final_params.get("event_id", event_id)
final_connector_id = final_params.get(
"connector_id", connector_id_from_context
)
final_delete_from_kb = final_params.get("delete_from_kb", delete_from_kb)
if not final_connector_id:
return {
"status": "error",
"message": "No connector found for this event.",
}
from sqlalchemy.future import select
from app.db import SearchSourceConnector, SearchSourceConnectorType
_CALENDAR_TYPES = [
SearchSourceConnectorType.GOOGLE_CALENDAR_CONNECTOR,
SearchSourceConnectorType.COMPOSIO_GOOGLE_CALENDAR_CONNECTOR,
]
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.in_(_CALENDAR_TYPES),
)
)
connector = result.scalars().first()
if not connector:
return {
"status": "error",
"message": "Selected Google Calendar connector is invalid or has been disconnected.",
}
actual_connector_id = connector.id
logger.info(
f"Deleting calendar event: event_id='{final_event_id}', connector={actual_connector_id}"
)
if connector.connector_type == SearchSourceConnectorType.COMPOSIO_GOOGLE_CALENDAR_CONNECTOR:
from app.utils.google_credentials import build_composio_credentials
cca_id = connector.config.get("composio_connected_account_id")
if cca_id:
creds = build_composio_credentials(cca_id)
else:
return {
"status": "error",
"message": "Composio connected account ID not found for this connector.",
}
else:
config_data = dict(connector.config)
from app.config import config as app_config
from app.utils.oauth_security import TokenEncryption
token_encrypted = config_data.get("_token_encrypted", False)
if token_encrypted and app_config.SECRET_KEY:
token_encryption = TokenEncryption(app_config.SECRET_KEY)
for key in ("token", "refresh_token", "client_secret"):
if config_data.get(key):
config_data[key] = token_encryption.decrypt_token(config_data[key])
exp = config_data.get("expiry", "")
if exp:
exp = exp.replace("Z", "")
creds = Credentials(
token=config_data.get("token"),
refresh_token=config_data.get("refresh_token"),
token_uri=config_data.get("token_uri"),
client_id=config_data.get("client_id"),
client_secret=config_data.get("client_secret"),
scopes=config_data.get("scopes", []),
expiry=datetime.fromisoformat(exp) if exp else None,
)
service = await asyncio.get_event_loop().run_in_executor(
None, lambda: build("calendar", "v3", credentials=creds)
)
await asyncio.get_event_loop().run_in_executor(
None,
lambda: service.events()
.delete(calendarId="primary", eventId=final_event_id)
.execute(),
)
logger.info(
f"Calendar event deleted: event_id={final_event_id}"
)
delete_result: dict[str, Any] = {
"status": "success",
"event_id": final_event_id,
"message": f"Successfully deleted the calendar event '{event.get('summary', event_title_or_id)}'.",
}
deleted_from_kb = False
if final_delete_from_kb and document_id:
try:
from app.db import 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_kb = True
logger.info(
f"Deleted document {document_id} from knowledge base"
)
else:
logger.warning(f"Document {document_id} not found in KB")
except Exception as e:
logger.error(f"Failed to delete document from KB: {e}")
await db_session.rollback()
delete_result["warning"] = (
f"Event deleted, but failed to remove from knowledge base: {e!s}"
)
delete_result["deleted_from_kb"] = deleted_from_kb
if deleted_from_kb:
delete_result["message"] = (
f"{delete_result.get('message', '')} (also removed from knowledge base)"
)
return delete_result
except Exception as e:
from langgraph.errors import GraphInterrupt
if isinstance(e, GraphInterrupt):
raise
logger.error(f"Error deleting calendar event: {e}", exc_info=True)
return {
"status": "error",
"message": "Something went wrong while deleting the event. Please try again.",
}
return delete_calendar_event

View file

@ -0,0 +1,325 @@
import asyncio
import logging
from datetime import datetime
from typing import Any
from google.oauth2.credentials import Credentials
from googleapiclient.discovery import build
from langchain_core.tools import tool
from langgraph.types import interrupt
from sqlalchemy.ext.asyncio import AsyncSession
from app.services.google_calendar import GoogleCalendarToolMetadataService
logger = logging.getLogger(__name__)
def create_update_calendar_event_tool(
db_session: AsyncSession | None = None,
search_space_id: int | None = None,
user_id: str | None = None,
):
@tool
async def update_calendar_event(
event_title_or_id: str,
new_summary: str | None = None,
new_start_datetime: str | None = None,
new_end_datetime: str | None = None,
new_description: str | None = None,
new_location: str | None = None,
new_attendees: list[str] | None = None,
) -> dict[str, Any]:
"""Update an existing Google Calendar event.
Use when the user asks to modify, reschedule, or change a calendar event.
Args:
event_title_or_id: The exact title or event ID of the event to update.
new_summary: New event title (if changing).
new_start_datetime: New start time in ISO 8601 format (if rescheduling).
new_end_datetime: New end time in ISO 8601 format (if rescheduling).
new_description: New event description (if changing).
new_location: New event location (if changing).
new_attendees: New list of attendee email addresses (if changing).
Returns:
Dictionary with:
- status: "success", "rejected", "not_found", "auth_error", or "error"
- event_id: Google Calendar event ID (if success)
- html_link: URL to open the event (if success)
- message: Result message
IMPORTANT:
- If status is "rejected", the user explicitly declined. Respond with a brief
acknowledgment and do NOT retry or suggest alternatives.
- If status is "not_found", relay the exact message to the user and ask them
to verify the event name or check if it has been indexed.
Examples:
- "Reschedule the team standup to 3pm"
- "Change the location of my dentist appointment"
"""
logger.info(
f"update_calendar_event called: event_ref='{event_title_or_id}'"
)
if db_session is None or search_space_id is None or user_id is None:
return {
"status": "error",
"message": "Google Calendar tool not properly configured. Please contact support.",
}
try:
metadata_service = GoogleCalendarToolMetadataService(db_session)
context = await metadata_service.get_update_context(
search_space_id, user_id, event_title_or_id
)
if "error" in context:
error_msg = context["error"]
if "not found" in error_msg.lower():
logger.warning(f"Event not found: {error_msg}")
return {"status": "not_found", "message": error_msg}
logger.error(f"Failed to fetch update context: {error_msg}")
return {"status": "error", "message": error_msg}
if context.get("auth_expired"):
logger.warning(
"Google Calendar account has expired authentication"
)
return {
"status": "auth_error",
"message": "The Google Calendar account for this event needs re-authentication. Please re-authenticate in your connector settings.",
"connector_type": "google_calendar",
}
event = context["event"]
event_id = event["event_id"]
document_id = event.get("document_id")
connector_id_from_context = context["account"]["id"]
if not event_id:
return {
"status": "error",
"message": "Event ID is missing from the indexed document. Please re-index the event and try again.",
}
logger.info(
f"Requesting approval for updating calendar event: '{event_title_or_id}' (event_id={event_id})"
)
approval = interrupt(
{
"type": "google_calendar_event_update",
"action": {
"tool": "update_calendar_event",
"params": {
"event_id": event_id,
"document_id": document_id,
"connector_id": connector_id_from_context,
"new_summary": new_summary,
"new_start_datetime": new_start_datetime,
"new_end_datetime": new_end_datetime,
"new_description": new_description,
"new_location": new_location,
"new_attendees": new_attendees,
},
},
"context": context,
}
)
decisions_raw = (
approval.get("decisions", []) if isinstance(approval, dict) else []
)
decisions = (
decisions_raw if isinstance(decisions_raw, list) else [decisions_raw]
)
decisions = [d for d in decisions if isinstance(d, dict)]
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":
return {
"status": "rejected",
"message": "User declined. The event was not updated. Do not ask again or suggest alternatives.",
}
edited_action = decision.get("edited_action")
final_params: dict[str, Any] = {}
if isinstance(edited_action, dict):
edited_args = edited_action.get("args")
if isinstance(edited_args, dict):
final_params = edited_args
elif isinstance(decision.get("args"), dict):
final_params = decision["args"]
final_event_id = final_params.get("event_id", event_id)
final_connector_id = final_params.get(
"connector_id", connector_id_from_context
)
final_new_summary = final_params.get("new_summary", new_summary)
final_new_start_datetime = final_params.get("new_start_datetime", new_start_datetime)
final_new_end_datetime = final_params.get("new_end_datetime", new_end_datetime)
final_new_description = final_params.get("new_description", new_description)
final_new_location = final_params.get("new_location", new_location)
final_new_attendees = final_params.get("new_attendees", new_attendees)
if not final_connector_id:
return {
"status": "error",
"message": "No connector found for this event.",
}
from sqlalchemy.future import select
from app.db import SearchSourceConnector, SearchSourceConnectorType
_CALENDAR_TYPES = [
SearchSourceConnectorType.GOOGLE_CALENDAR_CONNECTOR,
SearchSourceConnectorType.COMPOSIO_GOOGLE_CALENDAR_CONNECTOR,
]
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.in_(_CALENDAR_TYPES),
)
)
connector = result.scalars().first()
if not connector:
return {
"status": "error",
"message": "Selected Google Calendar connector is invalid or has been disconnected.",
}
actual_connector_id = connector.id
logger.info(
f"Updating calendar event: event_id='{final_event_id}', connector={actual_connector_id}"
)
if connector.connector_type == SearchSourceConnectorType.COMPOSIO_GOOGLE_CALENDAR_CONNECTOR:
from app.utils.google_credentials import build_composio_credentials
cca_id = connector.config.get("composio_connected_account_id")
if cca_id:
creds = build_composio_credentials(cca_id)
else:
return {
"status": "error",
"message": "Composio connected account ID not found for this connector.",
}
else:
config_data = dict(connector.config)
from app.config import config as app_config
from app.utils.oauth_security import TokenEncryption
token_encrypted = config_data.get("_token_encrypted", False)
if token_encrypted and app_config.SECRET_KEY:
token_encryption = TokenEncryption(app_config.SECRET_KEY)
for key in ("token", "refresh_token", "client_secret"):
if config_data.get(key):
config_data[key] = token_encryption.decrypt_token(config_data[key])
exp = config_data.get("expiry", "")
if exp:
exp = exp.replace("Z", "")
creds = Credentials(
token=config_data.get("token"),
refresh_token=config_data.get("refresh_token"),
token_uri=config_data.get("token_uri"),
client_id=config_data.get("client_id"),
client_secret=config_data.get("client_secret"),
scopes=config_data.get("scopes", []),
expiry=datetime.fromisoformat(exp) if exp else None,
)
service = await asyncio.get_event_loop().run_in_executor(
None, lambda: build("calendar", "v3", credentials=creds)
)
update_body: dict[str, Any] = {}
if final_new_summary is not None:
update_body["summary"] = final_new_summary
if final_new_start_datetime is not None:
tz = context.get("timezone", "UTC") if isinstance(context, dict) else "UTC"
update_body["start"] = {"dateTime": final_new_start_datetime, "timeZone": tz}
if final_new_end_datetime is not None:
tz = context.get("timezone", "UTC") if isinstance(context, dict) else "UTC"
update_body["end"] = {"dateTime": final_new_end_datetime, "timeZone": tz}
if final_new_description is not None:
update_body["description"] = final_new_description
if final_new_location is not None:
update_body["location"] = final_new_location
if final_new_attendees is not None:
update_body["attendees"] = [
{"email": e.strip()} for e in final_new_attendees if e.strip()
]
if not update_body:
return {
"status": "error",
"message": "No changes specified. Please provide at least one field to update.",
}
updated = await asyncio.get_event_loop().run_in_executor(
None,
lambda: service.events()
.patch(calendarId="primary", eventId=final_event_id, body=update_body)
.execute(),
)
logger.info(
f"Calendar event updated: event_id={final_event_id}"
)
kb_message_suffix = ""
if document_id is not None:
try:
from app.services.google_calendar import GoogleCalendarKBSyncService
kb_service = GoogleCalendarKBSyncService(db_session)
kb_result = await kb_service.sync_after_update(
document_id=document_id,
event_id=final_event_id,
connector_id=actual_connector_id,
search_space_id=search_space_id,
user_id=user_id,
)
if kb_result["status"] == "success":
kb_message_suffix = " Your knowledge base has also been updated."
else:
kb_message_suffix = " The knowledge base will be updated in the next scheduled sync."
except Exception as kb_err:
logger.warning(f"KB sync after update failed: {kb_err}")
kb_message_suffix = " The knowledge base will be updated in the next scheduled sync."
return {
"status": "success",
"event_id": final_event_id,
"html_link": updated.get("htmlLink"),
"message": f"Successfully updated the calendar event.{kb_message_suffix}",
}
except Exception as e:
from langgraph.errors import GraphInterrupt
if isinstance(e, GraphInterrupt):
raise
logger.error(f"Error updating calendar event: {e}", exc_info=True)
return {
"status": "error",
"message": "Something went wrong while updating the event. Please try again.",
}
return update_calendar_event

View file

@ -47,6 +47,16 @@ from app.db import ChatVisibility
from .display_image import create_display_image_tool
from .generate_image import create_generate_image_tool
from .gmail import (
create_create_gmail_draft_tool,
create_send_gmail_email_tool,
create_trash_gmail_email_tool,
)
from .google_calendar import (
create_create_calendar_event_tool,
create_delete_calendar_event_tool,
create_update_calendar_event_tool,
)
from .google_drive import (
create_create_google_drive_file_tool,
create_delete_google_drive_file_tool,
@ -336,6 +346,74 @@ BUILTIN_TOOLS: list[ToolDefinition] = [
),
requires=["db_session", "search_space_id", "user_id"],
),
# =========================================================================
# GOOGLE CALENDAR TOOLS - create, update, delete events
# Auto-disabled when no Google Calendar connector is configured
# =========================================================================
ToolDefinition(
name="create_calendar_event",
description="Create a new event on Google Calendar",
factory=lambda deps: create_create_calendar_event_tool(
db_session=deps["db_session"],
search_space_id=deps["search_space_id"],
user_id=deps["user_id"],
),
requires=["db_session", "search_space_id", "user_id"],
),
ToolDefinition(
name="update_calendar_event",
description="Update an existing indexed Google Calendar event",
factory=lambda deps: create_update_calendar_event_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_calendar_event",
description="Delete an existing indexed Google Calendar event",
factory=lambda deps: create_delete_calendar_event_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"],
),
# =========================================================================
# GMAIL TOOLS - create drafts, send emails, trash emails
# Auto-disabled when no Gmail connector is configured
# =========================================================================
ToolDefinition(
name="create_gmail_draft",
description="Create a draft email in Gmail",
factory=lambda deps: create_create_gmail_draft_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="send_gmail_email",
description="Send an email via Gmail",
factory=lambda deps: create_send_gmail_email_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="trash_gmail_email",
description="Move an indexed email to trash in Gmail",
factory=lambda deps: create_trash_gmail_email_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"],
),
]

View file

@ -0,0 +1,13 @@
from app.services.gmail.kb_sync_service import GmailKBSyncService
from app.services.gmail.tool_metadata_service import (
GmailAccount,
GmailMessage,
GmailToolMetadataService,
)
__all__ = [
"GmailAccount",
"GmailKBSyncService",
"GmailMessage",
"GmailToolMetadataService",
]

View file

@ -0,0 +1,163 @@
import logging
from datetime import datetime
from sqlalchemy.ext.asyncio import AsyncSession
from app.db import Document, DocumentType
from app.services.llm_service import get_user_long_context_llm
from app.utils.document_converters import (
create_document_chunks,
embed_text,
generate_content_hash,
generate_document_summary,
generate_unique_identifier_hash,
)
logger = logging.getLogger(__name__)
class GmailKBSyncService:
def __init__(self, db_session: AsyncSession):
self.db_session = db_session
async def sync_after_create(
self,
message_id: str,
thread_id: str,
subject: str,
sender: str,
date_str: str,
body_text: str | None,
connector_id: int,
search_space_id: int,
user_id: str,
) -> dict:
from app.tasks.connector_indexers.base import (
check_document_by_unique_identifier,
check_duplicate_document_by_hash,
get_current_timestamp,
safe_set_chunks,
)
try:
unique_hash = generate_unique_identifier_hash(
DocumentType.GOOGLE_GMAIL_CONNECTOR, message_id, search_space_id
)
existing = await check_document_by_unique_identifier(
self.db_session, unique_hash
)
if existing:
logger.info(
"Document for Gmail message %s already exists (doc_id=%s), skipping",
message_id,
existing.id,
)
return {"status": "success"}
indexable_content = (
f"Gmail Message: {subject}\n\nFrom: {sender}\nDate: {date_str}\n\n"
f"{body_text or ''}"
).strip()
if not indexable_content:
indexable_content = f"Gmail message: {subject}"
content_hash = generate_content_hash(indexable_content, search_space_id)
with self.db_session.no_autoflush:
dup = await check_duplicate_document_by_hash(
self.db_session, content_hash
)
if dup:
logger.info(
"Content-hash collision for Gmail message %s -- identical content "
"exists in doc %s. Using unique_identifier_hash as content_hash.",
message_id,
dup.id,
)
content_hash = unique_hash
user_llm = await get_user_long_context_llm(
self.db_session,
user_id,
search_space_id,
disable_streaming=True,
)
doc_metadata_for_summary = {
"subject": subject,
"sender": sender,
"document_type": "Gmail Message",
"connector_type": "Gmail",
}
if user_llm:
summary_content, summary_embedding = await generate_document_summary(
indexable_content, user_llm, doc_metadata_for_summary
)
else:
logger.warning("No LLM configured -- using fallback summary")
summary_content = f"Gmail Message: {subject}\n\n{indexable_content}"
summary_embedding = embed_text(summary_content)
chunks = await create_document_chunks(indexable_content)
now_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
document = Document(
title=subject,
document_type=DocumentType.GOOGLE_GMAIL_CONNECTOR,
document_metadata={
"message_id": message_id,
"thread_id": thread_id,
"subject": subject,
"sender": sender,
"date": date_str,
"connector_id": connector_id,
"indexed_at": now_str,
},
content=summary_content,
content_hash=content_hash,
unique_identifier_hash=unique_hash,
embedding=summary_embedding,
search_space_id=search_space_id,
connector_id=connector_id,
source_markdown=body_text,
updated_at=get_current_timestamp(),
)
self.db_session.add(document)
await self.db_session.flush()
await safe_set_chunks(self.db_session, document, chunks)
await self.db_session.commit()
logger.info(
"KB sync after create succeeded: doc_id=%s, subject=%s, chunks=%d",
document.id,
subject,
len(chunks),
)
return {"status": "success"}
except Exception as e:
error_str = str(e).lower()
if (
"duplicate key value violates unique constraint" in error_str
or "uniqueviolationerror" in error_str
):
logger.warning(
"Duplicate constraint hit during KB sync for message %s. "
"Rolling back -- periodic indexer will handle it. Error: %s",
message_id,
e,
)
await self.db_session.rollback()
return {"status": "error", "message": "Duplicate document detected"}
logger.error(
"KB sync after create failed for message %s: %s",
message_id,
e,
exc_info=True,
)
await self.db_session.rollback()
return {"status": "error", "message": str(e)}

View file

@ -0,0 +1,298 @@
import asyncio
import logging
from dataclasses import dataclass
from datetime import datetime
from google.oauth2.credentials import Credentials
from googleapiclient.discovery import build
from sqlalchemy import and_, func, or_
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select
from sqlalchemy.orm.attributes import flag_modified
from app.db import (
Document,
DocumentType,
SearchSourceConnector,
SearchSourceConnectorType,
)
from app.utils.google_credentials import build_composio_credentials
logger = logging.getLogger(__name__)
@dataclass
class GmailAccount:
id: int
name: str
email: str
@classmethod
def from_connector(cls, connector: SearchSourceConnector) -> "GmailAccount":
return cls(id=connector.id, name=connector.name, email="")
def to_dict(self) -> dict:
return {"id": self.id, "name": self.name, "email": self.email}
@dataclass
class GmailMessage:
message_id: str
thread_id: str
subject: str
sender: str
date: str
connector_id: int
document_id: int
@classmethod
def from_document(cls, document: Document) -> "GmailMessage":
meta = document.document_metadata or {}
return cls(
message_id=meta.get("message_id", ""),
thread_id=meta.get("thread_id", ""),
subject=meta.get("subject", document.title),
sender=meta.get("sender", ""),
date=meta.get("date", ""),
connector_id=document.connector_id,
document_id=document.id,
)
def to_dict(self) -> dict:
return {
"message_id": self.message_id,
"thread_id": self.thread_id,
"subject": self.subject,
"sender": self.sender,
"date": self.date,
"connector_id": self.connector_id,
"document_id": self.document_id,
}
class GmailToolMetadataService:
def __init__(self, db_session: AsyncSession):
self._db_session = db_session
async def _build_credentials(self, connector: SearchSourceConnector) -> Credentials:
if (
connector.connector_type
== SearchSourceConnectorType.COMPOSIO_GMAIL_CONNECTOR
):
cca_id = connector.config.get("composio_connected_account_id")
if cca_id:
return build_composio_credentials(cca_id)
config_data = dict(connector.config)
from app.config import config
from app.utils.oauth_security import TokenEncryption
token_encrypted = config_data.get("_token_encrypted", False)
if token_encrypted and config.SECRET_KEY:
token_encryption = TokenEncryption(config.SECRET_KEY)
if config_data.get("token"):
config_data["token"] = token_encryption.decrypt_token(
config_data["token"]
)
if config_data.get("refresh_token"):
config_data["refresh_token"] = token_encryption.decrypt_token(
config_data["refresh_token"]
)
if config_data.get("client_secret"):
config_data["client_secret"] = token_encryption.decrypt_token(
config_data["client_secret"]
)
exp = config_data.get("expiry", "")
if exp:
exp = exp.replace("Z", "")
return Credentials(
token=config_data.get("token"),
refresh_token=config_data.get("refresh_token"),
token_uri=config_data.get("token_uri"),
client_id=config_data.get("client_id"),
client_secret=config_data.get("client_secret"),
scopes=config_data.get("scopes", []),
expiry=datetime.fromisoformat(exp) if exp else None,
)
async def _check_account_health(self, connector_id: int) -> bool:
"""Check if a Gmail connector's credentials are still valid.
Uses a lightweight ``users().getProfile(userId='me')`` call.
Returns True if the credentials are expired/invalid, False if healthy.
"""
try:
result = await self._db_session.execute(
select(SearchSourceConnector).where(
SearchSourceConnector.id == connector_id
)
)
connector = result.scalar_one_or_none()
if not connector:
return True
creds = await self._build_credentials(connector)
service = build("gmail", "v1", credentials=creds)
await asyncio.get_event_loop().run_in_executor(
None, lambda: service.users().getProfile(userId="me").execute()
)
return False
except Exception as e:
logger.warning(
"Gmail connector %s health check failed: %s",
connector_id,
e,
)
return True
async def _persist_auth_expired(self, connector_id: int) -> None:
"""Persist ``auth_expired: True`` to the connector config if not already set."""
try:
result = await self._db_session.execute(
select(SearchSourceConnector).where(
SearchSourceConnector.id == connector_id
)
)
db_connector = result.scalar_one_or_none()
if db_connector and not db_connector.config.get("auth_expired"):
db_connector.config = {**db_connector.config, "auth_expired": True}
flag_modified(db_connector, "config")
await self._db_session.commit()
await self._db_session.refresh(db_connector)
except Exception:
logger.warning(
"Failed to persist auth_expired for connector %s",
connector_id,
exc_info=True,
)
async def _get_accounts(
self, search_space_id: int, user_id: str
) -> list[GmailAccount]:
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.in_([
SearchSourceConnectorType.GOOGLE_GMAIL_CONNECTOR,
SearchSourceConnectorType.COMPOSIO_GMAIL_CONNECTOR,
]),
)
)
.order_by(SearchSourceConnector.last_indexed_at.desc())
)
connectors = result.scalars().all()
return [GmailAccount.from_connector(c) for c in connectors]
async def get_creation_context(self, search_space_id: int, user_id: str) -> dict:
accounts = await self._get_accounts(search_space_id, user_id)
if not accounts:
return {
"accounts": [],
"error": "No Gmail account connected",
}
accounts_with_status = []
for acc in accounts:
acc_dict = acc.to_dict()
auth_expired = await self._check_account_health(acc.id)
acc_dict["auth_expired"] = auth_expired
if auth_expired:
await self._persist_auth_expired(acc.id)
else:
try:
result = await self._db_session.execute(
select(SearchSourceConnector).where(
SearchSourceConnector.id == acc.id
)
)
connector = result.scalar_one_or_none()
if connector:
creds = await self._build_credentials(connector)
service = build("gmail", "v1", credentials=creds)
profile = await asyncio.get_event_loop().run_in_executor(
None,
lambda: service.users()
.getProfile(userId="me")
.execute(),
)
acc_dict["email"] = profile.get("emailAddress", "")
except Exception:
logger.warning(
"Failed to fetch email for Gmail connector %s",
acc.id,
exc_info=True,
)
accounts_with_status.append(acc_dict)
return {"accounts": accounts_with_status}
async def get_trash_context(
self, search_space_id: int, user_id: str, email_ref: str
) -> dict:
document, connector = await self._resolve_email(
search_space_id, user_id, email_ref
)
if not document or not connector:
return {
"error": (
f"Email '{email_ref}' not found in your indexed Gmail messages. "
"This could mean: (1) the email doesn't exist, "
"(2) it hasn't been indexed yet, "
"or (3) the subject is different."
)
}
account = GmailAccount.from_connector(connector)
message = GmailMessage.from_document(document)
acc_dict = account.to_dict()
auth_expired = await self._check_account_health(connector.id)
acc_dict["auth_expired"] = auth_expired
if auth_expired:
await self._persist_auth_expired(connector.id)
return {
"account": acc_dict,
"email": message.to_dict(),
}
async def _resolve_email(
self, search_space_id: int, user_id: str, email_ref: str
) -> tuple[Document | None, SearchSourceConnector | None]:
result = await self._db_session.execute(
select(Document, SearchSourceConnector)
.join(
SearchSourceConnector,
Document.connector_id == SearchSourceConnector.id,
)
.filter(
and_(
Document.search_space_id == search_space_id,
Document.document_type.in_([
DocumentType.GOOGLE_GMAIL_CONNECTOR,
DocumentType.COMPOSIO_GMAIL_CONNECTOR,
]),
SearchSourceConnector.user_id == user_id,
or_(
func.lower(
Document.document_metadata["subject"].astext
)
== func.lower(email_ref),
func.lower(Document.title) == func.lower(email_ref),
),
)
)
)
row = result.first()
if row:
return row[0], row[1]
return None, None

View file

@ -0,0 +1,13 @@
from app.services.google_calendar.kb_sync_service import GoogleCalendarKBSyncService
from app.services.google_calendar.tool_metadata_service import (
GoogleCalendarAccount,
GoogleCalendarEvent,
GoogleCalendarToolMetadataService,
)
__all__ = [
"GoogleCalendarAccount",
"GoogleCalendarEvent",
"GoogleCalendarKBSyncService",
"GoogleCalendarToolMetadataService",
]

View file

@ -0,0 +1,348 @@
import asyncio
import logging
from datetime import datetime
from google.oauth2.credentials import Credentials
from googleapiclient.discovery import build
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select
from sqlalchemy.orm.attributes import flag_modified
from app.db import Document, DocumentType, SearchSourceConnector, SearchSourceConnectorType
from app.services.llm_service import get_user_long_context_llm
from app.utils.document_converters import (
create_document_chunks,
embed_text,
generate_content_hash,
generate_document_summary,
generate_unique_identifier_hash,
)
from app.utils.google_credentials import build_composio_credentials
logger = logging.getLogger(__name__)
class GoogleCalendarKBSyncService:
def __init__(self, db_session: AsyncSession):
self.db_session = db_session
async def sync_after_create(
self,
event_id: str,
event_summary: str,
calendar_id: str,
start_time: str,
end_time: str,
location: str | None,
html_link: str | None,
description: str | None,
connector_id: int,
search_space_id: int,
user_id: str,
) -> dict:
from app.tasks.connector_indexers.base import (
check_document_by_unique_identifier,
check_duplicate_document_by_hash,
get_current_timestamp,
safe_set_chunks,
)
try:
unique_hash = generate_unique_identifier_hash(
DocumentType.GOOGLE_CALENDAR_CONNECTOR, event_id, search_space_id
)
existing = await check_document_by_unique_identifier(
self.db_session, unique_hash
)
if existing:
logger.info(
"Document for Calendar event %s already exists (doc_id=%s), skipping",
event_id,
existing.id,
)
return {"status": "success"}
indexable_content = (
f"Google Calendar Event: {event_summary}\n\n"
f"Start: {start_time}\n"
f"End: {end_time}\n"
f"Location: {location or 'N/A'}\n\n"
f"{description or ''}"
).strip()
content_hash = generate_content_hash(indexable_content, search_space_id)
with self.db_session.no_autoflush:
dup = await check_duplicate_document_by_hash(
self.db_session, content_hash
)
if dup:
logger.info(
"Content-hash collision for Calendar event %s -- identical content "
"exists in doc %s. Using unique_identifier_hash as content_hash.",
event_id,
dup.id,
)
content_hash = unique_hash
user_llm = await get_user_long_context_llm(
self.db_session,
user_id,
search_space_id,
disable_streaming=True,
)
doc_metadata_for_summary = {
"event_summary": event_summary,
"start_time": start_time,
"end_time": end_time,
"document_type": "Google Calendar Event",
"connector_type": "Google Calendar",
}
if user_llm:
summary_content, summary_embedding = await generate_document_summary(
indexable_content, user_llm, doc_metadata_for_summary
)
else:
logger.warning("No LLM configured -- using fallback summary")
summary_content = f"Google Calendar Event: {event_summary}\n\n{indexable_content}"
summary_embedding = embed_text(summary_content)
chunks = await create_document_chunks(indexable_content)
now_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
document = Document(
title=event_summary,
document_type=DocumentType.GOOGLE_CALENDAR_CONNECTOR,
document_metadata={
"event_id": event_id,
"event_summary": event_summary,
"calendar_id": calendar_id,
"start_time": start_time,
"end_time": end_time,
"location": location,
"html_link": html_link,
"source_connector": "google_calendar",
"indexed_at": now_str,
"connector_id": connector_id,
},
content=summary_content,
content_hash=content_hash,
unique_identifier_hash=unique_hash,
embedding=summary_embedding,
search_space_id=search_space_id,
connector_id=connector_id,
source_markdown=indexable_content,
updated_at=get_current_timestamp(),
)
self.db_session.add(document)
await self.db_session.flush()
await safe_set_chunks(self.db_session, document, chunks)
await self.db_session.commit()
logger.info(
"KB sync after create succeeded: doc_id=%s, event=%s, chunks=%d",
document.id,
event_summary,
len(chunks),
)
return {"status": "success"}
except Exception as e:
error_str = str(e).lower()
if (
"duplicate key value violates unique constraint" in error_str
or "uniqueviolationerror" in error_str
):
logger.warning(
"Duplicate constraint hit during KB sync for event %s. "
"Rolling back -- periodic indexer will handle it. Error: %s",
event_id,
e,
)
await self.db_session.rollback()
return {"status": "error", "message": "Duplicate document detected"}
logger.error(
"KB sync after create failed for event %s: %s",
event_id,
e,
exc_info=True,
)
await self.db_session.rollback()
return {"status": "error", "message": str(e)}
async def sync_after_update(
self,
document_id: int,
event_id: str,
connector_id: int,
search_space_id: int,
user_id: str,
) -> dict:
from app.tasks.connector_indexers.base import (
get_current_timestamp,
safe_set_chunks,
)
try:
document = await self.db_session.get(Document, document_id)
if not document:
logger.warning("Document %s not found in KB", document_id)
return {"status": "not_indexed"}
creds = await self._build_credentials_for_connector(connector_id)
loop = asyncio.get_event_loop()
service = await loop.run_in_executor(
None, lambda: build("calendar", "v3", credentials=creds)
)
calendar_id = (document.document_metadata or {}).get("calendar_id", "primary")
live_event = await loop.run_in_executor(
None,
lambda: service.events()
.get(calendarId=calendar_id, eventId=event_id)
.execute(),
)
event_summary = live_event.get("summary", "")
description = live_event.get("description", "")
location = live_event.get("location", "")
start_data = live_event.get("start", {})
start_time = start_data.get("dateTime", start_data.get("date", ""))
end_data = live_event.get("end", {})
end_time = end_data.get("dateTime", end_data.get("date", ""))
attendees = [
{"email": a.get("email", ""), "responseStatus": a.get("responseStatus", "")}
for a in live_event.get("attendees", [])
]
indexable_content = (
f"Google Calendar Event: {event_summary}\n\n"
f"Start: {start_time}\n"
f"End: {end_time}\n"
f"Location: {location or 'N/A'}\n\n"
f"{description or ''}"
).strip()
if not indexable_content:
return {"status": "error", "message": "Event produced empty content"}
user_llm = await get_user_long_context_llm(
self.db_session, user_id, search_space_id, disable_streaming=True
)
doc_metadata_for_summary = {
"event_summary": event_summary,
"start_time": start_time,
"end_time": end_time,
"document_type": "Google Calendar Event",
"connector_type": "Google Calendar",
}
if user_llm:
summary_content, summary_embedding = await generate_document_summary(
indexable_content, user_llm, doc_metadata_for_summary
)
else:
summary_content = f"Google Calendar Event: {event_summary}\n\n{indexable_content}"
summary_embedding = embed_text(summary_content)
chunks = await create_document_chunks(indexable_content)
now_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
document.title = event_summary
document.content = summary_content
document.content_hash = generate_content_hash(
indexable_content, search_space_id
)
document.embedding = summary_embedding
document.document_metadata = {
**(document.document_metadata or {}),
"event_id": event_id,
"event_summary": event_summary,
"calendar_id": calendar_id,
"start_time": start_time,
"end_time": end_time,
"location": location,
"description": description,
"attendees": attendees,
"html_link": live_event.get("htmlLink", ""),
"indexed_at": now_str,
"connector_id": connector_id,
}
flag_modified(document, "document_metadata")
await safe_set_chunks(self.db_session, document, chunks)
document.updated_at = get_current_timestamp()
await self.db_session.commit()
logger.info(
"KB sync after update succeeded for document %s (event: %s)",
document_id,
event_summary,
)
return {"status": "success"}
except Exception as e:
logger.error(
"KB sync after update failed for document %s: %s",
document_id,
e,
exc_info=True,
)
await self.db_session.rollback()
return {"status": "error", "message": str(e)}
async def _build_credentials_for_connector(self, connector_id: int) -> Credentials:
result = await self.db_session.execute(
select(SearchSourceConnector).where(
SearchSourceConnector.id == connector_id
)
)
connector = result.scalar_one_or_none()
if not connector:
raise ValueError(f"Connector {connector_id} not found")
if connector.connector_type == SearchSourceConnectorType.COMPOSIO_GOOGLE_CALENDAR_CONNECTOR:
cca_id = connector.config.get("composio_connected_account_id")
if cca_id:
return build_composio_credentials(cca_id)
raise ValueError("Composio connected_account_id not found")
config_data = dict(connector.config)
from app.config import config as app_config
from app.utils.oauth_security import TokenEncryption
token_encrypted = config_data.get("_token_encrypted", False)
if token_encrypted and app_config.SECRET_KEY:
token_encryption = TokenEncryption(app_config.SECRET_KEY)
if config_data.get("token"):
config_data["token"] = token_encryption.decrypt_token(config_data["token"])
if config_data.get("refresh_token"):
config_data["refresh_token"] = token_encryption.decrypt_token(config_data["refresh_token"])
if config_data.get("client_secret"):
config_data["client_secret"] = token_encryption.decrypt_token(config_data["client_secret"])
exp = config_data.get("expiry", "")
if exp:
exp = exp.replace("Z", "")
return Credentials(
token=config_data.get("token"),
refresh_token=config_data.get("refresh_token"),
token_uri=config_data.get("token_uri"),
client_id=config_data.get("client_id"),
client_secret=config_data.get("client_secret"),
scopes=config_data.get("scopes", []),
expiry=datetime.fromisoformat(exp) if exp else None,
)

View file

@ -0,0 +1,403 @@
import asyncio
import logging
from dataclasses import dataclass
from datetime import datetime
from google.oauth2.credentials import Credentials
from googleapiclient.discovery import build
from sqlalchemy import and_, func, or_
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select
from sqlalchemy.orm.attributes import flag_modified
from app.db import (
Document,
DocumentType,
SearchSourceConnector,
SearchSourceConnectorType,
)
from app.utils.google_credentials import build_composio_credentials
logger = logging.getLogger(__name__)
CALENDAR_CONNECTOR_TYPES = [
SearchSourceConnectorType.GOOGLE_CALENDAR_CONNECTOR,
SearchSourceConnectorType.COMPOSIO_GOOGLE_CALENDAR_CONNECTOR,
]
CALENDAR_DOCUMENT_TYPES = [
DocumentType.GOOGLE_CALENDAR_CONNECTOR,
DocumentType.COMPOSIO_GOOGLE_CALENDAR_CONNECTOR,
]
@dataclass
class GoogleCalendarAccount:
id: int
name: str
@classmethod
def from_connector(cls, connector: SearchSourceConnector) -> "GoogleCalendarAccount":
return cls(id=connector.id, name=connector.name)
def to_dict(self) -> dict:
return {"id": self.id, "name": self.name}
@dataclass
class GoogleCalendarEvent:
event_id: str
summary: str
start: str
end: str
description: str
location: str
attendees: list
calendar_id: str
document_id: int
indexed_at: str | None
@classmethod
def from_document(cls, document: Document) -> "GoogleCalendarEvent":
meta = document.document_metadata or {}
return cls(
event_id=meta.get("event_id", ""),
summary=meta.get("event_summary", document.title),
start=meta.get("start_time", ""),
end=meta.get("end_time", ""),
description=meta.get("description", ""),
location=meta.get("location", ""),
attendees=meta.get("attendees", []),
calendar_id=meta.get("calendar_id", "primary"),
document_id=document.id,
indexed_at=meta.get("indexed_at"),
)
def to_dict(self) -> dict:
return {
"event_id": self.event_id,
"summary": self.summary,
"start": self.start,
"end": self.end,
"description": self.description,
"location": self.location,
"attendees": self.attendees,
"calendar_id": self.calendar_id,
"document_id": self.document_id,
"indexed_at": self.indexed_at,
}
class GoogleCalendarToolMetadataService:
def __init__(self, db_session: AsyncSession):
self._db_session = db_session
async def _build_credentials(self, connector: SearchSourceConnector) -> Credentials:
if connector.connector_type == SearchSourceConnectorType.COMPOSIO_GOOGLE_CALENDAR_CONNECTOR:
cca_id = connector.config.get("composio_connected_account_id")
if cca_id:
return build_composio_credentials(cca_id)
raise ValueError("Composio connected_account_id not found")
config_data = dict(connector.config)
from app.config import config as app_config
from app.utils.oauth_security import TokenEncryption
token_encrypted = config_data.get("_token_encrypted", False)
if token_encrypted and app_config.SECRET_KEY:
token_encryption = TokenEncryption(app_config.SECRET_KEY)
if config_data.get("token"):
config_data["token"] = token_encryption.decrypt_token(config_data["token"])
if config_data.get("refresh_token"):
config_data["refresh_token"] = token_encryption.decrypt_token(config_data["refresh_token"])
if config_data.get("client_secret"):
config_data["client_secret"] = token_encryption.decrypt_token(config_data["client_secret"])
exp = config_data.get("expiry", "")
if exp:
exp = exp.replace("Z", "")
return Credentials(
token=config_data.get("token"),
refresh_token=config_data.get("refresh_token"),
token_uri=config_data.get("token_uri"),
client_id=config_data.get("client_id"),
client_secret=config_data.get("client_secret"),
scopes=config_data.get("scopes", []),
expiry=datetime.fromisoformat(exp) if exp else None,
)
async def _check_account_health(self, connector_id: int) -> bool:
"""Check if a Google Calendar connector's credentials are still valid.
Uses a lightweight calendarList.list(maxResults=1) call to verify access.
Returns True if the credentials are expired/invalid, False if healthy.
"""
try:
result = await self._db_session.execute(
select(SearchSourceConnector).where(
SearchSourceConnector.id == connector_id
)
)
connector = result.scalar_one_or_none()
if not connector:
return True
creds = await self._build_credentials(connector)
loop = asyncio.get_event_loop()
await loop.run_in_executor(
None,
lambda: build("calendar", "v3", credentials=creds)
.calendarList()
.list(maxResults=1)
.execute(),
)
return False
except Exception as e:
logger.warning(
"Google Calendar connector %s health check failed: %s",
connector_id,
e,
)
return True
async def _persist_auth_expired(self, connector_id: int) -> None:
"""Persist ``auth_expired: True`` to the connector config if not already set."""
try:
result = await self._db_session.execute(
select(SearchSourceConnector).where(
SearchSourceConnector.id == connector_id
)
)
db_connector = result.scalar_one_or_none()
if db_connector and not db_connector.config.get("auth_expired"):
db_connector.config = {**db_connector.config, "auth_expired": True}
flag_modified(db_connector, "config")
await self._db_session.commit()
await self._db_session.refresh(db_connector)
except Exception:
logger.warning(
"Failed to persist auth_expired for connector %s",
connector_id,
exc_info=True,
)
async def _get_accounts(
self, search_space_id: int, user_id: str
) -> list[GoogleCalendarAccount]:
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.in_(CALENDAR_CONNECTOR_TYPES),
)
)
.order_by(SearchSourceConnector.last_indexed_at.desc())
)
connectors = result.scalars().all()
return [GoogleCalendarAccount.from_connector(c) for c in connectors]
async def get_creation_context(self, search_space_id: int, user_id: str) -> dict:
accounts = await self._get_accounts(search_space_id, user_id)
if not accounts:
return {
"accounts": [],
"error": "No Google Calendar account connected",
}
accounts_with_status = []
for acc in accounts:
acc_dict = acc.to_dict()
auth_expired = await self._check_account_health(acc.id)
acc_dict["auth_expired"] = auth_expired
if auth_expired:
await self._persist_auth_expired(acc.id)
accounts_with_status.append(acc_dict)
healthy_account = next(
(a for a in accounts_with_status if not a.get("auth_expired")), None
)
if not healthy_account:
return {
"accounts": accounts_with_status,
"calendars": [],
"timezone": "",
"error": "All connected Google Calendar accounts have expired credentials",
}
connector_id = healthy_account["id"]
result = await self._db_session.execute(
select(SearchSourceConnector).where(
SearchSourceConnector.id == connector_id
)
)
connector = result.scalar_one_or_none()
calendars = []
timezone_str = ""
if connector:
try:
creds = await self._build_credentials(connector)
loop = asyncio.get_event_loop()
service = await loop.run_in_executor(
None, lambda: build("calendar", "v3", credentials=creds)
)
cal_list = await loop.run_in_executor(
None, lambda: service.calendarList().list().execute()
)
for cal in cal_list.get("items", []):
calendars.append({
"id": cal.get("id", ""),
"summary": cal.get("summary", ""),
"primary": cal.get("primary", False),
})
tz_setting = await loop.run_in_executor(
None,
lambda: service.settings().get(setting="timezone").execute(),
)
timezone_str = tz_setting.get("value", "")
except Exception:
logger.warning(
"Failed to fetch calendars/timezone for connector %s",
connector_id,
exc_info=True,
)
return {
"accounts": accounts_with_status,
"calendars": calendars,
"timezone": timezone_str,
}
async def get_update_context(
self, search_space_id: int, user_id: str, event_ref: str
) -> dict:
resolved = await self._resolve_event(search_space_id, user_id, event_ref)
if not resolved:
return {
"error": (
f"Event '{event_ref}' not found in your indexed Google Calendar events. "
"This could mean: (1) the event doesn't exist, (2) it hasn't been indexed yet, "
"or (3) the event name is different."
)
}
document, connector = resolved
account = GoogleCalendarAccount.from_connector(connector)
event = GoogleCalendarEvent.from_document(document)
acc_dict = account.to_dict()
auth_expired = await self._check_account_health(connector.id)
acc_dict["auth_expired"] = auth_expired
if auth_expired:
await self._persist_auth_expired(connector.id)
return {
"error": "Google Calendar credentials have expired. Please re-authenticate.",
"auth_expired": True,
"connector_id": connector.id,
}
event_dict = event.to_dict()
try:
creds = await self._build_credentials(connector)
loop = asyncio.get_event_loop()
service = await loop.run_in_executor(
None, lambda: build("calendar", "v3", credentials=creds)
)
calendar_id = event.calendar_id or "primary"
live_event = await loop.run_in_executor(
None,
lambda: service.events()
.get(calendarId=calendar_id, eventId=event.event_id)
.execute(),
)
event_dict["summary"] = live_event.get("summary", event_dict["summary"])
event_dict["description"] = live_event.get("description", event_dict["description"])
event_dict["location"] = live_event.get("location", event_dict["location"])
start_data = live_event.get("start", {})
event_dict["start"] = start_data.get("dateTime", start_data.get("date", event_dict["start"]))
end_data = live_event.get("end", {})
event_dict["end"] = end_data.get("dateTime", end_data.get("date", event_dict["end"]))
event_dict["attendees"] = [
{"email": a.get("email", ""), "responseStatus": a.get("responseStatus", "")}
for a in live_event.get("attendees", [])
]
except Exception:
logger.warning(
"Failed to fetch live event data for event %s, using KB metadata",
event.event_id,
exc_info=True,
)
return {
"account": acc_dict,
"event": event_dict,
}
async def get_deletion_context(
self, search_space_id: int, user_id: str, event_ref: str
) -> dict:
resolved = await self._resolve_event(search_space_id, user_id, event_ref)
if not resolved:
return {
"error": (
f"Event '{event_ref}' not found in your indexed Google Calendar events. "
"This could mean: (1) the event doesn't exist, (2) it hasn't been indexed yet, "
"or (3) the event name is different."
)
}
document, connector = resolved
account = GoogleCalendarAccount.from_connector(connector)
event = GoogleCalendarEvent.from_document(document)
acc_dict = account.to_dict()
auth_expired = await self._check_account_health(connector.id)
acc_dict["auth_expired"] = auth_expired
if auth_expired:
await self._persist_auth_expired(connector.id)
return {
"account": acc_dict,
"event": event.to_dict(),
}
async def _resolve_event(
self, search_space_id: int, user_id: str, event_ref: str
) -> tuple[Document, SearchSourceConnector] | None:
result = await self._db_session.execute(
select(Document, SearchSourceConnector)
.join(
SearchSourceConnector,
Document.connector_id == SearchSourceConnector.id,
)
.filter(
and_(
Document.search_space_id == search_space_id,
Document.document_type.in_(CALENDAR_DOCUMENT_TYPES),
SearchSourceConnector.user_id == user_id,
or_(
func.lower(
Document.document_metadata["event_summary"].astext
)
== func.lower(event_ref),
func.lower(Document.title) == func.lower(event_ref),
),
)
)
)
row = result.first()
if row:
return row[0], row[1]
return None

View file

@ -41,6 +41,16 @@ import type { ThinkingStep } from "@/components/tool-ui/deepagent-thinking";
import { DisplayImageToolUI } from "@/components/tool-ui/display-image";
import { GeneratePodcastToolUI } from "@/components/tool-ui/generate-podcast";
import { GenerateReportToolUI } from "@/components/tool-ui/generate-report";
import {
CreateCalendarEventToolUI,
UpdateCalendarEventToolUI,
DeleteCalendarEventToolUI,
} from "@/components/tool-ui/google-calendar";
import {
CreateGmailDraftToolUI,
SendGmailEmailToolUI,
TrashGmailEmailToolUI,
} from "@/components/tool-ui/gmail";
import {
CreateGoogleDriveFileToolUI,
DeleteGoogleDriveFileToolUI,
@ -160,6 +170,12 @@ const TOOLS_WITH_UI = new Set([
"delete_linear_issue",
"create_google_drive_file",
"delete_google_drive_file",
"create_calendar_event",
"update_calendar_event",
"delete_calendar_event",
"create_gmail_draft",
"send_gmail_email",
"trash_gmail_email",
"execute",
// "write_todos", // Disabled for now
]);
@ -1676,6 +1692,12 @@ export default function NewChatPage() {
<DeleteLinearIssueToolUI />
<CreateGoogleDriveFileToolUI />
<DeleteGoogleDriveFileToolUI />
<CreateCalendarEventToolUI />
<UpdateCalendarEventToolUI />
<DeleteCalendarEventToolUI />
<CreateGmailDraftToolUI />
<SendGmailEmailToolUI />
<TrashGmailEmailToolUI />
<SandboxExecuteToolUI />
{/* <WriteTodosToolUI /> Disabled for now */}
<div key={searchSpaceId} className="flex h-[calc(100dvh-64px)] overflow-hidden">

View file

@ -1,12 +1,20 @@
import { atom } from "jotai";
import { rightPanelCollapsedAtom, rightPanelTabAtom } from "@/atoms/layout/right-panel.atom";
export interface ExtraField {
label: string;
key: string;
value: string;
type: "text" | "email" | "datetime-local" | "textarea";
}
interface HitlEditPanelState {
isOpen: boolean;
title: string;
content: string;
toolName: string;
onSave: ((title: string, content: string) => void) | null;
extraFields?: ExtraField[];
onSave: ((title: string, content: string, extraFieldValues?: Record<string, string>) => void) | null;
}
const initialState: HitlEditPanelState = {
@ -14,6 +22,7 @@ const initialState: HitlEditPanelState = {
title: "",
content: "",
toolName: "",
extraFields: undefined,
onSave: null,
};
@ -30,7 +39,8 @@ export const openHitlEditPanelAtom = atom(
title: string;
content: string;
toolName: string;
onSave: (title: string, content: string) => void;
extraFields?: ExtraField[];
onSave: (title: string, content: string, extraFieldValues?: Record<string, string>) => void;
}
) => {
if (!get(hitlEditPanelAtom).isOpen) {
@ -41,6 +51,7 @@ export const openHitlEditPanelAtom = atom(
title: payload.title,
content: payload.content,
toolName: payload.toolName,
extraFields: payload.extraFields,
onSave: payload.onSave,
});
set(rightPanelTabAtom, "hitl-edit");

View file

@ -7,14 +7,19 @@ import {
closeHitlEditPanelAtom,
hitlEditPanelAtom,
} from "@/atoms/chat/hitl-edit-panel.atom";
import type { ExtraField } from "@/atoms/chat/hitl-edit-panel.atom";
import { PlateEditor } from "@/components/editor/plate-editor";
import { Button } from "@/components/ui/button";
import { Drawer, DrawerContent, DrawerHandle, DrawerTitle } from "@/components/ui/drawer";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { useMediaQuery } from "@/hooks/use-media-query";
export function HitlEditPanelContent({
title: initialTitle,
content: initialContent,
extraFields,
onSave,
onClose,
showCloseButton = true,
@ -22,24 +27,38 @@ export function HitlEditPanelContent({
title: string;
content: string;
toolName: string;
onSave: (title: string, content: string) => void;
extraFields?: ExtraField[];
onSave: (title: string, content: string, extraFieldValues?: Record<string, string>) => void;
onClose?: () => void;
showCloseButton?: boolean;
}) {
const [editedTitle, setEditedTitle] = useState(initialTitle);
const markdownRef = useRef(initialContent);
const [isSaving, setIsSaving] = useState(false);
const [extraFieldValues, setExtraFieldValues] = useState<Record<string, string>>(() => {
if (!extraFields) return {};
const initial: Record<string, string> = {};
for (const field of extraFields) {
initial[field.key] = field.value;
}
return initial;
});
const handleMarkdownChange = useCallback((md: string) => {
markdownRef.current = md;
}, []);
const handleExtraFieldChange = useCallback((key: string, value: string) => {
setExtraFieldValues((prev) => ({ ...prev, [key]: value }));
}, []);
const handleSave = useCallback(() => {
if (!editedTitle.trim()) return;
setIsSaving(true);
onSave(editedTitle, markdownRef.current);
const extras = extraFields && extraFields.length > 0 ? extraFieldValues : undefined;
onSave(editedTitle, markdownRef.current, extras);
onClose?.();
}, [editedTitle, onSave, onClose]);
}, [editedTitle, onSave, onClose, extraFields, extraFieldValues]);
return (
<>
@ -59,6 +78,34 @@ export function HitlEditPanelContent({
)}
</div>
{extraFields && extraFields.length > 0 && (
<div className="flex flex-col gap-3 px-4 py-3 border-b">
{extraFields.map((field) => (
<div key={field.key} className="flex flex-col gap-1.5">
<Label htmlFor={`extra-field-${field.key}`} className="text-xs font-medium text-muted-foreground">
{field.label}
</Label>
{field.type === "textarea" ? (
<Textarea
id={`extra-field-${field.key}`}
value={extraFieldValues[field.key] ?? ""}
onChange={(e) => handleExtraFieldChange(field.key, e.target.value)}
className="text-sm min-h-[60px]"
/>
) : (
<Input
id={`extra-field-${field.key}`}
type={field.type}
value={extraFieldValues[field.key] ?? ""}
onChange={(e) => handleExtraFieldChange(field.key, e.target.value)}
className="text-sm"
/>
)}
</div>
))}
</div>
)}
<div className="flex-1 overflow-hidden">
<PlateEditor
markdown={initialContent}
@ -90,6 +137,7 @@ function DesktopHitlEditPanel() {
title={panelState.title}
content={panelState.content}
toolName={panelState.toolName}
extraFields={panelState.extraFields}
onSave={panelState.onSave}
onClose={closePanel}
/>
@ -124,6 +172,7 @@ function MobileHitlEditDrawer() {
title={panelState.title}
content={panelState.content}
toolName={panelState.toolName}
extraFields={panelState.extraFields}
onSave={panelState.onSave}
onClose={closePanel}
showCloseButton={false}

View file

@ -0,0 +1,450 @@
"use client";
import { makeAssistantToolUI } from "@assistant-ui/react";
import {
CornerDownLeftIcon,
FileEditIcon,
MailIcon,
Pen,
UserIcon,
UsersIcon,
} from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { PlateEditor } from "@/components/editor/plate-editor";
import { TextShimmerLoader } from "@/components/prompt-kit/loader";
import { useSetAtom } from "jotai";
import { openHitlEditPanelAtom } from "@/atoms/chat/hitl-edit-panel.atom";
import type { ExtraField } from "@/atoms/chat/hitl-edit-panel.atom";
interface GmailAccount {
id: number;
name: string;
email: string;
auth_expired?: boolean;
}
interface InterruptResult {
__interrupt__: true;
__decided__?: "approve" | "reject" | "edit";
action_requests: Array<{
name: string;
args: Record<string, unknown>;
}>;
review_configs: Array<{
action_name: string;
allowed_decisions: Array<"approve" | "edit" | "reject">;
}>;
context?: {
accounts?: GmailAccount[];
error?: string;
};
}
interface SuccessResult {
status: "success";
draft_id?: string;
message_id?: string;
message?: string;
}
interface ErrorResult {
status: "error";
message: string;
}
interface AuthErrorResult {
status: "auth_error";
message: string;
connector_type?: string;
}
type CreateGmailDraftResult =
| InterruptResult
| SuccessResult
| ErrorResult
| AuthErrorResult;
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 isAuthErrorResult(result: unknown): result is AuthErrorResult {
return (
typeof result === "object" &&
result !== null &&
"status" in result &&
(result as AuthErrorResult).status === "auth_error"
);
}
function ApprovalCard({
args,
interruptData,
onDecision,
}: {
args: { to: string; subject: string; body: string; cc?: string; bcc?: string };
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 [isPanelOpen, setIsPanelOpen] = useState(false);
const openHitlEditPanel = useSetAtom(openHitlEditPanelAtom);
const accounts = interruptData.context?.accounts ?? [];
const validAccounts = accounts.filter((a) => !a.auth_expired);
const expiredAccounts = accounts.filter((a) => a.auth_expired);
const defaultAccountId = useMemo(() => {
if (validAccounts.length === 1) return String(validAccounts[0].id);
return "";
}, [validAccounts]);
const [selectedAccountId, setSelectedAccountId] = useState<string>(defaultAccountId);
const canApprove = !!selectedAccountId;
const reviewConfig = interruptData.review_configs[0];
const allowedDecisions = reviewConfig?.allowed_decisions ?? ["approve", "reject"];
const canEdit = allowedDecisions.includes("edit");
const handleApprove = useCallback(() => {
if (decided || isPanelOpen || !canApprove) return;
if (!allowedDecisions.includes("approve")) return;
setDecided("approve");
onDecision({
type: "approve",
edited_action: {
name: interruptData.action_requests[0].name,
args: {
...args,
connector_id: selectedAccountId ? Number(selectedAccountId) : null,
},
},
});
}, [decided, isPanelOpen, canApprove, allowedDecisions, onDecision, interruptData, args, selectedAccountId]);
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey && !e.ctrlKey && !e.metaKey) {
handleApprove();
}
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [handleApprove]);
if (decided && decided !== "reject") return null;
return (
<div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 transition-all duration-300">
{/* Header */}
<div className="flex items-start justify-between px-5 pt-5 pb-4 select-none">
<div className="flex items-center gap-2">
<FileEditIcon className="size-4 text-muted-foreground shrink-0" />
<div>
<p className="text-sm font-semibold text-foreground">
{decided === "reject"
? "Gmail Draft Rejected"
: decided === "approve" || decided === "edit"
? "Gmail Draft Approved"
: "Create Gmail Draft"}
</p>
<p className="text-xs text-muted-foreground mt-0.5">
{decided === "reject"
? "Draft creation was cancelled"
: decided === "edit"
? "Draft creation is in progress with your changes"
: decided === "approve"
? "Draft creation is in progress"
: "Requires your approval to proceed"}
</p>
</div>
</div>
{!decided && canEdit && (
<Button
size="sm"
variant="ghost"
className="rounded-lg text-muted-foreground -mt-1 -mr-2"
onClick={() => {
setIsPanelOpen(true);
const extraFields: ExtraField[] = [
{ key: "to", label: "To", type: "email", value: args.to || "" },
{ key: "cc", label: "CC", type: "email", value: args.cc || "" },
{ key: "bcc", label: "BCC", type: "email", value: args.bcc || "" },
];
openHitlEditPanel({
title: args.subject ?? "",
content: args.body ?? "",
toolName: "Gmail Draft",
extraFields,
onSave: (newTitle, newContent, extraFieldValues) => {
setIsPanelOpen(false);
setDecided("edit");
const extras = extraFieldValues ?? {};
onDecision({
type: "edit",
edited_action: {
name: interruptData.action_requests[0].name,
args: {
...args,
subject: newTitle,
body: newContent,
to: extras.to ?? args.to,
cc: extras.cc ?? args.cc,
bcc: extras.bcc ?? args.bcc,
connector_id: selectedAccountId ? Number(selectedAccountId) : null,
},
},
});
},
});
}}
>
<Pen className="size-3.5" />
Edit
</Button>
)}
</div>
{/* Account selector */}
{!decided && interruptData.context && (
<>
<div className="mx-5 h-px bg-border/50" />
<div className="px-5 py-4 space-y-4 select-none">
{interruptData.context.error ? (
<p className="text-sm text-destructive">{interruptData.context.error}</p>
) : (
<>
{accounts.length > 0 && (
<div className="space-y-2">
<p className="text-xs font-medium text-muted-foreground">
Gmail Account <span className="text-destructive">*</span>
</p>
<Select value={selectedAccountId} onValueChange={setSelectedAccountId}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select an account" />
</SelectTrigger>
<SelectContent>
{validAccounts.map((account) => (
<SelectItem key={account.id} value={String(account.id)}>
{account.email}
</SelectItem>
))}
{expiredAccounts.map((a) => (
<div
key={a.id}
className="relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 px-2 text-sm select-none opacity-50 pointer-events-none"
>
{a.email} (expired, retry after re-auth)
</div>
))}
</SelectContent>
</Select>
</div>
)}
</>
)}
</div>
</>
)}
{/* Email headers + body preview */}
<div className="mx-5 h-px bg-border/50" />
<div className="px-5 pt-3 pb-2 space-y-1.5 select-none">
{args.to && (
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<UserIcon className="size-3 shrink-0" />
<span>To: {args.to}</span>
</div>
)}
{args.cc && args.cc.trim() !== "" && (
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<UsersIcon className="size-3 shrink-0" />
<span>CC: {args.cc}</span>
</div>
)}
{args.bcc && args.bcc.trim() !== "" && (
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<UsersIcon className="size-3 shrink-0" />
<span>BCC: {args.bcc}</span>
</div>
)}
</div>
<div className="px-5 pt-1">
{args.subject != null && (
<p className="text-sm font-medium text-foreground">{args.subject}</p>
)}
{args.body != null && (
<div
className="mt-2 max-h-[7rem] overflow-hidden text-sm"
style={{
maskImage: "linear-gradient(to bottom, black 50%, transparent 100%)",
WebkitMaskImage: "linear-gradient(to bottom, black 50%, transparent 100%)",
}}
>
<PlateEditor
markdown={String(args.body)}
readOnly
preset="readonly"
editorVariant="none"
className="h-auto [&_[data-slate-editor]]:!min-h-0 [&_[data-slate-editor]>*:first-child]:!mt-0"
/>
</div>
)}
</div>
{/* Action buttons */}
{!decided && (
<>
<div className="mx-5 h-px bg-border/50" />
<div className="px-5 py-4 flex items-center gap-2 select-none">
{allowedDecisions.includes("approve") && (
<Button
size="sm"
className="rounded-lg gap-1.5"
onClick={handleApprove}
disabled={!canApprove}
>
Approve
<CornerDownLeftIcon className="size-3 opacity-60" />
</Button>
)}
{allowedDecisions.includes("reject") && (
<Button
size="sm"
variant="ghost"
className="rounded-lg text-muted-foreground"
onClick={() => {
setDecided("reject");
onDecision({ type: "reject", message: "User rejected the action." });
}}
>
Reject
</Button>
)}
</div>
</>
)}
</div>
);
}
function ErrorCard({ result }: { result: ErrorResult }) {
return (
<div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 select-none">
<div className="px-5 pt-5 pb-4">
<p className="text-sm font-semibold text-destructive">Failed to create Gmail draft</p>
</div>
<div className="mx-5 h-px bg-border/50" />
<div className="px-5 py-4">
<p className="text-sm text-muted-foreground">{result.message}</p>
</div>
</div>
);
}
function AuthErrorCard({ result }: { result: AuthErrorResult }) {
return (
<div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 select-none">
<div className="px-5 pt-5 pb-4">
<p className="text-sm font-semibold text-destructive">
Gmail authentication expired
</p>
</div>
<div className="mx-5 h-px bg-border/50" />
<div className="px-5 py-4">
<p className="text-sm text-muted-foreground">{result.message}</p>
</div>
</div>
);
}
function SuccessCard({ result }: { result: SuccessResult }) {
return (
<div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 select-none">
<div className="px-5 pt-5 pb-4">
<div className="flex items-center gap-2">
<MailIcon className="size-4 text-muted-foreground shrink-0" />
<p className="text-sm font-semibold text-foreground">
{result.message || "Gmail draft created successfully"}
</p>
</div>
</div>
</div>
);
}
export const CreateGmailDraftToolUI = makeAssistantToolUI<
{ to: string; subject: string; body: string; cc?: string; bcc?: string },
CreateGmailDraftResult
>({
toolName: "create_gmail_draft",
render: function CreateGmailDraftUI({ args, result, status }) {
if (status.type === "running") {
return (
<div className="my-4 max-w-lg rounded-2xl border bg-muted/30 px-5 py-4 select-none">
<TextShimmerLoader text="Creating Gmail draft..." size="sm" />
</div>
);
}
if (!result) return null;
if (isInterruptResult(result)) {
return (
<ApprovalCard
args={args}
interruptData={result}
onDecision={(decision) => {
window.dispatchEvent(
new CustomEvent("hitl-decision", { detail: { decisions: [decision] } })
);
}}
/>
);
}
if (
typeof result === "object" &&
result !== null &&
"status" in result &&
(result as { status: string }).status === "rejected"
) {
return null;
}
if (isAuthErrorResult(result)) return <AuthErrorCard result={result} />;
if (isErrorResult(result)) return <ErrorCard result={result} />;
return <SuccessCard result={result as SuccessResult} />;
},
});

View file

@ -0,0 +1,3 @@
export { CreateGmailDraftToolUI } from "./create-draft";
export { SendGmailEmailToolUI } from "./send-email";
export { TrashGmailEmailToolUI } from "./trash-email";

View file

@ -0,0 +1,449 @@
"use client";
import { makeAssistantToolUI } from "@assistant-ui/react";
import {
CornerDownLeftIcon,
MailIcon,
Pen,
SendIcon,
UserIcon,
UsersIcon,
} from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { PlateEditor } from "@/components/editor/plate-editor";
import { TextShimmerLoader } from "@/components/prompt-kit/loader";
import { useSetAtom } from "jotai";
import { openHitlEditPanelAtom } from "@/atoms/chat/hitl-edit-panel.atom";
import type { ExtraField } from "@/atoms/chat/hitl-edit-panel.atom";
interface GmailAccount {
id: number;
name: string;
email: string;
auth_expired?: boolean;
}
interface InterruptResult {
__interrupt__: true;
__decided__?: "approve" | "reject" | "edit";
action_requests: Array<{
name: string;
args: Record<string, unknown>;
}>;
review_configs: Array<{
action_name: string;
allowed_decisions: Array<"approve" | "edit" | "reject">;
}>;
context?: {
accounts?: GmailAccount[];
error?: string;
};
}
interface SuccessResult {
status: "success";
message_id?: string;
message?: string;
}
interface ErrorResult {
status: "error";
message: string;
}
interface AuthErrorResult {
status: "auth_error";
message: string;
connector_type?: string;
}
type SendGmailEmailResult =
| InterruptResult
| SuccessResult
| ErrorResult
| AuthErrorResult;
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 isAuthErrorResult(result: unknown): result is AuthErrorResult {
return (
typeof result === "object" &&
result !== null &&
"status" in result &&
(result as AuthErrorResult).status === "auth_error"
);
}
function ApprovalCard({
args,
interruptData,
onDecision,
}: {
args: { to: string; subject: string; body: string; cc?: string; bcc?: string };
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 [isPanelOpen, setIsPanelOpen] = useState(false);
const openHitlEditPanel = useSetAtom(openHitlEditPanelAtom);
const accounts = interruptData.context?.accounts ?? [];
const validAccounts = accounts.filter((a) => !a.auth_expired);
const expiredAccounts = accounts.filter((a) => a.auth_expired);
const defaultAccountId = useMemo(() => {
if (validAccounts.length === 1) return String(validAccounts[0].id);
return "";
}, [validAccounts]);
const [selectedAccountId, setSelectedAccountId] = useState<string>(defaultAccountId);
const canApprove = !!selectedAccountId;
const reviewConfig = interruptData.review_configs[0];
const allowedDecisions = reviewConfig?.allowed_decisions ?? ["approve", "reject"];
const canEdit = allowedDecisions.includes("edit");
const handleApprove = useCallback(() => {
if (decided || isPanelOpen || !canApprove) return;
if (!allowedDecisions.includes("approve")) return;
setDecided("approve");
onDecision({
type: "approve",
edited_action: {
name: interruptData.action_requests[0].name,
args: {
...args,
connector_id: selectedAccountId ? Number(selectedAccountId) : null,
},
},
});
}, [decided, isPanelOpen, canApprove, allowedDecisions, onDecision, interruptData, args, selectedAccountId]);
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey && !e.ctrlKey && !e.metaKey) {
handleApprove();
}
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [handleApprove]);
if (decided && decided !== "reject") return null;
return (
<div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 transition-all duration-300">
{/* Header */}
<div className="flex items-start justify-between px-5 pt-5 pb-4 select-none">
<div className="flex items-center gap-2">
<SendIcon className="size-4 text-muted-foreground shrink-0" />
<div>
<p className="text-sm font-semibold text-foreground">
{decided === "reject"
? "Email Sending Rejected"
: decided === "approve" || decided === "edit"
? "Email Sending Approved"
: "Send Email"}
</p>
<p className="text-xs text-muted-foreground mt-0.5">
{decided === "reject"
? "Email sending was cancelled"
: decided === "edit"
? "Email is being sent with your changes"
: decided === "approve"
? "Email is being sent"
: "This will send the email immediately"}
</p>
</div>
</div>
{!decided && canEdit && (
<Button
size="sm"
variant="ghost"
className="rounded-lg text-muted-foreground -mt-1 -mr-2"
onClick={() => {
setIsPanelOpen(true);
const extraFields: ExtraField[] = [
{ key: "to", label: "To", type: "email", value: args.to || "" },
{ key: "cc", label: "CC", type: "email", value: args.cc || "" },
{ key: "bcc", label: "BCC", type: "email", value: args.bcc || "" },
];
openHitlEditPanel({
title: args.subject ?? "",
content: args.body ?? "",
toolName: "Send Email",
extraFields,
onSave: (newTitle, newContent, extraFieldValues) => {
setIsPanelOpen(false);
setDecided("edit");
const extras = extraFieldValues ?? {};
onDecision({
type: "edit",
edited_action: {
name: interruptData.action_requests[0].name,
args: {
...args,
subject: newTitle,
body: newContent,
to: extras.to ?? args.to,
cc: extras.cc ?? args.cc,
bcc: extras.bcc ?? args.bcc,
connector_id: selectedAccountId ? Number(selectedAccountId) : null,
},
},
});
},
});
}}
>
<Pen className="size-3.5" />
Edit
</Button>
)}
</div>
{/* Account selector */}
{!decided && interruptData.context && (
<>
<div className="mx-5 h-px bg-border/50" />
<div className="px-5 py-4 space-y-4 select-none">
{interruptData.context.error ? (
<p className="text-sm text-destructive">{interruptData.context.error}</p>
) : (
<>
{accounts.length > 0 && (
<div className="space-y-2">
<p className="text-xs font-medium text-muted-foreground">
Gmail Account <span className="text-destructive">*</span>
</p>
<Select value={selectedAccountId} onValueChange={setSelectedAccountId}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select an account" />
</SelectTrigger>
<SelectContent>
{validAccounts.map((account) => (
<SelectItem key={account.id} value={String(account.id)}>
{account.email}
</SelectItem>
))}
{expiredAccounts.map((a) => (
<div
key={a.id}
className="relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 px-2 text-sm select-none opacity-50 pointer-events-none"
>
{a.email} (expired, retry after re-auth)
</div>
))}
</SelectContent>
</Select>
</div>
)}
</>
)}
</div>
</>
)}
{/* Email headers + body preview */}
<div className="mx-5 h-px bg-border/50" />
<div className="px-5 pt-3 pb-2 space-y-1.5 select-none">
{args.to && (
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<UserIcon className="size-3 shrink-0" />
<span>To: {args.to}</span>
</div>
)}
{args.cc && args.cc.trim() !== "" && (
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<UsersIcon className="size-3 shrink-0" />
<span>CC: {args.cc}</span>
</div>
)}
{args.bcc && args.bcc.trim() !== "" && (
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<UsersIcon className="size-3 shrink-0" />
<span>BCC: {args.bcc}</span>
</div>
)}
</div>
<div className="px-5 pt-1">
{args.subject != null && (
<p className="text-sm font-medium text-foreground">{args.subject}</p>
)}
{args.body != null && (
<div
className="mt-2 max-h-[7rem] overflow-hidden text-sm"
style={{
maskImage: "linear-gradient(to bottom, black 50%, transparent 100%)",
WebkitMaskImage: "linear-gradient(to bottom, black 50%, transparent 100%)",
}}
>
<PlateEditor
markdown={String(args.body)}
readOnly
preset="readonly"
editorVariant="none"
className="h-auto [&_[data-slate-editor]]:!min-h-0 [&_[data-slate-editor]>*:first-child]:!mt-0"
/>
</div>
)}
</div>
{/* Action buttons */}
{!decided && (
<>
<div className="mx-5 h-px bg-border/50" />
<div className="px-5 py-4 flex items-center gap-2 select-none">
{allowedDecisions.includes("approve") && (
<Button
size="sm"
className="rounded-lg gap-1.5"
onClick={handleApprove}
disabled={!canApprove}
>
Send
<CornerDownLeftIcon className="size-3 opacity-60" />
</Button>
)}
{allowedDecisions.includes("reject") && (
<Button
size="sm"
variant="ghost"
className="rounded-lg text-muted-foreground"
onClick={() => {
setDecided("reject");
onDecision({ type: "reject", message: "User rejected the action." });
}}
>
Reject
</Button>
)}
</div>
</>
)}
</div>
);
}
function ErrorCard({ result }: { result: ErrorResult }) {
return (
<div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 select-none">
<div className="px-5 pt-5 pb-4">
<p className="text-sm font-semibold text-destructive">Failed to send email</p>
</div>
<div className="mx-5 h-px bg-border/50" />
<div className="px-5 py-4">
<p className="text-sm text-muted-foreground">{result.message}</p>
</div>
</div>
);
}
function AuthErrorCard({ result }: { result: AuthErrorResult }) {
return (
<div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 select-none">
<div className="px-5 pt-5 pb-4">
<p className="text-sm font-semibold text-destructive">
Gmail authentication expired
</p>
</div>
<div className="mx-5 h-px bg-border/50" />
<div className="px-5 py-4">
<p className="text-sm text-muted-foreground">{result.message}</p>
</div>
</div>
);
}
function SuccessCard({ result }: { result: SuccessResult }) {
return (
<div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 select-none">
<div className="px-5 pt-5 pb-4">
<div className="flex items-center gap-2">
<MailIcon className="size-4 text-muted-foreground shrink-0" />
<p className="text-sm font-semibold text-foreground">
{result.message || "Email sent successfully"}
</p>
</div>
</div>
</div>
);
}
export const SendGmailEmailToolUI = makeAssistantToolUI<
{ to: string; subject: string; body: string; cc?: string; bcc?: string },
SendGmailEmailResult
>({
toolName: "send_gmail_email",
render: function SendGmailEmailUI({ args, result, status }) {
if (status.type === "running") {
return (
<div className="my-4 max-w-lg rounded-2xl border bg-muted/30 px-5 py-4 select-none">
<TextShimmerLoader text="Sending email..." size="sm" />
</div>
);
}
if (!result) return null;
if (isInterruptResult(result)) {
return (
<ApprovalCard
args={args}
interruptData={result}
onDecision={(decision) => {
window.dispatchEvent(
new CustomEvent("hitl-decision", { detail: { decisions: [decision] } })
);
}}
/>
);
}
if (
typeof result === "object" &&
result !== null &&
"status" in result &&
(result as { status: string }).status === "rejected"
) {
return null;
}
if (isAuthErrorResult(result)) return <AuthErrorCard result={result} />;
if (isErrorResult(result)) return <ErrorCard result={result} />;
return <SuccessCard result={result as SuccessResult} />;
},
});

View file

@ -0,0 +1,406 @@
"use client";
import { makeAssistantToolUI } from "@assistant-ui/react";
import {
CalendarIcon,
CornerDownLeftIcon,
MailIcon,
Trash2Icon,
TriangleAlertIcon,
UserIcon,
} from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { TextShimmerLoader } from "@/components/prompt-kit/loader";
interface GmailAccount {
id: number;
name: string;
email: string;
auth_expired?: boolean;
}
interface GmailMessage {
message_id: string;
thread_id?: string;
subject: string;
sender: string;
date: string;
connector_id: number;
document_id: number;
}
interface InterruptResult {
__interrupt__: true;
__decided__?: "approve" | "reject";
action_requests: Array<{
name: string;
args: Record<string, unknown>;
}>;
review_configs: Array<{
action_name: string;
allowed_decisions: Array<"approve" | "reject">;
}>;
context?: {
account?: GmailAccount;
email?: GmailMessage;
error?: string;
};
}
interface SuccessResult {
status: "success";
message_id?: string;
message?: string;
deleted_from_kb?: boolean;
}
interface ErrorResult {
status: "error";
message: string;
}
interface NotFoundResult {
status: "not_found";
message: string;
}
interface AuthErrorResult {
status: "auth_error";
message: string;
connector_type?: string;
}
type TrashGmailEmailResult =
| InterruptResult
| SuccessResult
| ErrorResult
| NotFoundResult
| AuthErrorResult;
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 isNotFoundResult(result: unknown): result is NotFoundResult {
return (
typeof result === "object" &&
result !== null &&
"status" in result &&
(result as NotFoundResult).status === "not_found"
);
}
function isAuthErrorResult(result: unknown): result is AuthErrorResult {
return (
typeof result === "object" &&
result !== null &&
"status" in result &&
(result as AuthErrorResult).status === "auth_error"
);
}
function formatDate(dateStr: string): string {
return new Date(dateStr).toLocaleDateString(undefined, { dateStyle: "medium" });
}
function ApprovalCard({
interruptData,
onDecision,
}: {
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 [deleteFromKb, setDeleteFromKb] = useState(false);
const account = interruptData.context?.account;
const email = interruptData.context?.email;
const handleApprove = useCallback(() => {
if (decided) return;
setDecided("approve");
onDecision({
type: "approve",
edited_action: {
name: interruptData.action_requests[0].name,
args: {
message_id: email?.message_id,
connector_id: email?.connector_id ?? account?.id,
delete_from_kb: deleteFromKb,
},
},
});
}, [decided, onDecision, interruptData, email, account?.id, deleteFromKb]);
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey && !e.ctrlKey && !e.metaKey) {
handleApprove();
}
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [handleApprove]);
if (decided && decided !== "reject") return null;
return (
<div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 transition-all duration-300">
{/* Header */}
<div className="flex items-start justify-between px-5 pt-5 pb-4 select-none">
<div className="flex items-center gap-2">
<Trash2Icon className="size-4 text-muted-foreground shrink-0" />
<div>
<p className="text-sm font-semibold text-foreground">
{decided === "reject"
? "Email Trash Rejected"
: decided === "approve"
? "Email Trash Approved"
: "Trash Email"}
</p>
<p className="text-xs text-muted-foreground mt-0.5">
{decided === "reject"
? "Email trash was cancelled"
: decided === "approve"
? "Email is being trashed"
: "Requires your approval to proceed"}
</p>
</div>
</div>
</div>
{/* Context — read-only account and email info */}
{!decided && interruptData.context && (
<>
<div className="mx-5 h-px bg-border/50" />
<div className="px-5 py-4 space-y-4 select-none">
{interruptData.context.error ? (
<p className="text-sm text-destructive">{interruptData.context.error}</p>
) : (
<>
{account && (
<div className="space-y-2">
<p className="text-xs font-medium text-muted-foreground">Gmail Account</p>
<div className="w-full rounded-md border border-input bg-muted/50 px-3 py-2 text-sm">
{account.email}
</div>
</div>
)}
{email && (
<div className="space-y-2">
<p className="text-xs font-medium text-muted-foreground">Email to Trash</p>
<div className="w-full rounded-md border border-input bg-muted/50 px-3 py-2 text-sm space-y-1.5">
<div className="flex items-center gap-1.5">
<MailIcon className="size-3 shrink-0 text-muted-foreground" />
<span className="font-medium">{email.subject}</span>
</div>
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<UserIcon className="size-3 shrink-0" />
<span>From: {email.sender}</span>
</div>
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<CalendarIcon className="size-3 shrink-0" />
<span>Date: {formatDate(email.date)}</span>
</div>
</div>
</div>
)}
</>
)}
</div>
</>
)}
{/* delete_from_kb toggle */}
{!decided && (
<>
<div className="mx-5 h-px bg-border/50" />
<div className="px-5 py-4 select-none">
<div className="flex items-center gap-2.5">
<Checkbox
id="gmail-delete-from-kb"
checked={deleteFromKb}
onCheckedChange={(v) => setDeleteFromKb(v === true)}
className="shrink-0"
/>
<label htmlFor="gmail-delete-from-kb" className="flex-1 cursor-pointer">
<span className="text-sm text-foreground">Also remove from knowledge base</span>
<p className="text-xs text-muted-foreground mt-0.5">
This will permanently delete the email from your knowledge base (cannot be undone)
</p>
</label>
</div>
</div>
</>
)}
{/* Action buttons */}
{!decided && (
<>
<div className="mx-5 h-px bg-border/50" />
<div className="px-5 py-4 flex items-center gap-2 select-none">
<Button
size="sm"
className="rounded-lg gap-1.5"
onClick={handleApprove}
>
Approve
<CornerDownLeftIcon className="size-3 opacity-60" />
</Button>
<Button
size="sm"
variant="ghost"
className="rounded-lg text-muted-foreground"
onClick={() => {
setDecided("reject");
onDecision({ type: "reject", message: "User rejected the action." });
}}
>
Reject
</Button>
</div>
</>
)}
</div>
);
}
function ErrorCard({ result }: { result: ErrorResult }) {
return (
<div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 select-none">
<div className="px-5 pt-5 pb-4">
<p className="text-sm font-semibold text-destructive">Failed to trash email</p>
</div>
<div className="mx-5 h-px bg-border/50" />
<div className="px-5 py-4">
<p className="text-sm text-muted-foreground">{result.message}</p>
</div>
</div>
);
}
function AuthErrorCard({ result }: { result: AuthErrorResult }) {
return (
<div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 select-none">
<div className="px-5 pt-5 pb-4">
<p className="text-sm font-semibold text-destructive">
Gmail authentication expired
</p>
</div>
<div className="mx-5 h-px bg-border/50" />
<div className="px-5 py-4">
<p className="text-sm text-muted-foreground">{result.message}</p>
</div>
</div>
);
}
function NotFoundCard({ result }: { result: NotFoundResult }) {
return (
<div className="my-4 max-w-lg overflow-hidden rounded-2xl border border-amber-500/50 bg-muted/30 select-none">
<div className="px-5 pt-5 pb-4">
<div className="flex items-center gap-2">
<TriangleAlertIcon className="size-4 text-amber-500 shrink-0" />
<p className="text-sm font-semibold text-amber-600 dark:text-amber-400">
Email not found
</p>
</div>
</div>
<div className="mx-5 h-px bg-amber-500/30" />
<div className="px-5 py-4">
<p className="text-sm text-muted-foreground">{result.message}</p>
</div>
</div>
);
}
function SuccessCard({ result }: { result: SuccessResult }) {
return (
<div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 select-none">
<div className="px-5 pt-5 pb-4">
<p className="text-sm font-semibold text-foreground">
{result.message || "Email moved to trash successfully"}
</p>
</div>
{result.deleted_from_kb && (
<>
<div className="mx-5 h-px bg-border/50" />
<div className="px-5 py-4 text-xs">
<span className="text-green-600 dark:text-green-500">
Also removed from knowledge base
</span>
</div>
</>
)}
</div>
);
}
export const TrashGmailEmailToolUI = makeAssistantToolUI<
{ email_subject_or_id: string; delete_from_kb?: boolean },
TrashGmailEmailResult
>({
toolName: "trash_gmail_email",
render: function TrashGmailEmailUI({ result, status }) {
if (status.type === "running") {
return (
<div className="my-4 max-w-lg rounded-2xl border bg-muted/30 px-5 py-4 select-none">
<TextShimmerLoader text="Trashing email..." size="sm" />
</div>
);
}
if (!result) return null;
if (isInterruptResult(result)) {
return (
<ApprovalCard
interruptData={result}
onDecision={(decision) => {
window.dispatchEvent(
new CustomEvent("hitl-decision", { detail: { decisions: [decision] } })
);
}}
/>
);
}
if (
typeof result === "object" &&
result !== null &&
"status" in result &&
(result as { status: string }).status === "rejected"
) {
return null;
}
if (isAuthErrorResult(result)) return <AuthErrorCard result={result} />;
if (isNotFoundResult(result)) return <NotFoundCard result={result} />;
if (isErrorResult(result)) return <ErrorCard result={result} />;
return <SuccessCard result={result as SuccessResult} />;
},
});

View file

@ -0,0 +1,559 @@
"use client";
import { makeAssistantToolUI } from "@assistant-ui/react";
import {
CalendarPlusIcon,
ClockIcon,
MapPinIcon,
UsersIcon,
GlobeIcon,
CornerDownLeftIcon,
Pen,
} from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useSetAtom } from "jotai";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { PlateEditor } from "@/components/editor/plate-editor";
import { TextShimmerLoader } from "@/components/prompt-kit/loader";
import { openHitlEditPanelAtom } from "@/atoms/chat/hitl-edit-panel.atom";
import type { ExtraField } from "@/atoms/chat/hitl-edit-panel.atom";
interface GoogleCalendarAccount {
id: number;
name: string;
auth_expired?: boolean;
}
interface CalendarEntry {
id: string;
summary: string;
primary?: boolean;
}
interface InterruptResult {
__interrupt__: true;
__decided__?: "approve" | "reject" | "edit";
action_requests: Array<{
name: string;
args: Record<string, unknown>;
}>;
review_configs: Array<{
action_name: string;
allowed_decisions: Array<"approve" | "edit" | "reject">;
}>;
context?: {
accounts?: GoogleCalendarAccount[];
calendars?: CalendarEntry[];
timezone?: string;
error?: string;
};
}
interface SuccessResult {
status: "success";
event_id: string;
html_link?: string;
message?: string;
}
interface ErrorResult {
status: "error";
message: string;
}
interface AuthErrorResult {
status: "auth_error";
message: string;
connector_type?: string;
}
type CreateCalendarEventResult =
| InterruptResult
| SuccessResult
| ErrorResult
| AuthErrorResult;
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 isAuthErrorResult(result: unknown): result is AuthErrorResult {
return (
typeof result === "object" &&
result !== null &&
"status" in result &&
(result as AuthErrorResult).status === "auth_error"
);
}
function formatDateTime(iso: string): string {
try {
return new Date(iso).toLocaleString(undefined, {
dateStyle: "medium",
timeStyle: "short",
});
} catch {
return iso;
}
}
function ApprovalCard({
args,
interruptData,
onDecision,
}: {
args: {
summary: string;
start_datetime: string;
end_datetime: string;
description?: string;
location?: string;
attendees?: string[];
};
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 [isPanelOpen, setIsPanelOpen] = useState(false);
const openHitlEditPanel = useSetAtom(openHitlEditPanelAtom);
const accounts = interruptData.context?.accounts ?? [];
const validAccounts = accounts.filter((a) => !a.auth_expired);
const expiredAccounts = accounts.filter((a) => a.auth_expired);
const calendars = interruptData.context?.calendars ?? [];
const timezone = interruptData.context?.timezone ?? "";
const defaultAccountId = useMemo(() => {
if (validAccounts.length === 1) return String(validAccounts[0].id);
return "";
}, [validAccounts]);
const defaultCalendarId = useMemo(() => {
const primary = calendars.find((c) => c.primary);
if (primary) return primary.id;
if (calendars.length === 1) return calendars[0].id;
return "";
}, [calendars]);
const [selectedAccountId, setSelectedAccountId] = useState<string>(defaultAccountId);
const [selectedCalendarId, setSelectedCalendarId] = useState<string>(defaultCalendarId);
const reviewConfig = interruptData.review_configs[0];
const allowedDecisions = reviewConfig?.allowed_decisions ?? ["approve", "reject"];
const canEdit = allowedDecisions.includes("edit");
const canApprove =
!!selectedAccountId &&
!!selectedCalendarId &&
!!args.summary?.trim();
const handleApprove = useCallback(() => {
if (decided || isPanelOpen || !canApprove) return;
if (!allowedDecisions.includes("approve")) return;
setDecided("approve");
onDecision({
type: "approve",
edited_action: {
name: interruptData.action_requests[0].name,
args: {
...args,
connector_id: selectedAccountId ? Number(selectedAccountId) : null,
calendar_id: selectedCalendarId || null,
},
},
});
}, [decided, isPanelOpen, canApprove, allowedDecisions, onDecision, interruptData, args, selectedAccountId, selectedCalendarId]);
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey && !e.ctrlKey && !e.metaKey) {
handleApprove();
}
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [handleApprove]);
if (decided && decided !== "reject") return null;
const attendeesList = (args.attendees as string[]) ?? [];
return (
<div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 transition-all duration-300">
{/* Header */}
<div className="flex items-start justify-between px-5 pt-5 pb-4 select-none">
<div className="flex items-center gap-2">
<CalendarPlusIcon className="size-4 text-muted-foreground shrink-0" />
<div>
<p className="text-sm font-semibold text-foreground">
{decided === "reject"
? "Calendar Event Rejected"
: decided === "approve" || decided === "edit"
? "Calendar Event Approved"
: "Create Calendar Event"}
</p>
<p className="text-xs text-muted-foreground mt-0.5">
{decided === "reject"
? "Event creation was cancelled"
: decided === "edit"
? "Event creation is in progress with your changes"
: decided === "approve"
? "Event creation is in progress"
: "Requires your approval to proceed"}
</p>
</div>
</div>
{!decided && canEdit && (
<Button
size="sm"
variant="ghost"
className="rounded-lg text-muted-foreground -mt-1 -mr-2"
onClick={() => {
setIsPanelOpen(true);
const extraFields: ExtraField[] = [
{ key: "start_datetime", label: "Start", type: "datetime-local", value: args.start_datetime || "" },
{ key: "end_datetime", label: "End", type: "datetime-local", value: args.end_datetime || "" },
{ key: "location", label: "Location", type: "text", value: args.location || "" },
{ key: "attendees", label: "Attendees (comma-separated emails)", type: "text", value: attendeesList.join(", ") },
];
openHitlEditPanel({
title: args.summary ?? "",
content: args.description ?? "",
toolName: "Calendar Event",
extraFields,
onSave: (newTitle, newContent, extraFieldValues) => {
setIsPanelOpen(false);
setDecided("edit");
const editedArgs: Record<string, unknown> = {
...args,
summary: newTitle,
description: newContent,
connector_id: selectedAccountId ? Number(selectedAccountId) : null,
calendar_id: selectedCalendarId || null,
};
if (extraFieldValues) {
if (extraFieldValues.start_datetime) editedArgs.start_datetime = extraFieldValues.start_datetime;
if (extraFieldValues.end_datetime) editedArgs.end_datetime = extraFieldValues.end_datetime;
if (extraFieldValues.location !== undefined) editedArgs.location = extraFieldValues.location;
if (extraFieldValues.attendees !== undefined) {
editedArgs.attendees = extraFieldValues.attendees
.split(",")
.map((e) => e.trim())
.filter(Boolean);
}
}
onDecision({
type: "edit",
edited_action: {
name: interruptData.action_requests[0].name,
args: editedArgs,
},
});
},
});
}}
>
<Pen className="size-3.5" />
Edit
</Button>
)}
</div>
{/* Context section */}
{!decided && interruptData.context && (
<>
<div className="mx-5 h-px bg-border/50" />
<div className="px-5 py-4 space-y-4 select-none">
{interruptData.context.error ? (
<p className="text-sm text-destructive">{interruptData.context.error}</p>
) : (
<>
{accounts.length > 0 && (
<div className="space-y-2">
<p className="text-xs font-medium text-muted-foreground">
Google Calendar Account <span className="text-destructive">*</span>
</p>
<Select value={selectedAccountId} onValueChange={setSelectedAccountId}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select an account" />
</SelectTrigger>
<SelectContent>
{validAccounts.map((account) => (
<SelectItem key={account.id} value={String(account.id)}>
{account.name}
</SelectItem>
))}
{expiredAccounts.map((a) => (
<div
key={a.id}
className="relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 px-2 text-sm select-none opacity-50 pointer-events-none"
>
{a.name} (expired, retry after re-auth)
</div>
))}
</SelectContent>
</Select>
</div>
)}
{calendars.length > 0 && (
<div className="space-y-2">
<p className="text-xs font-medium text-muted-foreground">
Calendar <span className="text-destructive">*</span>
</p>
<Select value={selectedCalendarId} onValueChange={setSelectedCalendarId}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select a calendar" />
</SelectTrigger>
<SelectContent>
{calendars.map((cal) => (
<SelectItem key={cal.id} value={cal.id}>
{cal.summary}{cal.primary ? " (primary)" : ""}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{timezone && (
<div className="space-y-2">
<p className="text-xs font-medium text-muted-foreground">Timezone</p>
<div className="flex items-center gap-2 w-full rounded-md border border-input bg-muted/50 px-3 py-2 text-sm">
<GlobeIcon className="size-3.5 text-muted-foreground shrink-0" />
{timezone}
</div>
</div>
)}
</>
)}
</div>
</>
)}
{/* Content preview */}
<div className="mx-5 h-px bg-border/50" />
<div className="px-5 pt-3 pb-3 space-y-2">
{args.summary && (
<p className="text-sm font-medium text-foreground">{args.summary}</p>
)}
{(args.start_datetime || args.end_datetime) && (
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<ClockIcon className="size-3.5 shrink-0" />
<span>
{args.start_datetime ? formatDateTime(args.start_datetime) : ""}
{args.start_datetime && args.end_datetime ? " — " : ""}
{args.end_datetime ? formatDateTime(args.end_datetime) : ""}
</span>
</div>
)}
{args.location && (
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<MapPinIcon className="size-3.5 shrink-0" />
<span>{args.location}</span>
</div>
)}
{attendeesList.length > 0 && (
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<UsersIcon className="size-3.5 shrink-0" />
<span>{attendeesList.join(", ")}</span>
</div>
)}
{args.description && (
<div
className="mt-2 max-h-[7rem] overflow-hidden text-sm"
style={{
maskImage: "linear-gradient(to bottom, black 50%, transparent 100%)",
WebkitMaskImage: "linear-gradient(to bottom, black 50%, transparent 100%)",
}}
>
<PlateEditor
markdown={String(args.description)}
readOnly
preset="readonly"
editorVariant="none"
className="h-auto [&_[data-slate-editor]]:!min-h-0 [&_[data-slate-editor]>*:first-child]:!mt-0"
/>
</div>
)}
</div>
{/* Action buttons */}
{!decided && (
<>
<div className="mx-5 h-px bg-border/50" />
<div className="px-5 py-4 flex items-center gap-2 select-none">
{allowedDecisions.includes("approve") && (
<Button
size="sm"
className="rounded-lg gap-1.5"
onClick={handleApprove}
disabled={!canApprove}
>
Approve
<CornerDownLeftIcon className="size-3 opacity-60" />
</Button>
)}
{allowedDecisions.includes("reject") && (
<Button
size="sm"
variant="ghost"
className="rounded-lg text-muted-foreground"
onClick={() => {
setDecided("reject");
onDecision({ type: "reject", message: "User rejected the action." });
}}
>
Reject
</Button>
)}
</div>
</>
)}
</div>
);
}
function ErrorCard({ result }: { result: ErrorResult }) {
return (
<div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 select-none">
<div className="px-5 pt-5 pb-4">
<p className="text-sm font-semibold text-destructive">Failed to create calendar event</p>
</div>
<div className="mx-5 h-px bg-border/50" />
<div className="px-5 py-4">
<p className="text-sm text-muted-foreground">{result.message}</p>
</div>
</div>
);
}
function AuthErrorCard({ result }: { result: AuthErrorResult }) {
return (
<div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 select-none">
<div className="px-5 pt-5 pb-4">
<p className="text-sm font-semibold text-destructive">
Google Calendar authentication expired
</p>
</div>
<div className="mx-5 h-px bg-border/50" />
<div className="px-5 py-4">
<p className="text-sm text-muted-foreground">{result.message}</p>
</div>
</div>
);
}
function SuccessCard({ result }: { result: SuccessResult }) {
return (
<div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 select-none">
<div className="px-5 pt-5 pb-4">
<p className="text-sm font-semibold text-foreground">
{result.message || "Calendar event created successfully"}
</p>
</div>
<div className="mx-5 h-px bg-border/50" />
<div className="px-5 py-4 space-y-2 text-xs">
{result.html_link && (
<div>
<a
href={result.html_link}
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
>
Open in Google Calendar
</a>
</div>
)}
</div>
</div>
);
}
export const CreateCalendarEventToolUI = makeAssistantToolUI<
{
summary: string;
start_datetime: string;
end_datetime: string;
description?: string;
location?: string;
attendees?: string[];
},
CreateCalendarEventResult
>({
toolName: "create_calendar_event",
render: function CreateCalendarEventUI({ args, result, status }) {
if (status.type === "running") {
return (
<div className="my-4 max-w-lg rounded-2xl border bg-muted/30 px-5 py-4 select-none">
<TextShimmerLoader text="Preparing calendar event..." size="sm" />
</div>
);
}
if (!result) return null;
if (isInterruptResult(result)) {
return (
<ApprovalCard
args={args}
interruptData={result}
onDecision={(decision) => {
window.dispatchEvent(
new CustomEvent("hitl-decision", { detail: { decisions: [decision] } })
);
}}
/>
);
}
if (
typeof result === "object" &&
result !== null &&
"status" in result &&
(result as { status: string }).status === "rejected"
) {
return null;
}
if (isAuthErrorResult(result)) return <AuthErrorCard result={result} />;
if (isErrorResult(result)) return <ErrorCard result={result} />;
return <SuccessCard result={result as SuccessResult} />;
},
});

View file

@ -0,0 +1,459 @@
"use client";
import { makeAssistantToolUI } from "@assistant-ui/react";
import {
CalendarX2Icon,
CalendarIcon,
ClockIcon,
MapPinIcon,
CornerDownLeftIcon,
TriangleAlertIcon,
} from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { TextShimmerLoader } from "@/components/prompt-kit/loader";
interface GoogleCalendarAccount {
id: number;
name: string;
auth_expired?: boolean;
}
interface CalendarEvent {
event_id: string;
summary: string;
start: string;
end: string;
description?: string;
location?: string;
attendees?: Array<{ email: string }>;
calendar_id: string;
document_id: number;
indexed_at?: string;
}
interface InterruptResult {
__interrupt__: true;
__decided__?: "approve" | "reject";
action_requests: Array<{
name: string;
args: Record<string, unknown>;
}>;
review_configs: Array<{
action_name: string;
allowed_decisions: Array<"approve" | "reject">;
}>;
context?: {
account?: GoogleCalendarAccount;
event?: CalendarEvent;
error?: string;
};
}
interface SuccessResult {
status: "success";
event_id: string;
message?: string;
deleted_from_kb?: boolean;
}
interface ErrorResult {
status: "error";
message: string;
}
interface NotFoundResult {
status: "not_found";
message: string;
}
interface WarningResult {
status: "success";
warning: string;
event_id?: string;
message?: string;
}
interface AuthErrorResult {
status: "auth_error";
message: string;
connector_type?: string;
}
type DeleteCalendarEventResult =
| InterruptResult
| SuccessResult
| ErrorResult
| NotFoundResult
| WarningResult
| AuthErrorResult;
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 isNotFoundResult(result: unknown): result is NotFoundResult {
return (
typeof result === "object" &&
result !== null &&
"status" in result &&
(result as NotFoundResult).status === "not_found"
);
}
function isAuthErrorResult(result: unknown): result is AuthErrorResult {
return (
typeof result === "object" &&
result !== null &&
"status" in result &&
(result as AuthErrorResult).status === "auth_error"
);
}
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 formatDateTime(iso: string): string {
try {
return new Date(iso).toLocaleString(undefined, {
dateStyle: "medium",
timeStyle: "short",
});
} catch {
return iso;
}
}
function ApprovalCard({
interruptData,
onDecision,
}: {
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 [deleteFromKb, setDeleteFromKb] = useState(false);
const context = interruptData.context;
const account = context?.account;
const event = context?.event;
const handleApprove = useCallback(() => {
if (decided) return;
setDecided("approve");
onDecision({
type: "approve",
edited_action: {
name: interruptData.action_requests[0].name,
args: {
event_id: event?.event_id,
connector_id: account?.id,
delete_from_kb: deleteFromKb,
},
},
});
}, [decided, onDecision, interruptData, event?.event_id, account?.id, deleteFromKb]);
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey && !e.ctrlKey && !e.metaKey) {
handleApprove();
}
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [handleApprove]);
if (decided && decided !== "reject") return null;
return (
<div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 transition-all duration-300">
{/* Header */}
<div className="flex items-start justify-between px-5 pt-5 pb-4 select-none">
<div className="flex items-center gap-2">
<CalendarX2Icon className="size-4 text-muted-foreground shrink-0" />
<div>
<p className="text-sm font-semibold text-foreground">
{decided === "reject"
? "Calendar Event Deletion Rejected"
: decided === "approve"
? "Calendar Event Deletion Approved"
: "Delete Calendar Event"}
</p>
<p className="text-xs text-muted-foreground mt-0.5">
{decided === "reject"
? "Event deletion was cancelled"
: decided === "approve"
? "Event deletion is in progress"
: "Requires your approval to proceed"}
</p>
</div>
</div>
</div>
{/* Context section */}
{!decided && context && (
<>
<div className="mx-5 h-px bg-border/50" />
<div className="px-5 py-4 space-y-4 select-none">
{context.error ? (
<p className="text-sm text-destructive">{context.error}</p>
) : (
<>
{account && (
<div className="space-y-2">
<p className="text-xs font-medium text-muted-foreground">Google Calendar Account</p>
<div className="w-full rounded-md border border-input bg-muted/50 px-3 py-2 text-sm">
{account.name}
</div>
</div>
)}
{event && (
<div className="space-y-2">
<p className="text-xs font-medium text-muted-foreground">Event to Delete</p>
<div className="w-full rounded-md border border-input bg-muted/50 px-3 py-2 text-sm space-y-1.5">
<div className="flex items-center gap-1.5">
<CalendarIcon className="size-3 shrink-0 text-muted-foreground" />
<span className="font-medium">{event.summary}</span>
</div>
{(event.start || event.end) && (
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<ClockIcon className="size-3 shrink-0" />
<span>
{event.start ? formatDateTime(event.start) : ""}
{event.start && event.end ? " — " : ""}
{event.end ? formatDateTime(event.end) : ""}
</span>
</div>
)}
{event.location && (
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<MapPinIcon className="size-3 shrink-0" />
<span>{event.location}</span>
</div>
)}
</div>
</div>
)}
</>
)}
</div>
</>
)}
{/* delete_from_kb toggle */}
{!decided && (
<>
<div className="mx-5 h-px bg-border/50" />
<div className="px-5 py-4 select-none">
<div className="flex items-center gap-2.5">
<Checkbox
id="calendar-delete-from-kb"
checked={deleteFromKb}
onCheckedChange={(v) => setDeleteFromKb(v === true)}
className="shrink-0"
/>
<label htmlFor="calendar-delete-from-kb" className="flex-1 cursor-pointer">
<span className="text-sm text-foreground">Also remove from knowledge base</span>
<p className="text-xs text-muted-foreground mt-0.5">
This will permanently delete the event from your knowledge base (cannot be undone)
</p>
</label>
</div>
</div>
</>
)}
{/* Action buttons */}
{!decided && (
<>
<div className="mx-5 h-px bg-border/50" />
<div className="px-5 py-4 flex items-center gap-2 select-none">
<Button
size="sm"
className="rounded-lg gap-1.5"
onClick={handleApprove}
>
Approve
<CornerDownLeftIcon className="size-3 opacity-60" />
</Button>
<Button
size="sm"
variant="ghost"
className="rounded-lg text-muted-foreground"
onClick={() => {
setDecided("reject");
onDecision({ type: "reject", message: "User rejected the action." });
}}
>
Reject
</Button>
</div>
</>
)}
</div>
);
}
function ErrorCard({ result }: { result: ErrorResult }) {
return (
<div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 select-none">
<div className="px-5 pt-5 pb-4">
<p className="text-sm font-semibold text-destructive">Failed to delete calendar event</p>
</div>
<div className="mx-5 h-px bg-border/50" />
<div className="px-5 py-4">
<p className="text-sm text-muted-foreground">{result.message}</p>
</div>
</div>
);
}
function AuthErrorCard({ result }: { result: AuthErrorResult }) {
return (
<div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 select-none">
<div className="px-5 pt-5 pb-4">
<p className="text-sm font-semibold text-destructive">
Google Calendar authentication expired
</p>
</div>
<div className="mx-5 h-px bg-border/50" />
<div className="px-5 py-4">
<p className="text-sm text-muted-foreground">{result.message}</p>
</div>
</div>
);
}
function NotFoundCard({ result }: { result: NotFoundResult }) {
return (
<div className="my-4 max-w-lg overflow-hidden rounded-2xl border border-amber-500/50 bg-muted/30 select-none">
<div className="px-5 pt-5 pb-4">
<div className="flex items-center gap-2">
<TriangleAlertIcon className="size-4 text-amber-500 shrink-0" />
<p className="text-sm font-semibold text-amber-600 dark:text-amber-400">
Event not found
</p>
</div>
</div>
<div className="mx-5 h-px bg-amber-500/30" />
<div className="px-5 py-4">
<p className="text-sm text-muted-foreground">{result.message}</p>
</div>
</div>
);
}
function WarningCard({ result }: { result: WarningResult }) {
return (
<div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 select-none">
<div className="flex items-start gap-3 border-b px-5 py-4">
<TriangleAlertIcon className="size-4 mt-0.5 shrink-0 text-amber-500" />
<p className="text-sm font-medium text-amber-600 dark:text-amber-500">Partial success</p>
</div>
<div className="px-5 py-4 space-y-2 text-xs">
<p className="text-sm text-muted-foreground">{result.warning}</p>
</div>
</div>
);
}
function SuccessCard({ result }: { result: SuccessResult }) {
return (
<div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 select-none">
<div className="px-5 pt-5 pb-4">
<p className="text-sm font-semibold text-foreground">
{result.message || "Calendar event deleted successfully"}
</p>
</div>
{result.deleted_from_kb && (
<>
<div className="mx-5 h-px bg-border/50" />
<div className="px-5 py-4 text-xs">
<span className="text-green-600 dark:text-green-500">
Also removed from knowledge base
</span>
</div>
</>
)}
</div>
);
}
export const DeleteCalendarEventToolUI = makeAssistantToolUI<
{ event_ref: string; delete_from_kb?: boolean },
DeleteCalendarEventResult
>({
toolName: "delete_calendar_event",
render: function DeleteCalendarEventUI({ result, status }) {
if (status.type === "running") {
return (
<div className="my-4 max-w-lg rounded-2xl border bg-muted/30 px-5 py-4 select-none">
<TextShimmerLoader text="Deleting calendar event..." size="sm" />
</div>
);
}
if (!result) return null;
if (isInterruptResult(result)) {
return (
<ApprovalCard
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 (isNotFoundResult(result)) return <NotFoundCard result={result} />;
if (isWarningResult(result)) return <WarningCard result={result} />;
if (isAuthErrorResult(result)) return <AuthErrorCard result={result} />;
if (isErrorResult(result)) return <ErrorCard result={result} />;
return <SuccessCard result={result as SuccessResult} />;
},
});

View file

@ -0,0 +1,3 @@
export { CreateCalendarEventToolUI } from "./create-event";
export { UpdateCalendarEventToolUI } from "./update-event";
export { DeleteCalendarEventToolUI } from "./delete-event";

View file

@ -0,0 +1,602 @@
"use client";
import { makeAssistantToolUI } from "@assistant-ui/react";
import {
CalendarIcon,
ClockIcon,
MapPinIcon,
UsersIcon,
ArrowRightIcon,
CornerDownLeftIcon,
Pen,
TriangleAlertIcon,
} from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { useSetAtom } from "jotai";
import { Button } from "@/components/ui/button";
import { PlateEditor } from "@/components/editor/plate-editor";
import { TextShimmerLoader } from "@/components/prompt-kit/loader";
import { openHitlEditPanelAtom } from "@/atoms/chat/hitl-edit-panel.atom";
import type { ExtraField } from "@/atoms/chat/hitl-edit-panel.atom";
interface GoogleCalendarAccount {
id: number;
name: string;
auth_expired?: boolean;
}
interface CalendarEvent {
event_id: string;
summary: string;
start: string;
end: string;
description?: string;
location?: string;
attendees?: Array<{ email: string }>;
calendar_id: string;
document_id: number;
indexed_at?: string;
}
interface InterruptResult {
__interrupt__: true;
__decided__?: "approve" | "reject" | "edit";
action_requests: Array<{
name: string;
args: Record<string, unknown>;
}>;
review_configs: Array<{
action_name: string;
allowed_decisions: Array<"approve" | "edit" | "reject">;
}>;
context?: {
account?: GoogleCalendarAccount;
event?: CalendarEvent;
error?: string;
};
}
interface SuccessResult {
status: "success";
event_id: string;
html_link?: string;
message?: string;
}
interface ErrorResult {
status: "error";
message: string;
}
interface NotFoundResult {
status: "not_found";
message: string;
}
interface AuthErrorResult {
status: "auth_error";
message: string;
connector_type?: string;
}
type UpdateCalendarEventResult =
| InterruptResult
| SuccessResult
| ErrorResult
| NotFoundResult
| AuthErrorResult;
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 isNotFoundResult(result: unknown): result is NotFoundResult {
return (
typeof result === "object" &&
result !== null &&
"status" in result &&
(result as NotFoundResult).status === "not_found"
);
}
function isAuthErrorResult(result: unknown): result is AuthErrorResult {
return (
typeof result === "object" &&
result !== null &&
"status" in result &&
(result as AuthErrorResult).status === "auth_error"
);
}
function formatDateTime(iso: string): string {
try {
return new Date(iso).toLocaleString(undefined, {
dateStyle: "medium",
timeStyle: "short",
});
} catch {
return iso;
}
}
function ApprovalCard({
interruptData,
onDecision,
}: {
interruptData: InterruptResult;
onDecision: (decision: {
type: "approve" | "reject" | "edit";
message?: string;
edited_action?: { name: string; args: Record<string, unknown> };
}) => void;
}) {
const actionArgs = interruptData.action_requests[0]?.args ?? {};
const context = interruptData.context;
const account = context?.account;
const event = context?.event;
const [decided, setDecided] = useState<"approve" | "reject" | "edit" | null>(
interruptData.__decided__ ?? null
);
const [isPanelOpen, setIsPanelOpen] = useState(false);
const openHitlEditPanel = useSetAtom(openHitlEditPanelAtom);
const reviewConfig = interruptData.review_configs[0];
const allowedDecisions = reviewConfig?.allowed_decisions ?? ["approve", "reject"];
const canEdit = allowedDecisions.includes("edit");
const currentAttendees = event?.attendees?.map((a) => a.email) ?? [];
const proposedAttendees = Array.isArray(actionArgs.new_attendees)
? (actionArgs.new_attendees as string[])
: null;
const changes: Array<{ label: string; oldVal: string; newVal: string }> = [];
if (actionArgs.new_summary && String(actionArgs.new_summary) !== event?.summary) {
changes.push({ label: "Summary", oldVal: event?.summary ?? "", newVal: String(actionArgs.new_summary) });
}
if (actionArgs.new_start_datetime && String(actionArgs.new_start_datetime) !== event?.start) {
changes.push({
label: "Start",
oldVal: event?.start ? formatDateTime(event.start) : "",
newVal: formatDateTime(String(actionArgs.new_start_datetime)),
});
}
if (actionArgs.new_end_datetime && String(actionArgs.new_end_datetime) !== event?.end) {
changes.push({
label: "End",
oldVal: event?.end ? formatDateTime(event.end) : "",
newVal: formatDateTime(String(actionArgs.new_end_datetime)),
});
}
if (actionArgs.new_location !== undefined && String(actionArgs.new_location ?? "") !== (event?.location ?? "")) {
changes.push({ label: "Location", oldVal: event?.location ?? "", newVal: String(actionArgs.new_location ?? "") });
}
if (proposedAttendees) {
const oldStr = currentAttendees.join(", ");
const newStr = proposedAttendees.join(", ");
if (oldStr !== newStr) {
changes.push({ label: "Attendees", oldVal: oldStr, newVal: newStr });
}
}
const hasDescriptionChange =
actionArgs.new_description !== undefined &&
String(actionArgs.new_description ?? "") !== (event?.description ?? "");
const buildFinalArgs = useCallback(() => {
return {
event_id: event?.event_id,
document_id: event?.document_id,
connector_id: account?.id,
new_summary: actionArgs.new_summary ?? null,
new_description: actionArgs.new_description ?? null,
new_start_datetime: actionArgs.new_start_datetime ?? null,
new_end_datetime: actionArgs.new_end_datetime ?? null,
new_location: actionArgs.new_location ?? null,
new_attendees: proposedAttendees ?? null,
};
}, [event, account, actionArgs, proposedAttendees]);
const handleApprove = useCallback(() => {
if (decided || isPanelOpen) return;
if (!allowedDecisions.includes("approve")) return;
setDecided("approve");
onDecision({
type: "approve",
edited_action: {
name: interruptData.action_requests[0].name,
args: buildFinalArgs(),
},
});
}, [decided, isPanelOpen, allowedDecisions, onDecision, interruptData, buildFinalArgs]);
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey && !e.ctrlKey && !e.metaKey) {
handleApprove();
}
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [handleApprove]);
if (decided && decided !== "reject") return null;
return (
<div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 transition-all duration-300">
{/* Header */}
<div className="flex items-start justify-between px-5 pt-5 pb-4 select-none">
<div className="flex items-center gap-2">
<CalendarIcon className="size-4 text-muted-foreground shrink-0" />
<div>
<p className="text-sm font-semibold text-foreground">
{decided === "reject"
? "Calendar Event Update Rejected"
: decided === "approve" || decided === "edit"
? "Calendar Event Update Approved"
: "Update Calendar Event"}
</p>
<p className="text-xs text-muted-foreground mt-0.5">
{decided === "reject"
? "Event update was cancelled"
: decided === "edit"
? "Event update is in progress with your changes"
: decided === "approve"
? "Event update is in progress"
: "Requires your approval to proceed"}
</p>
</div>
</div>
{!decided && canEdit && (
<Button
size="sm"
variant="ghost"
className="rounded-lg text-muted-foreground -mt-1 -mr-2"
onClick={() => {
setIsPanelOpen(true);
const proposedSummary = actionArgs.new_summary
? String(actionArgs.new_summary)
: (event?.summary ?? "");
const proposedDescription = actionArgs.new_description
? String(actionArgs.new_description)
: (event?.description ?? "");
const proposedStart = actionArgs.new_start_datetime
? String(actionArgs.new_start_datetime)
: (event?.start ?? "");
const proposedEnd = actionArgs.new_end_datetime
? String(actionArgs.new_end_datetime)
: (event?.end ?? "");
const proposedLocation = actionArgs.new_location !== undefined
? String(actionArgs.new_location ?? "")
: (event?.location ?? "");
const proposedAttendeesStr = proposedAttendees
? proposedAttendees.join(", ")
: currentAttendees.join(", ");
const extraFields: ExtraField[] = [
{ key: "start_datetime", label: "Start", type: "datetime-local", value: proposedStart },
{ key: "end_datetime", label: "End", type: "datetime-local", value: proposedEnd },
{ key: "location", label: "Location", type: "text", value: proposedLocation },
{ key: "attendees", label: "Attendees (comma-separated emails)", type: "text", value: proposedAttendeesStr },
];
openHitlEditPanel({
title: proposedSummary,
content: proposedDescription,
toolName: "Calendar Event",
extraFields,
onSave: (newTitle, newContent, extraFieldValues) => {
setIsPanelOpen(false);
setDecided("edit");
const editedArgs: Record<string, unknown> = {
event_id: event?.event_id,
document_id: event?.document_id,
connector_id: account?.id,
new_summary: newTitle || null,
new_description: newContent || null,
};
if (extraFieldValues) {
editedArgs.new_start_datetime = extraFieldValues.start_datetime || null;
editedArgs.new_end_datetime = extraFieldValues.end_datetime || null;
editedArgs.new_location = extraFieldValues.location || null;
if (extraFieldValues.attendees !== undefined) {
editedArgs.new_attendees = extraFieldValues.attendees
.split(",")
.map((e) => e.trim())
.filter(Boolean);
}
}
onDecision({
type: "edit",
edited_action: {
name: interruptData.action_requests[0].name,
args: editedArgs,
},
});
},
});
}}
>
<Pen className="size-3.5" />
Edit
</Button>
)}
</div>
{/* Context section */}
{!decided && (
<>
<div className="mx-5 h-px bg-border/50" />
<div className="px-5 py-4 space-y-4 select-none">
{context?.error ? (
<p className="text-sm text-destructive">{context.error}</p>
) : (
<>
{account && (
<div className="space-y-2">
<p className="text-xs font-medium text-muted-foreground">Google Calendar Account</p>
<div className="w-full rounded-md border border-input bg-muted/50 px-3 py-2 text-sm">
{account.name}
</div>
</div>
)}
{event && (
<div className="space-y-2">
<p className="text-xs font-medium text-muted-foreground">Current Event</p>
<div className="w-full rounded-md border border-input bg-muted/50 px-3 py-2 text-sm space-y-1.5">
<div className="font-medium">{event.summary}</div>
{(event.start || event.end) && (
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<ClockIcon className="size-3 shrink-0" />
<span>
{event.start ? formatDateTime(event.start) : ""}
{event.start && event.end ? " — " : ""}
{event.end ? formatDateTime(event.end) : ""}
</span>
</div>
)}
{event.location && (
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<MapPinIcon className="size-3 shrink-0" />
<span>{event.location}</span>
</div>
)}
{currentAttendees.length > 0 && (
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<UsersIcon className="size-3 shrink-0" />
<span>{currentAttendees.join(", ")}</span>
</div>
)}
</div>
</div>
)}
{(changes.length > 0 || hasDescriptionChange) && (
<div className="space-y-2">
<p className="text-xs font-medium text-muted-foreground">Proposed Changes</p>
<div className="space-y-2">
{changes.map((change) => (
<div key={change.label} className="text-xs space-y-0.5">
<span className="text-muted-foreground">{change.label}</span>
<div className="flex items-center gap-1.5 flex-wrap">
<span className="text-muted-foreground line-through">{change.oldVal || "(empty)"}</span>
<ArrowRightIcon className="size-3 text-muted-foreground shrink-0" />
<span className="font-medium text-foreground">{change.newVal || "(empty)"}</span>
</div>
</div>
))}
{hasDescriptionChange && (
<div className="text-xs space-y-0.5">
<span className="text-muted-foreground">Description</span>
<div
className="mt-1 max-h-[5rem] overflow-hidden"
style={{
maskImage: "linear-gradient(to bottom, black 50%, transparent 100%)",
WebkitMaskImage: "linear-gradient(to bottom, black 50%, transparent 100%)",
}}
>
<PlateEditor
markdown={String(actionArgs.new_description ?? "")}
readOnly
preset="readonly"
editorVariant="none"
className="h-auto [&_[data-slate-editor]]:!min-h-0 [&_[data-slate-editor]>*:first-child]:!mt-0"
/>
</div>
</div>
)}
</div>
</div>
)}
{changes.length === 0 && !hasDescriptionChange && (
<p className="text-sm text-muted-foreground italic">No changes proposed</p>
)}
</>
)}
</div>
</>
)}
{/* Action buttons */}
{!decided && (
<>
<div className="mx-5 h-px bg-border/50" />
<div className="px-5 py-4 flex items-center gap-2 select-none">
{allowedDecisions.includes("approve") && (
<Button
size="sm"
className="rounded-lg gap-1.5"
onClick={handleApprove}
>
Approve
<CornerDownLeftIcon className="size-3 opacity-60" />
</Button>
)}
{allowedDecisions.includes("reject") && (
<Button
size="sm"
variant="ghost"
className="rounded-lg text-muted-foreground"
onClick={() => {
setDecided("reject");
onDecision({ type: "reject", message: "User rejected the action." });
}}
>
Reject
</Button>
)}
</div>
</>
)}
</div>
);
}
function ErrorCard({ result }: { result: ErrorResult }) {
return (
<div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 select-none">
<div className="px-5 pt-5 pb-4">
<p className="text-sm font-semibold text-destructive">Failed to update calendar event</p>
</div>
<div className="mx-5 h-px bg-border/50" />
<div className="px-5 py-4">
<p className="text-sm text-muted-foreground">{result.message}</p>
</div>
</div>
);
}
function AuthErrorCard({ result }: { result: AuthErrorResult }) {
return (
<div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 select-none">
<div className="px-5 pt-5 pb-4">
<p className="text-sm font-semibold text-destructive">
Google Calendar authentication expired
</p>
</div>
<div className="mx-5 h-px bg-border/50" />
<div className="px-5 py-4">
<p className="text-sm text-muted-foreground">{result.message}</p>
</div>
</div>
);
}
function NotFoundCard({ result }: { result: NotFoundResult }) {
return (
<div className="my-4 max-w-lg overflow-hidden rounded-2xl border border-amber-500/50 bg-muted/30 select-none">
<div className="px-5 pt-5 pb-4">
<div className="flex items-center gap-2">
<TriangleAlertIcon className="size-4 text-amber-500 shrink-0" />
<p className="text-sm font-semibold text-amber-600 dark:text-amber-400">
Event not found
</p>
</div>
</div>
<div className="mx-5 h-px bg-amber-500/30" />
<div className="px-5 py-4">
<p className="text-sm text-muted-foreground">{result.message}</p>
</div>
</div>
);
}
function SuccessCard({ result }: { result: SuccessResult }) {
return (
<div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 select-none">
<div className="px-5 pt-5 pb-4">
<p className="text-sm font-semibold text-foreground">
{result.message || "Calendar event updated successfully"}
</p>
</div>
<div className="mx-5 h-px bg-border/50" />
<div className="px-5 py-4 space-y-2 text-xs">
{result.html_link && (
<div>
<a
href={result.html_link}
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
>
Open in Google Calendar
</a>
</div>
)}
</div>
</div>
);
}
export const UpdateCalendarEventToolUI = makeAssistantToolUI<
{
event_ref: string;
new_summary?: string;
new_description?: string;
new_start_datetime?: string;
new_end_datetime?: string;
new_location?: string;
new_attendees?: string[];
},
UpdateCalendarEventResult
>({
toolName: "update_calendar_event",
render: function UpdateCalendarEventUI({ result, status }) {
if (status.type === "running") {
return (
<div className="my-4 max-w-lg rounded-2xl border bg-muted/30 px-5 py-4 select-none">
<TextShimmerLoader text="Looking up calendar event..." size="sm" />
</div>
);
}
if (!result) return null;
if (isInterruptResult(result)) {
return (
<ApprovalCard
interruptData={result}
onDecision={(decision) => {
window.dispatchEvent(
new CustomEvent("hitl-decision", { detail: { decisions: [decision] } })
);
}}
/>
);
}
if (
typeof result === "object" &&
result !== null &&
"status" in result &&
(result as { status: string }).status === "rejected"
) {
return null;
}
if (isNotFoundResult(result)) return <NotFoundCard result={result} />;
if (isAuthErrorResult(result)) return <AuthErrorCard result={result} />;
if (isErrorResult(result)) return <ErrorCard result={result} />;
return <SuccessCard result={result as SuccessResult} />;
},
});