Merge pull request #1219 from AnishSarkar22/fix/sensitive-actions

refactor: Unified sensitive actions using HITL & many UI/UX changes
This commit is contained in:
Rohan Verma 2026-04-13 20:33:23 -07:00 committed by GitHub
commit e1e4bb4706
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
86 changed files with 2387 additions and 3144 deletions

View file

@ -472,7 +472,7 @@ async def create_surfsense_deep_agent(
SubAgentMiddleware(backend=StateBackend, subagents=[general_purpose_spec]), SubAgentMiddleware(backend=StateBackend, subagents=[general_purpose_spec]),
create_summarization_middleware(llm, StateBackend), create_summarization_middleware(llm, StateBackend),
PatchToolCallsMiddleware(), PatchToolCallsMiddleware(),
DedupHITLToolCallsMiddleware(), DedupHITLToolCallsMiddleware(agent_tools=tools),
AnthropicPromptCachingMiddleware(unsupported_model_behavior="ignore"), AnthropicPromptCachingMiddleware(unsupported_model_behavior="ignore"),
] ]

View file

@ -20,19 +20,39 @@ from langgraph.runtime import Runtime
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
_HITL_TOOL_DEDUP_KEYS: dict[str, str] = { _NATIVE_HITL_TOOL_DEDUP_KEYS: dict[str, str] = {
"delete_calendar_event": "event_title_or_id", # Gmail
"update_calendar_event": "event_title_or_id", "send_gmail_email": "subject",
"trash_gmail_email": "email_subject_or_id", "create_gmail_draft": "subject",
"update_gmail_draft": "draft_subject_or_id", "update_gmail_draft": "draft_subject_or_id",
"trash_gmail_email": "email_subject_or_id",
# Google Calendar
"create_calendar_event": "title",
"update_calendar_event": "event_title_or_id",
"delete_calendar_event": "event_title_or_id",
# Google Drive
"create_google_drive_file": "file_name",
"delete_google_drive_file": "file_name", "delete_google_drive_file": "file_name",
# OneDrive
"create_onedrive_file": "file_name",
"delete_onedrive_file": "file_name", "delete_onedrive_file": "file_name",
"delete_notion_page": "page_title", # Dropbox
"create_dropbox_file": "file_name",
"delete_dropbox_file": "file_name",
# Notion
"create_notion_page": "title",
"update_notion_page": "page_title", "update_notion_page": "page_title",
"delete_linear_issue": "issue_ref", "delete_notion_page": "page_title",
# Linear
"create_linear_issue": "title",
"update_linear_issue": "issue_ref", "update_linear_issue": "issue_ref",
"delete_linear_issue": "issue_ref",
# Jira
"create_jira_issue": "summary",
"update_jira_issue": "issue_title_or_key", "update_jira_issue": "issue_title_or_key",
"delete_jira_issue": "issue_title_or_key", "delete_jira_issue": "issue_title_or_key",
# Confluence
"create_confluence_page": "title",
"update_confluence_page": "page_title_or_id", "update_confluence_page": "page_title_or_id",
"delete_confluence_page": "page_title_or_id", "delete_confluence_page": "page_title_or_id",
} }
@ -43,22 +63,38 @@ class DedupHITLToolCallsMiddleware(AgentMiddleware): # type: ignore[type-arg]
Only the **first** occurrence of each (tool-name, primary-arg-value) Only the **first** occurrence of each (tool-name, primary-arg-value)
pair is kept; subsequent duplicates are silently dropped. pair is kept; subsequent duplicates are silently dropped.
The dedup map is built from two sources:
1. A comprehensive list of native HITL tools (hardcoded above).
2. Any ``StructuredTool`` instances passed via *agent_tools* whose
``metadata`` contains ``{"hitl": True, "hitl_dedup_key": "..."}``.
This is how MCP tools automatically get dedup support.
""" """
tools = () tools = ()
def __init__(self, *, agent_tools: list[Any] | None = None) -> None:
self._dedup_keys: dict[str, str] = dict(_NATIVE_HITL_TOOL_DEDUP_KEYS)
for t in agent_tools or []:
meta = getattr(t, "metadata", None) or {}
if meta.get("hitl") and meta.get("hitl_dedup_key"):
self._dedup_keys[t.name] = meta["hitl_dedup_key"]
def after_model( def after_model(
self, state: AgentState, runtime: Runtime[Any] self, state: AgentState, runtime: Runtime[Any]
) -> dict[str, Any] | None: ) -> dict[str, Any] | None:
return self._dedup(state) return self._dedup(state, self._dedup_keys)
async def aafter_model( async def aafter_model(
self, state: AgentState, runtime: Runtime[Any] self, state: AgentState, runtime: Runtime[Any]
) -> dict[str, Any] | None: ) -> dict[str, Any] | None:
return self._dedup(state) return self._dedup(state, self._dedup_keys)
@staticmethod @staticmethod
def _dedup(state: AgentState) -> dict[str, Any] | None: # type: ignore[type-arg] def _dedup(
state: AgentState, dedup_keys: dict[str, str] # type: ignore[type-arg]
) -> dict[str, Any] | None:
messages = state.get("messages") messages = state.get("messages")
if not messages: if not messages:
return None return None
@ -73,7 +109,7 @@ class DedupHITLToolCallsMiddleware(AgentMiddleware): # type: ignore[type-arg]
for tc in tool_calls: for tc in tool_calls:
name = tc.get("name", "") name = tc.get("name", "")
dedup_key_arg = _HITL_TOOL_DEDUP_KEYS.get(name) dedup_key_arg = dedup_keys.get(name)
if dedup_key_arg is not None: if dedup_key_arg is not None:
arg_val = str(tc.get("args", {}).get(dedup_key_arg, "")).lower() arg_val = str(tc.get("args", {}).get(dedup_key_arg, "")).lower()
key = (name, arg_val) key = (name, arg_val)

View file

@ -2,7 +2,7 @@ import logging
from typing import Any from typing import Any
from langchain_core.tools import tool from langchain_core.tools import tool
from langgraph.types import interrupt from app.agents.new_chat.tools.hitl import request_approval
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm.attributes import flag_modified from sqlalchemy.orm.attributes import flag_modified
@ -65,54 +65,28 @@ def create_create_confluence_page_tool(
"connector_type": "confluence", "connector_type": "confluence",
} }
approval = interrupt( result = request_approval(
{ action_type="confluence_page_creation",
"type": "confluence_page_creation", tool_name="create_confluence_page",
"action": { params={
"tool": "create_confluence_page",
"params": {
"title": title, "title": title,
"content": content, "content": content,
"space_id": space_id, "space_id": space_id,
"connector_id": connector_id, "connector_id": connector_id,
}, },
}, context=context,
"context": context,
}
) )
decisions_raw = ( if result.rejected:
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:
return {"status": "error", "message": "No approval decision received"}
decision = decisions[0]
decision_type = decision.get("type") or decision.get("decision_type")
if decision_type == "reject":
return { return {
"status": "rejected", "status": "rejected",
"message": "User declined. The page was not created.", "message": "User declined. Do not retry or suggest alternatives.",
} }
final_params: dict[str, Any] = {} final_title = result.params.get("title", title)
edited_action = decision.get("edited_action") final_content = result.params.get("content", content) or ""
if isinstance(edited_action, dict): final_space_id = result.params.get("space_id", space_id)
edited_args = edited_action.get("args") final_connector_id = result.params.get("connector_id", connector_id)
if isinstance(edited_args, dict):
final_params = edited_args
elif isinstance(decision.get("args"), dict):
final_params = decision["args"]
final_title = final_params.get("title", title)
final_content = final_params.get("content", content) or ""
final_space_id = final_params.get("space_id", space_id)
final_connector_id = final_params.get("connector_id", connector_id)
if not final_title or not final_title.strip(): if not final_title or not final_title.strip():
return {"status": "error", "message": "Page title cannot be empty."} return {"status": "error", "message": "Page title cannot be empty."}

View file

@ -2,7 +2,7 @@ import logging
from typing import Any from typing import Any
from langchain_core.tools import tool from langchain_core.tools import tool
from langgraph.types import interrupt from app.agents.new_chat.tools.hitl import request_approval
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm.attributes import flag_modified from sqlalchemy.orm.attributes import flag_modified
@ -74,54 +74,28 @@ def create_delete_confluence_page_tool(
document_id = page_data["document_id"] document_id = page_data["document_id"]
connector_id_from_context = context.get("account", {}).get("id") connector_id_from_context = context.get("account", {}).get("id")
approval = interrupt( result = request_approval(
{ action_type="confluence_page_deletion",
"type": "confluence_page_deletion", tool_name="delete_confluence_page",
"action": { params={
"tool": "delete_confluence_page",
"params": {
"page_id": page_id, "page_id": page_id,
"connector_id": connector_id_from_context, "connector_id": connector_id_from_context,
"delete_from_kb": delete_from_kb, "delete_from_kb": delete_from_kb,
}, },
}, context=context,
"context": context,
}
) )
decisions_raw = ( if result.rejected:
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:
return {"status": "error", "message": "No approval decision received"}
decision = decisions[0]
decision_type = decision.get("type") or decision.get("decision_type")
if decision_type == "reject":
return { return {
"status": "rejected", "status": "rejected",
"message": "User declined. The page was not deleted.", "message": "User declined. Do not retry or suggest alternatives.",
} }
final_params: dict[str, Any] = {} final_page_id = result.params.get("page_id", page_id)
edited_action = decision.get("edited_action") final_connector_id = result.params.get(
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_page_id = final_params.get("page_id", page_id)
final_connector_id = final_params.get(
"connector_id", connector_id_from_context "connector_id", connector_id_from_context
) )
final_delete_from_kb = final_params.get("delete_from_kb", delete_from_kb) final_delete_from_kb = result.params.get("delete_from_kb", delete_from_kb)
from sqlalchemy.future import select from sqlalchemy.future import select

View file

@ -2,7 +2,7 @@ import logging
from typing import Any from typing import Any
from langchain_core.tools import tool from langchain_core.tools import tool
from langgraph.types import interrupt from app.agents.new_chat.tools.hitl import request_approval
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm.attributes import flag_modified from sqlalchemy.orm.attributes import flag_modified
@ -78,12 +78,10 @@ def create_update_confluence_page_tool(
document_id = page_data.get("document_id") document_id = page_data.get("document_id")
connector_id_from_context = context.get("account", {}).get("id") connector_id_from_context = context.get("account", {}).get("id")
approval = interrupt( result = request_approval(
{ action_type="confluence_page_update",
"type": "confluence_page_update", tool_name="update_confluence_page",
"action": { params={
"tool": "update_confluence_page",
"params": {
"page_id": page_id, "page_id": page_id,
"document_id": document_id, "document_id": document_id,
"new_title": new_title, "new_title": new_title,
@ -91,49 +89,25 @@ def create_update_confluence_page_tool(
"version": current_version, "version": current_version,
"connector_id": connector_id_from_context, "connector_id": connector_id_from_context,
}, },
}, context=context,
"context": context,
}
) )
decisions_raw = ( if result.rejected:
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:
return {"status": "error", "message": "No approval decision received"}
decision = decisions[0]
decision_type = decision.get("type") or decision.get("decision_type")
if decision_type == "reject":
return { return {
"status": "rejected", "status": "rejected",
"message": "User declined. The page was not updated.", "message": "User declined. Do not retry or suggest alternatives.",
} }
final_params: dict[str, Any] = {} final_page_id = result.params.get("page_id", page_id)
edited_action = decision.get("edited_action") final_title = result.params.get("new_title", new_title) or current_title
if isinstance(edited_action, dict): final_content = result.params.get("new_content", new_content)
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_page_id = final_params.get("page_id", page_id)
final_title = final_params.get("new_title", new_title) or current_title
final_content = final_params.get("new_content", new_content)
if final_content is None: if final_content is None:
final_content = current_body final_content = current_body
final_version = final_params.get("version", current_version) final_version = result.params.get("version", current_version)
final_connector_id = final_params.get( final_connector_id = result.params.get(
"connector_id", connector_id_from_context "connector_id", connector_id_from_context
) )
final_document_id = final_params.get("document_id", document_id) final_document_id = result.params.get("document_id", document_id)
from sqlalchemy.future import select from sqlalchemy.future import select

View file

@ -5,7 +5,7 @@ from pathlib import Path
from typing import Any, Literal from typing import Any, Literal
from langchain_core.tools import tool from langchain_core.tools import tool
from langgraph.types import interrupt from app.agents.new_chat.tools.hitl import request_approval
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select from sqlalchemy.future import select
@ -159,56 +159,30 @@ def create_create_dropbox_file_tool(
"supported_types": _SUPPORTED_TYPES, "supported_types": _SUPPORTED_TYPES,
} }
approval = interrupt( result = request_approval(
{ action_type="dropbox_file_creation",
"type": "dropbox_file_creation", tool_name="create_dropbox_file",
"action": { params={
"tool": "create_dropbox_file",
"params": {
"name": name, "name": name,
"file_type": file_type, "file_type": file_type,
"content": content, "content": content,
"connector_id": None, "connector_id": None,
"parent_folder_path": None, "parent_folder_path": None,
}, },
}, context=context,
"context": context,
}
) )
decisions_raw = ( if result.rejected:
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:
return {"status": "error", "message": "No approval decision received"}
decision = decisions[0]
decision_type = decision.get("type") or decision.get("decision_type")
if decision_type == "reject":
return { return {
"status": "rejected", "status": "rejected",
"message": "User declined. The file was not created.", "message": "User declined. Do not retry or suggest alternatives.",
} }
final_params: dict[str, Any] = {} final_name = result.params.get("name", name)
edited_action = decision.get("edited_action") final_file_type = result.params.get("file_type", file_type)
if isinstance(edited_action, dict): final_content = result.params.get("content", content)
edited_args = edited_action.get("args") final_connector_id = result.params.get("connector_id")
if isinstance(edited_args, dict): final_parent_folder_path = result.params.get("parent_folder_path")
final_params = edited_args
elif isinstance(decision.get("args"), dict):
final_params = decision["args"]
final_name = final_params.get("name", name)
final_file_type = final_params.get("file_type", file_type)
final_content = final_params.get("content", content)
final_connector_id = final_params.get("connector_id")
final_parent_folder_path = final_params.get("parent_folder_path")
if not final_name or not final_name.strip(): if not final_name or not final_name.strip():
return {"status": "error", "message": "File name cannot be empty."} return {"status": "error", "message": "File name cannot be empty."}

View file

@ -2,7 +2,7 @@ import logging
from typing import Any from typing import Any
from langchain_core.tools import tool from langchain_core.tools import tool
from langgraph.types import interrupt from app.agents.new_chat.tools.hitl import request_approval
from sqlalchemy import String, and_, cast, func from sqlalchemy import String, and_, cast, func
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select from sqlalchemy.future import select
@ -174,53 +174,26 @@ def create_delete_dropbox_file_tool(
}, },
} }
approval = interrupt( result = request_approval(
{ action_type="dropbox_file_trash",
"type": "dropbox_file_trash", tool_name="delete_dropbox_file",
"action": { params={
"tool": "delete_dropbox_file",
"params": {
"file_path": file_path, "file_path": file_path,
"connector_id": connector.id, "connector_id": connector.id,
"delete_from_kb": delete_from_kb, "delete_from_kb": delete_from_kb,
}, },
}, context=context,
"context": context,
}
) )
decisions_raw = ( if result.rejected:
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:
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 { return {
"status": "rejected", "status": "rejected",
"message": "User declined. The file was not deleted. Do not ask again or suggest alternatives.", "message": "User declined. Do not retry or suggest alternatives.",
} }
final_params: dict[str, Any] = {} final_file_path = result.params.get("file_path", file_path)
edited_action = decision.get("edited_action") final_connector_id = result.params.get("connector_id", connector.id)
if isinstance(edited_action, dict): final_delete_from_kb = result.params.get("delete_from_kb", delete_from_kb)
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_file_path = final_params.get("file_path", file_path)
final_connector_id = final_params.get("connector_id", connector.id)
final_delete_from_kb = final_params.get("delete_from_kb", delete_from_kb)
if final_connector_id != connector.id: if final_connector_id != connector.id:
result = await db_session.execute( result = await db_session.execute(

View file

@ -6,7 +6,7 @@ from email.mime.text import MIMEText
from typing import Any from typing import Any
from langchain_core.tools import tool from langchain_core.tools import tool
from langgraph.types import interrupt from app.agents.new_chat.tools.hitl import request_approval
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.services.gmail import GmailToolMetadataService from app.services.gmail import GmailToolMetadataService
@ -85,12 +85,10 @@ def create_create_gmail_draft_tool(
logger.info( logger.info(
f"Requesting approval for creating Gmail draft: to='{to}', subject='{subject}'" f"Requesting approval for creating Gmail draft: to='{to}', subject='{subject}'"
) )
approval = interrupt( result = request_approval(
{ action_type="gmail_draft_creation",
"type": "gmail_draft_creation", tool_name="create_gmail_draft",
"action": { params={
"tool": "create_gmail_draft",
"params": {
"to": to, "to": to,
"subject": subject, "subject": subject,
"body": body, "body": body,
@ -98,47 +96,21 @@ def create_create_gmail_draft_tool(
"bcc": bcc, "bcc": bcc,
"connector_id": None, "connector_id": None,
}, },
}, context=context,
"context": context,
}
) )
decisions_raw = ( if result.rejected:
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 { return {
"status": "rejected", "status": "rejected",
"message": "User declined. The draft was not created. Do not ask again or suggest alternatives.", "message": "User declined. The draft was not created. Do not ask again or suggest alternatives.",
} }
final_params: dict[str, Any] = {} final_to = result.params.get("to", to)
edited_action = decision.get("edited_action") final_subject = result.params.get("subject", subject)
if isinstance(edited_action, dict): final_body = result.params.get("body", body)
edited_args = edited_action.get("args") final_cc = result.params.get("cc", cc)
if isinstance(edited_args, dict): final_bcc = result.params.get("bcc", bcc)
final_params = edited_args final_connector_id = result.params.get("connector_id")
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 sqlalchemy.future import select

View file

@ -6,7 +6,7 @@ from email.mime.text import MIMEText
from typing import Any from typing import Any
from langchain_core.tools import tool from langchain_core.tools import tool
from langgraph.types import interrupt from app.agents.new_chat.tools.hitl import request_approval
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.services.gmail import GmailToolMetadataService from app.services.gmail import GmailToolMetadataService
@ -86,12 +86,10 @@ def create_send_gmail_email_tool(
logger.info( logger.info(
f"Requesting approval for sending Gmail email: to='{to}', subject='{subject}'" f"Requesting approval for sending Gmail email: to='{to}', subject='{subject}'"
) )
approval = interrupt( result = request_approval(
{ action_type="gmail_email_send",
"type": "gmail_email_send", tool_name="send_gmail_email",
"action": { params={
"tool": "send_gmail_email",
"params": {
"to": to, "to": to,
"subject": subject, "subject": subject,
"body": body, "body": body,
@ -99,47 +97,21 @@ def create_send_gmail_email_tool(
"bcc": bcc, "bcc": bcc,
"connector_id": None, "connector_id": None,
}, },
}, context=context,
"context": context,
}
) )
decisions_raw = ( if result.rejected:
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 { return {
"status": "rejected", "status": "rejected",
"message": "User declined. The email was not sent. Do not ask again or suggest alternatives.", "message": "User declined. The email was not sent. Do not ask again or suggest alternatives.",
} }
final_params: dict[str, Any] = {} final_to = result.params.get("to", to)
edited_action = decision.get("edited_action") final_subject = result.params.get("subject", subject)
if isinstance(edited_action, dict): final_body = result.params.get("body", body)
edited_args = edited_action.get("args") final_cc = result.params.get("cc", cc)
if isinstance(edited_args, dict): final_bcc = result.params.get("bcc", bcc)
final_params = edited_args final_connector_id = result.params.get("connector_id")
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 sqlalchemy.future import select

View file

@ -4,7 +4,7 @@ from datetime import datetime
from typing import Any from typing import Any
from langchain_core.tools import tool from langchain_core.tools import tool
from langgraph.types import interrupt from app.agents.new_chat.tools.hitl import request_approval
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.services.gmail import GmailToolMetadataService from app.services.gmail import GmailToolMetadataService
@ -101,56 +101,28 @@ def create_trash_gmail_email_tool(
logger.info( logger.info(
f"Requesting approval for trashing Gmail email: '{email_subject_or_id}' (message_id={message_id}, delete_from_kb={delete_from_kb})" f"Requesting approval for trashing Gmail email: '{email_subject_or_id}' (message_id={message_id}, delete_from_kb={delete_from_kb})"
) )
approval = interrupt( result = request_approval(
{ action_type="gmail_email_trash",
"type": "gmail_email_trash", tool_name="trash_gmail_email",
"action": { params={
"tool": "trash_gmail_email",
"params": {
"message_id": message_id, "message_id": message_id,
"connector_id": connector_id_from_context, "connector_id": connector_id_from_context,
"delete_from_kb": delete_from_kb, "delete_from_kb": delete_from_kb,
}, },
}, context=context,
"context": context,
}
) )
decisions_raw = ( if result.rejected:
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 { return {
"status": "rejected", "status": "rejected",
"message": "User declined. The email was not trashed. Do not ask again or suggest alternatives.", "message": "User declined. The email was not trashed. Do not ask again or suggest alternatives.",
} }
edited_action = decision.get("edited_action") final_message_id = result.params.get("message_id", message_id)
final_params: dict[str, Any] = {} final_connector_id = result.params.get(
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 "connector_id", connector_id_from_context
) )
final_delete_from_kb = final_params.get("delete_from_kb", delete_from_kb) final_delete_from_kb = result.params.get("delete_from_kb", delete_from_kb)
if not final_connector_id: if not final_connector_id:
return { return {

View file

@ -6,7 +6,7 @@ from email.mime.text import MIMEText
from typing import Any from typing import Any
from langchain_core.tools import tool from langchain_core.tools import tool
from langgraph.types import interrupt from app.agents.new_chat.tools.hitl import request_approval
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.services.gmail import GmailToolMetadataService from app.services.gmail import GmailToolMetadataService
@ -122,12 +122,10 @@ def create_update_gmail_draft_tool(
f"Requesting approval for updating Gmail draft: '{original_subject}' " f"Requesting approval for updating Gmail draft: '{original_subject}' "
f"(message_id={message_id}, draft_id={draft_id_from_context})" f"(message_id={message_id}, draft_id={draft_id_from_context})"
) )
approval = interrupt( result = request_approval(
{ action_type="gmail_draft_update",
"type": "gmail_draft_update", tool_name="update_gmail_draft",
"action": { params={
"tool": "update_gmail_draft",
"params": {
"message_id": message_id, "message_id": message_id,
"draft_id": draft_id_from_context, "draft_id": draft_id_from_context,
"to": final_to_default, "to": final_to_default,
@ -137,50 +135,24 @@ def create_update_gmail_draft_tool(
"bcc": bcc, "bcc": bcc,
"connector_id": connector_id_from_context, "connector_id": connector_id_from_context,
}, },
}, context=context,
"context": context,
}
) )
decisions_raw = ( if result.rejected:
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 { return {
"status": "rejected", "status": "rejected",
"message": "User declined. The draft was not updated. Do not ask again or suggest alternatives.", "message": "User declined. The draft was not updated. Do not ask again or suggest alternatives.",
} }
final_params: dict[str, Any] = {} final_to = result.params.get("to", final_to_default)
edited_action = decision.get("edited_action") final_subject = result.params.get("subject", final_subject_default)
if isinstance(edited_action, dict): final_body = result.params.get("body", body)
edited_args = edited_action.get("args") final_cc = result.params.get("cc", cc)
if isinstance(edited_args, dict): final_bcc = result.params.get("bcc", bcc)
final_params = edited_args final_connector_id = result.params.get(
elif isinstance(decision.get("args"), dict):
final_params = decision["args"]
final_to = final_params.get("to", final_to_default)
final_subject = final_params.get("subject", final_subject_default)
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", connector_id_from_context "connector_id", connector_id_from_context
) )
final_draft_id = final_params.get("draft_id", draft_id_from_context) final_draft_id = result.params.get("draft_id", draft_id_from_context)
if not final_connector_id: if not final_connector_id:
return { return {

View file

@ -6,9 +6,9 @@ from typing import Any
from google.oauth2.credentials import Credentials from google.oauth2.credentials import Credentials
from googleapiclient.discovery import build from googleapiclient.discovery import build
from langchain_core.tools import tool from langchain_core.tools import tool
from langgraph.types import interrupt
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.agents.new_chat.tools.hitl import request_approval
from app.services.google_calendar import GoogleCalendarToolMetadataService from app.services.google_calendar import GoogleCalendarToolMetadataService
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -90,12 +90,10 @@ def create_create_calendar_event_tool(
logger.info( logger.info(
f"Requesting approval for creating calendar event: summary='{summary}'" f"Requesting approval for creating calendar event: summary='{summary}'"
) )
approval = interrupt( result = request_approval(
{ action_type="google_calendar_event_creation",
"type": "google_calendar_event_creation", tool_name="create_calendar_event",
"action": { params={
"tool": "create_calendar_event",
"params": {
"summary": summary, "summary": summary,
"start_datetime": start_datetime, "start_datetime": start_datetime,
"end_datetime": end_datetime, "end_datetime": end_datetime,
@ -105,48 +103,22 @@ def create_create_calendar_event_tool(
"timezone": context.get("timezone"), "timezone": context.get("timezone"),
"connector_id": None, "connector_id": None,
}, },
}, context=context,
"context": context,
}
) )
decisions_raw = ( if result.rejected:
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 { return {
"status": "rejected", "status": "rejected",
"message": "User declined. The event was not created. Do not ask again or suggest alternatives.", "message": "User declined. The event was not created. Do not ask again or suggest alternatives.",
} }
final_params: dict[str, Any] = {} final_summary = result.params.get("summary", summary)
edited_action = decision.get("edited_action") final_start_datetime = result.params.get("start_datetime", start_datetime)
if isinstance(edited_action, dict): final_end_datetime = result.params.get("end_datetime", end_datetime)
edited_args = edited_action.get("args") final_description = result.params.get("description", description)
if isinstance(edited_args, dict): final_location = result.params.get("location", location)
final_params = edited_args final_attendees = result.params.get("attendees", attendees)
elif isinstance(decision.get("args"), dict): final_connector_id = result.params.get("connector_id")
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(): if not final_summary or not final_summary.strip():
return {"status": "error", "message": "Event summary cannot be empty."} return {"status": "error", "message": "Event summary cannot be empty."}

View file

@ -6,9 +6,9 @@ from typing import Any
from google.oauth2.credentials import Credentials from google.oauth2.credentials import Credentials
from googleapiclient.discovery import build from googleapiclient.discovery import build
from langchain_core.tools import tool from langchain_core.tools import tool
from langgraph.types import interrupt
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.agents.new_chat.tools.hitl import request_approval
from app.services.google_calendar import GoogleCalendarToolMetadataService from app.services.google_calendar import GoogleCalendarToolMetadataService
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -100,56 +100,28 @@ def create_delete_calendar_event_tool(
logger.info( logger.info(
f"Requesting approval for deleting calendar event: '{event_title_or_id}' (event_id={event_id}, delete_from_kb={delete_from_kb})" f"Requesting approval for deleting calendar event: '{event_title_or_id}' (event_id={event_id}, delete_from_kb={delete_from_kb})"
) )
approval = interrupt( result = request_approval(
{ action_type="google_calendar_event_deletion",
"type": "google_calendar_event_deletion", tool_name="delete_calendar_event",
"action": { params={
"tool": "delete_calendar_event",
"params": {
"event_id": event_id, "event_id": event_id,
"connector_id": connector_id_from_context, "connector_id": connector_id_from_context,
"delete_from_kb": delete_from_kb, "delete_from_kb": delete_from_kb,
}, },
}, context=context,
"context": context,
}
) )
decisions_raw = ( if result.rejected:
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 { return {
"status": "rejected", "status": "rejected",
"message": "User declined. The event was not deleted. Do not ask again or suggest alternatives.", "message": "User declined. The event was not deleted. Do not ask again or suggest alternatives.",
} }
edited_action = decision.get("edited_action") final_event_id = result.params.get("event_id", event_id)
final_params: dict[str, Any] = {} final_connector_id = result.params.get(
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 "connector_id", connector_id_from_context
) )
final_delete_from_kb = final_params.get("delete_from_kb", delete_from_kb) final_delete_from_kb = result.params.get("delete_from_kb", delete_from_kb)
if not final_connector_id: if not final_connector_id:
return { return {

View file

@ -6,9 +6,9 @@ from typing import Any
from google.oauth2.credentials import Credentials from google.oauth2.credentials import Credentials
from googleapiclient.discovery import build from googleapiclient.discovery import build
from langchain_core.tools import tool from langchain_core.tools import tool
from langgraph.types import interrupt
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.agents.new_chat.tools.hitl import request_approval
from app.services.google_calendar import GoogleCalendarToolMetadataService from app.services.google_calendar import GoogleCalendarToolMetadataService
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -116,12 +116,10 @@ def create_update_calendar_event_tool(
logger.info( logger.info(
f"Requesting approval for updating calendar event: '{event_title_or_id}' (event_id={event_id})" f"Requesting approval for updating calendar event: '{event_title_or_id}' (event_id={event_id})"
) )
approval = interrupt( result = request_approval(
{ action_type="google_calendar_event_update",
"type": "google_calendar_event_update", tool_name="update_calendar_event",
"action": { params={
"tool": "update_calendar_event",
"params": {
"event_id": event_id, "event_id": event_id,
"document_id": document_id, "document_id": document_id,
"connector_id": connector_id_from_context, "connector_id": connector_id_from_context,
@ -132,55 +130,29 @@ def create_update_calendar_event_tool(
"new_location": new_location, "new_location": new_location,
"new_attendees": new_attendees, "new_attendees": new_attendees,
}, },
}, context=context,
"context": context,
}
) )
decisions_raw = ( if result.rejected:
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 { return {
"status": "rejected", "status": "rejected",
"message": "User declined. The event was not updated. Do not ask again or suggest alternatives.", "message": "User declined. The event was not updated. Do not ask again or suggest alternatives.",
} }
edited_action = decision.get("edited_action") final_event_id = result.params.get("event_id", event_id)
final_params: dict[str, Any] = {} final_connector_id = result.params.get(
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 "connector_id", connector_id_from_context
) )
final_new_summary = final_params.get("new_summary", new_summary) final_new_summary = result.params.get("new_summary", new_summary)
final_new_start_datetime = final_params.get( final_new_start_datetime = result.params.get(
"new_start_datetime", new_start_datetime "new_start_datetime", new_start_datetime
) )
final_new_end_datetime = final_params.get( final_new_end_datetime = result.params.get(
"new_end_datetime", new_end_datetime "new_end_datetime", new_end_datetime
) )
final_new_description = final_params.get("new_description", new_description) final_new_description = result.params.get("new_description", new_description)
final_new_location = final_params.get("new_location", new_location) final_new_location = result.params.get("new_location", new_location)
final_new_attendees = final_params.get("new_attendees", new_attendees) final_new_attendees = result.params.get("new_attendees", new_attendees)
if not final_connector_id: if not final_connector_id:
return { return {

View file

@ -3,9 +3,9 @@ from typing import Any, Literal
from googleapiclient.errors import HttpError from googleapiclient.errors import HttpError
from langchain_core.tools import tool from langchain_core.tools import tool
from langgraph.types import interrupt
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.agents.new_chat.tools.hitl import request_approval
from app.connectors.google_drive.client import GoogleDriveClient from app.connectors.google_drive.client import GoogleDriveClient
from app.connectors.google_drive.file_types import GOOGLE_DOC, GOOGLE_SHEET from app.connectors.google_drive.file_types import GOOGLE_DOC, GOOGLE_SHEET
from app.services.google_drive import GoogleDriveToolMetadataService from app.services.google_drive import GoogleDriveToolMetadataService
@ -99,58 +99,30 @@ def create_create_google_drive_file_tool(
logger.info( logger.info(
f"Requesting approval for creating Google Drive file: name='{name}', type='{file_type}'" f"Requesting approval for creating Google Drive file: name='{name}', type='{file_type}'"
) )
approval = interrupt( result = request_approval(
{ action_type="google_drive_file_creation",
"type": "google_drive_file_creation", tool_name="create_google_drive_file",
"action": { params={
"tool": "create_google_drive_file",
"params": {
"name": name, "name": name,
"file_type": file_type, "file_type": file_type,
"content": content, "content": content,
"connector_id": None, "connector_id": None,
"parent_folder_id": None, "parent_folder_id": None,
}, },
}, context=context,
"context": context,
}
) )
decisions_raw = ( if result.rejected:
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 { return {
"status": "rejected", "status": "rejected",
"message": "User declined. The file was not created. Do not ask again or suggest alternatives.", "message": "User declined. The file was not created. Do not ask again or suggest alternatives.",
} }
final_params: dict[str, Any] = {} final_name = result.params.get("name", name)
edited_action = decision.get("edited_action") final_file_type = result.params.get("file_type", file_type)
if isinstance(edited_action, dict): final_content = result.params.get("content", content)
edited_args = edited_action.get("args") final_connector_id = result.params.get("connector_id")
if isinstance(edited_args, dict): final_parent_folder_id = result.params.get("parent_folder_id")
final_params = edited_args
elif isinstance(decision.get("args"), dict):
final_params = decision["args"]
final_name = final_params.get("name", name)
final_file_type = final_params.get("file_type", file_type)
final_content = final_params.get("content", content)
final_connector_id = final_params.get("connector_id")
final_parent_folder_id = final_params.get("parent_folder_id")
if not final_name or not final_name.strip(): if not final_name or not final_name.strip():
return {"status": "error", "message": "File name cannot be empty."} return {"status": "error", "message": "File name cannot be empty."}

View file

@ -3,9 +3,9 @@ from typing import Any
from googleapiclient.errors import HttpError from googleapiclient.errors import HttpError
from langchain_core.tools import tool from langchain_core.tools import tool
from langgraph.types import interrupt
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.agents.new_chat.tools.hitl import request_approval
from app.connectors.google_drive.client import GoogleDriveClient from app.connectors.google_drive.client import GoogleDriveClient
from app.services.google_drive import GoogleDriveToolMetadataService from app.services.google_drive import GoogleDriveToolMetadataService
@ -101,56 +101,28 @@ def create_delete_google_drive_file_tool(
logger.info( logger.info(
f"Requesting approval for deleting Google Drive file: '{file_name}' (file_id={file_id}, delete_from_kb={delete_from_kb})" f"Requesting approval for deleting Google Drive file: '{file_name}' (file_id={file_id}, delete_from_kb={delete_from_kb})"
) )
approval = interrupt( result = request_approval(
{ action_type="google_drive_file_trash",
"type": "google_drive_file_trash", tool_name="delete_google_drive_file",
"action": { params={
"tool": "delete_google_drive_file",
"params": {
"file_id": file_id, "file_id": file_id,
"connector_id": connector_id_from_context, "connector_id": connector_id_from_context,
"delete_from_kb": delete_from_kb, "delete_from_kb": delete_from_kb,
}, },
}, context=context,
"context": context,
}
) )
decisions_raw = ( if result.rejected:
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 { return {
"status": "rejected", "status": "rejected",
"message": "User declined. The file was not trashed. Do not ask again or suggest alternatives.", "message": "User declined. The file was not trashed. Do not ask again or suggest alternatives.",
} }
edited_action = decision.get("edited_action") final_file_id = result.params.get("file_id", file_id)
final_params: dict[str, Any] = {} final_connector_id = result.params.get(
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_file_id = final_params.get("file_id", file_id)
final_connector_id = final_params.get(
"connector_id", connector_id_from_context "connector_id", connector_id_from_context
) )
final_delete_from_kb = final_params.get("delete_from_kb", delete_from_kb) final_delete_from_kb = result.params.get("delete_from_kb", delete_from_kb)
if not final_connector_id: if not final_connector_id:
return { return {

View file

@ -0,0 +1,140 @@
"""Unified HITL (Human-in-the-Loop) approval utility.
Provides a single ``request_approval()`` function that encapsulates the
interrupt payload creation, decision parsing, and parameter merging logic
shared by every sensitive tool (native connectors and MCP tools alike).
Usage inside a tool::
from app.agents.new_chat.tools.hitl import request_approval
result = request_approval(
action_type="gmail_email_send",
tool_name="send_gmail_email",
params={"to": to, "subject": subject, "body": body},
context=context,
)
if result.rejected:
return {"status": "rejected", "message": "User declined."}
# result.params contains the final (possibly edited) parameters
"""
from __future__ import annotations
import logging
from dataclasses import dataclass, field
from typing import Any
from langgraph.types import interrupt
logger = logging.getLogger(__name__)
@dataclass(frozen=True, slots=True)
class HITLResult:
"""Outcome of a human-in-the-loop approval request."""
rejected: bool
decision_type: str
params: dict[str, Any] = field(default_factory=dict)
def _parse_decision(approval: Any) -> tuple[str, dict[str, Any]]:
"""Extract the first valid decision and its edited parameters.
Returns:
(decision_type, edited_params) where *decision_type* is one of
``"approve"``, ``"edit"``, or ``"reject"`` and *edited_params* is
the dict of user-modified arguments (empty when there are none).
Raises:
ValueError: when no usable decision dict can be found.
"""
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:
raise ValueError("No approval decision received")
decision = decisions[0]
decision_type: str = decision.get("type") or decision.get("decision_type") or "approve"
edited_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):
edited_params = edited_args
elif isinstance(decision.get("args"), dict):
edited_params = decision["args"]
return decision_type, edited_params
def request_approval(
*,
action_type: str,
tool_name: str,
params: dict[str, Any],
context: dict[str, Any] | None = None,
trusted_tools: list[str] | None = None,
) -> HITLResult:
"""Pause the graph for user approval and return the decision.
This is a **synchronous** helper (not ``async``) because
``langgraph.types.interrupt`` is itself synchronous it raises a
``GraphInterrupt`` exception that the LangGraph runtime catches.
Parameters
----------
action_type:
A label that the frontend uses to select the correct approval card
(e.g. ``"gmail_email_send"``, ``"mcp_tool_call"``).
tool_name:
The registered LangChain tool name (e.g. ``"send_gmail_email"``).
params:
The original tool arguments. These are shown in the approval card
and used as defaults when the user does not edit anything.
context:
Rich metadata from a ``*ToolMetadataService`` (accounts, folders,
labels, etc.). For MCP tools this can hold the server name and
tool description.
trusted_tools:
An allow-list of tool names the user has previously marked as
"Always Allow". If *tool_name* appears in this list, HITL is
skipped and the tool executes immediately.
Returns
-------
HITLResult
``result.rejected`` is ``True`` when the user chose to deny the
action. Otherwise ``result.params`` contains the final parameter
dict either the originals or the user-edited version merged on
top.
"""
if trusted_tools and tool_name in trusted_tools:
logger.info("Tool '%s' is user-trusted — skipping HITL", tool_name)
return HITLResult(rejected=False, decision_type="trusted", params=dict(params))
approval = interrupt(
{
"type": action_type,
"action": {"tool": tool_name, "params": params},
"context": context or {},
}
)
try:
decision_type, edited_params = _parse_decision(approval)
except ValueError:
logger.warning("No approval decision received for %s", tool_name)
return HITLResult(rejected=False, decision_type="error", params=params)
logger.info("User decision for %s: %s", tool_name, decision_type)
if decision_type == "reject":
return HITLResult(rejected=True, decision_type="reject", params=params)
final_params = {**params, **edited_params} if edited_params else dict(params)
return HITLResult(rejected=False, decision_type=decision_type, params=final_params)

View file

@ -3,7 +3,7 @@ import logging
from typing import Any from typing import Any
from langchain_core.tools import tool from langchain_core.tools import tool
from langgraph.types import interrupt from app.agents.new_chat.tools.hitl import request_approval
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm.attributes import flag_modified from sqlalchemy.orm.attributes import flag_modified
@ -69,12 +69,10 @@ def create_create_jira_issue_tool(
"connector_type": "jira", "connector_type": "jira",
} }
approval = interrupt( result = request_approval(
{ action_type="jira_issue_creation",
"type": "jira_issue_creation", tool_name="create_jira_issue",
"action": { params={
"tool": "create_jira_issue",
"params": {
"project_key": project_key, "project_key": project_key,
"summary": summary, "summary": summary,
"issue_type": issue_type, "issue_type": issue_type,
@ -82,45 +80,21 @@ def create_create_jira_issue_tool(
"priority": priority, "priority": priority,
"connector_id": connector_id, "connector_id": connector_id,
}, },
}, context=context,
"context": context,
}
) )
decisions_raw = ( if result.rejected:
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:
return {"status": "error", "message": "No approval decision received"}
decision = decisions[0]
decision_type = decision.get("type") or decision.get("decision_type")
if decision_type == "reject":
return { return {
"status": "rejected", "status": "rejected",
"message": "User declined. The issue was not created.", "message": "User declined. Do not retry or suggest alternatives.",
} }
final_params: dict[str, Any] = {} final_project_key = result.params.get("project_key", project_key)
edited_action = decision.get("edited_action") final_summary = result.params.get("summary", summary)
if isinstance(edited_action, dict): final_issue_type = result.params.get("issue_type", issue_type)
edited_args = edited_action.get("args") final_description = result.params.get("description", description)
if isinstance(edited_args, dict): final_priority = result.params.get("priority", priority)
final_params = edited_args final_connector_id = result.params.get("connector_id", connector_id)
elif isinstance(decision.get("args"), dict):
final_params = decision["args"]
final_project_key = final_params.get("project_key", project_key)
final_summary = final_params.get("summary", summary)
final_issue_type = final_params.get("issue_type", issue_type)
final_description = final_params.get("description", description)
final_priority = final_params.get("priority", priority)
final_connector_id = final_params.get("connector_id", connector_id)
if not final_summary or not final_summary.strip(): if not final_summary or not final_summary.strip():
return {"status": "error", "message": "Issue summary cannot be empty."} return {"status": "error", "message": "Issue summary cannot be empty."}

View file

@ -3,7 +3,7 @@ import logging
from typing import Any from typing import Any
from langchain_core.tools import tool from langchain_core.tools import tool
from langgraph.types import interrupt from app.agents.new_chat.tools.hitl import request_approval
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm.attributes import flag_modified from sqlalchemy.orm.attributes import flag_modified
@ -71,54 +71,28 @@ def create_delete_jira_issue_tool(
document_id = issue_data["document_id"] document_id = issue_data["document_id"]
connector_id_from_context = context.get("account", {}).get("id") connector_id_from_context = context.get("account", {}).get("id")
approval = interrupt( result = request_approval(
{ action_type="jira_issue_deletion",
"type": "jira_issue_deletion", tool_name="delete_jira_issue",
"action": { params={
"tool": "delete_jira_issue",
"params": {
"issue_key": issue_key, "issue_key": issue_key,
"connector_id": connector_id_from_context, "connector_id": connector_id_from_context,
"delete_from_kb": delete_from_kb, "delete_from_kb": delete_from_kb,
}, },
}, context=context,
"context": context,
}
) )
decisions_raw = ( if result.rejected:
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:
return {"status": "error", "message": "No approval decision received"}
decision = decisions[0]
decision_type = decision.get("type") or decision.get("decision_type")
if decision_type == "reject":
return { return {
"status": "rejected", "status": "rejected",
"message": "User declined. The issue was not deleted.", "message": "User declined. Do not retry or suggest alternatives.",
} }
final_params: dict[str, Any] = {} final_issue_key = result.params.get("issue_key", issue_key)
edited_action = decision.get("edited_action") final_connector_id = result.params.get(
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_issue_key = final_params.get("issue_key", issue_key)
final_connector_id = final_params.get(
"connector_id", connector_id_from_context "connector_id", connector_id_from_context
) )
final_delete_from_kb = final_params.get("delete_from_kb", delete_from_kb) final_delete_from_kb = result.params.get("delete_from_kb", delete_from_kb)
from sqlalchemy.future import select from sqlalchemy.future import select

View file

@ -3,7 +3,7 @@ import logging
from typing import Any from typing import Any
from langchain_core.tools import tool from langchain_core.tools import tool
from langgraph.types import interrupt from app.agents.new_chat.tools.hitl import request_approval
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm.attributes import flag_modified from sqlalchemy.orm.attributes import flag_modified
@ -75,12 +75,10 @@ def create_update_jira_issue_tool(
document_id = issue_data.get("document_id") document_id = issue_data.get("document_id")
connector_id_from_context = context.get("account", {}).get("id") connector_id_from_context = context.get("account", {}).get("id")
approval = interrupt( result = request_approval(
{ action_type="jira_issue_update",
"type": "jira_issue_update", tool_name="update_jira_issue",
"action": { params={
"tool": "update_jira_issue",
"params": {
"issue_key": issue_key, "issue_key": issue_key,
"document_id": document_id, "document_id": document_id,
"new_summary": new_summary, "new_summary": new_summary,
@ -88,47 +86,23 @@ def create_update_jira_issue_tool(
"new_priority": new_priority, "new_priority": new_priority,
"connector_id": connector_id_from_context, "connector_id": connector_id_from_context,
}, },
}, context=context,
"context": context,
}
) )
decisions_raw = ( if result.rejected:
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:
return {"status": "error", "message": "No approval decision received"}
decision = decisions[0]
decision_type = decision.get("type") or decision.get("decision_type")
if decision_type == "reject":
return { return {
"status": "rejected", "status": "rejected",
"message": "User declined. The issue was not updated.", "message": "User declined. Do not retry or suggest alternatives.",
} }
final_params: dict[str, Any] = {} final_issue_key = result.params.get("issue_key", issue_key)
edited_action = decision.get("edited_action") final_summary = result.params.get("new_summary", new_summary)
if isinstance(edited_action, dict): final_description = result.params.get("new_description", new_description)
edited_args = edited_action.get("args") final_priority = result.params.get("new_priority", new_priority)
if isinstance(edited_args, dict): final_connector_id = result.params.get(
final_params = edited_args
elif isinstance(decision.get("args"), dict):
final_params = decision["args"]
final_issue_key = final_params.get("issue_key", issue_key)
final_summary = final_params.get("new_summary", new_summary)
final_description = final_params.get("new_description", new_description)
final_priority = final_params.get("new_priority", new_priority)
final_connector_id = final_params.get(
"connector_id", connector_id_from_context "connector_id", connector_id_from_context
) )
final_document_id = final_params.get("document_id", document_id) final_document_id = result.params.get("document_id", document_id)
from sqlalchemy.future import select from sqlalchemy.future import select

View file

@ -2,7 +2,7 @@ import logging
from typing import Any from typing import Any
from langchain_core.tools import tool from langchain_core.tools import tool
from langgraph.types import interrupt from app.agents.new_chat.tools.hitl import request_approval
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.connectors.linear_connector import LinearAPIError, LinearConnector from app.connectors.linear_connector import LinearAPIError, LinearConnector
@ -94,12 +94,10 @@ def create_create_linear_issue_tool(
} }
logger.info(f"Requesting approval for creating Linear issue: '{title}'") logger.info(f"Requesting approval for creating Linear issue: '{title}'")
approval = interrupt( result = request_approval(
{ action_type="linear_issue_creation",
"type": "linear_issue_creation", tool_name="create_linear_issue",
"action": { params={
"tool": "create_linear_issue",
"params": {
"title": title, "title": title,
"description": description, "description": description,
"team_id": None, "team_id": None,
@ -109,50 +107,24 @@ def create_create_linear_issue_tool(
"label_ids": [], "label_ids": [],
"connector_id": connector_id, "connector_id": connector_id,
}, },
}, context=context,
"context": context,
}
) )
decisions_raw = ( if result.rejected:
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":
logger.info("Linear issue creation rejected by user") logger.info("Linear issue creation rejected by user")
return { return {
"status": "rejected", "status": "rejected",
"message": "User declined. The issue was not created. Do not ask again or suggest alternatives.", "message": "User declined. Do not retry or suggest alternatives.",
} }
final_params: dict[str, Any] = {} final_title = result.params.get("title", title)
edited_action = decision.get("edited_action") final_description = result.params.get("description", description)
if isinstance(edited_action, dict): final_team_id = result.params.get("team_id")
edited_args = edited_action.get("args") final_state_id = result.params.get("state_id")
if isinstance(edited_args, dict): final_assignee_id = result.params.get("assignee_id")
final_params = edited_args final_priority = result.params.get("priority")
elif isinstance(decision.get("args"), dict): final_label_ids = result.params.get("label_ids") or []
final_params = decision["args"] final_connector_id = result.params.get("connector_id", connector_id)
final_title = final_params.get("title", title)
final_description = final_params.get("description", description)
final_team_id = final_params.get("team_id")
final_state_id = final_params.get("state_id")
final_assignee_id = final_params.get("assignee_id")
final_priority = final_params.get("priority")
final_label_ids = final_params.get("label_ids") or []
final_connector_id = final_params.get("connector_id", connector_id)
if not final_title or not final_title.strip(): if not final_title or not final_title.strip():
logger.error("Title is empty or contains only whitespace") logger.error("Title is empty or contains only whitespace")

View file

@ -2,7 +2,7 @@ import logging
from typing import Any from typing import Any
from langchain_core.tools import tool from langchain_core.tools import tool
from langgraph.types import interrupt from app.agents.new_chat.tools.hitl import request_approval
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.connectors.linear_connector import LinearAPIError, LinearConnector from app.connectors.linear_connector import LinearAPIError, LinearConnector
@ -114,57 +114,29 @@ def create_delete_linear_issue_tool(
f"Requesting approval for deleting Linear issue: '{issue_ref}' " f"Requesting approval for deleting Linear issue: '{issue_ref}' "
f"(id={issue_id}, delete_from_kb={delete_from_kb})" f"(id={issue_id}, delete_from_kb={delete_from_kb})"
) )
approval = interrupt( result = request_approval(
{ action_type="linear_issue_deletion",
"type": "linear_issue_deletion", tool_name="delete_linear_issue",
"action": { params={
"tool": "delete_linear_issue",
"params": {
"issue_id": issue_id, "issue_id": issue_id,
"connector_id": connector_id_from_context, "connector_id": connector_id_from_context,
"delete_from_kb": delete_from_kb, "delete_from_kb": delete_from_kb,
}, },
}, context=context,
"context": context,
}
) )
decisions_raw = ( if result.rejected:
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":
logger.info("Linear issue deletion rejected by user") logger.info("Linear issue deletion rejected by user")
return { return {
"status": "rejected", "status": "rejected",
"message": "User declined. The issue was not deleted. Do not ask again or suggest alternatives.", "message": "User declined. Do not retry or suggest alternatives.",
} }
edited_action = decision.get("edited_action") final_issue_id = result.params.get("issue_id", issue_id)
final_params: dict[str, Any] = {} final_connector_id = result.params.get(
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_issue_id = final_params.get("issue_id", issue_id)
final_connector_id = final_params.get(
"connector_id", connector_id_from_context "connector_id", connector_id_from_context
) )
final_delete_from_kb = final_params.get("delete_from_kb", delete_from_kb) final_delete_from_kb = result.params.get("delete_from_kb", delete_from_kb)
logger.info( logger.info(
f"Deleting Linear issue with final params: issue_id={final_issue_id}, " f"Deleting Linear issue with final params: issue_id={final_issue_id}, "

View file

@ -2,7 +2,7 @@ import logging
from typing import Any from typing import Any
from langchain_core.tools import tool from langchain_core.tools import tool
from langgraph.types import interrupt from app.agents.new_chat.tools.hitl import request_approval
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.connectors.linear_connector import LinearAPIError, LinearConnector from app.connectors.linear_connector import LinearAPIError, LinearConnector
@ -130,12 +130,10 @@ def create_update_linear_issue_tool(
logger.info( logger.info(
f"Requesting approval for updating Linear issue: '{issue_ref}' (id={issue_id})" f"Requesting approval for updating Linear issue: '{issue_ref}' (id={issue_id})"
) )
approval = interrupt( result = request_approval(
{ action_type="linear_issue_update",
"type": "linear_issue_update", tool_name="update_linear_issue",
"action": { params={
"tool": "update_linear_issue",
"params": {
"issue_id": issue_id, "issue_id": issue_id,
"document_id": document_id, "document_id": document_id,
"new_title": new_title, "new_title": new_title,
@ -146,53 +144,27 @@ def create_update_linear_issue_tool(
"new_label_ids": new_label_ids, "new_label_ids": new_label_ids,
"connector_id": connector_id_from_context, "connector_id": connector_id_from_context,
}, },
}, context=context,
"context": context,
}
) )
decisions_raw = ( if result.rejected:
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":
logger.info("Linear issue update rejected by user") logger.info("Linear issue update rejected by user")
return { return {
"status": "rejected", "status": "rejected",
"message": "User declined. The issue was not updated. Do not ask again or suggest alternatives.", "message": "User declined. Do not retry or suggest alternatives.",
} }
edited_action = decision.get("edited_action") final_issue_id = result.params.get("issue_id", issue_id)
final_params: dict[str, Any] = {} final_document_id = result.params.get("document_id", document_id)
if isinstance(edited_action, dict): final_new_title = result.params.get("new_title", new_title)
edited_args = edited_action.get("args") final_new_description = result.params.get("new_description", new_description)
if isinstance(edited_args, dict): final_new_state_id = result.params.get("new_state_id", new_state_id)
final_params = edited_args final_new_assignee_id = result.params.get("new_assignee_id", new_assignee_id)
elif isinstance(decision.get("args"), dict): final_new_priority = result.params.get("new_priority", new_priority)
final_params = decision["args"] final_new_label_ids: list[str] | None = result.params.get(
final_issue_id = final_params.get("issue_id", issue_id)
final_document_id = final_params.get("document_id", document_id)
final_new_title = final_params.get("new_title", new_title)
final_new_description = final_params.get("new_description", new_description)
final_new_state_id = final_params.get("new_state_id", new_state_id)
final_new_assignee_id = final_params.get("new_assignee_id", new_assignee_id)
final_new_priority = final_params.get("new_priority", new_priority)
final_new_label_ids: list[str] | None = final_params.get(
"new_label_ids", new_label_ids "new_label_ids", new_label_ids
) )
final_connector_id = final_params.get( final_connector_id = result.params.get(
"connector_id", connector_id_from_context "connector_id", connector_id_from_context
) )

View file

@ -7,7 +7,11 @@ Supports both transport types:
- stdio: Local process-based MCP servers (command, args, env) - stdio: Local process-based MCP servers (command, args, env)
- streamable-http/http/sse: Remote HTTP-based MCP servers (url, headers) - streamable-http/http/sse: Remote HTTP-based MCP servers (url, headers)
This implements real MCP protocol support similar to Cursor's implementation. All MCP tools are unconditionally gated by HITL (Human-in-the-Loop) approval.
Per the MCP spec: "Clients MUST consider tool annotations to be untrusted unless
they come from trusted servers." Users can bypass HITL for specific tools by
clicking "Always Allow", which adds the tool name to the connector's
``config.trusted_tools`` allow-list.
""" """
import logging import logging
@ -21,6 +25,7 @@ from pydantic import BaseModel, create_model
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.agents.new_chat.tools.hitl import request_approval
from app.agents.new_chat.tools.mcp_client import MCPClient from app.agents.new_chat.tools.mcp_client import MCPClient
from app.db import SearchSourceConnector, SearchSourceConnectorType from app.db import SearchSourceConnector, SearchSourceConnectorType
@ -49,27 +54,15 @@ def _create_dynamic_input_model_from_schema(
tool_name: str, tool_name: str,
input_schema: dict[str, Any], input_schema: dict[str, Any],
) -> type[BaseModel]: ) -> type[BaseModel]:
"""Create a Pydantic model from MCP tool's JSON schema. """Create a Pydantic model from MCP tool's JSON schema."""
Args:
tool_name: Name of the tool (used for model class name)
input_schema: JSON schema from MCP server
Returns:
Pydantic model class for tool input validation
"""
properties = input_schema.get("properties", {}) properties = input_schema.get("properties", {})
required_fields = input_schema.get("required", []) required_fields = input_schema.get("required", [])
# Build Pydantic field definitions
field_definitions = {} field_definitions = {}
for param_name, param_schema in properties.items(): for param_name, param_schema in properties.items():
param_description = param_schema.get("description", "") param_description = param_schema.get("description", "")
is_required = param_name in required_fields is_required = param_name in required_fields
# Use Any type for complex schemas to preserve structure
# This allows the MCP server to do its own validation
from typing import Any as AnyType from typing import Any as AnyType
from pydantic import Field from pydantic import Field
@ -85,7 +78,6 @@ def _create_dynamic_input_model_from_schema(
Field(None, description=param_description), Field(None, description=param_description),
) )
# Create dynamic model
model_name = f"{tool_name.replace(' ', '').replace('-', '_')}Input" model_name = f"{tool_name.replace(' ', '').replace('-', '_')}Input"
return create_model(model_name, **field_definitions) return create_model(model_name, **field_definitions)
@ -93,55 +85,70 @@ def _create_dynamic_input_model_from_schema(
async def _create_mcp_tool_from_definition_stdio( async def _create_mcp_tool_from_definition_stdio(
tool_def: dict[str, Any], tool_def: dict[str, Any],
mcp_client: MCPClient, mcp_client: MCPClient,
*,
connector_name: str = "",
connector_id: int | None = None,
trusted_tools: list[str] | None = None,
) -> StructuredTool: ) -> StructuredTool:
"""Create a LangChain tool from an MCP tool definition (stdio transport). """Create a LangChain tool from an MCP tool definition (stdio transport).
Args: All MCP tools are unconditionally wrapped with HITL approval.
tool_def: Tool definition from MCP server with name, description, input_schema ``request_approval()`` is called OUTSIDE the try/except so that
mcp_client: MCP client instance for calling the tool ``GraphInterrupt`` propagates cleanly to LangGraph.
Returns:
LangChain StructuredTool instance
""" """
tool_name = tool_def.get("name", "unnamed_tool") tool_name = tool_def.get("name", "unnamed_tool")
tool_description = tool_def.get("description", "No description provided") tool_description = tool_def.get("description", "No description provided")
input_schema = tool_def.get("input_schema", {"type": "object", "properties": {}}) input_schema = tool_def.get("input_schema", {"type": "object", "properties": {}})
# Log the actual schema for debugging
logger.info(f"MCP tool '{tool_name}' input schema: {input_schema}") logger.info(f"MCP tool '{tool_name}' input schema: {input_schema}")
# Create dynamic input model from schema
input_model = _create_dynamic_input_model_from_schema(tool_name, input_schema) input_model = _create_dynamic_input_model_from_schema(tool_name, input_schema)
async def mcp_tool_call(**kwargs) -> str: async def mcp_tool_call(**kwargs) -> str:
"""Execute the MCP tool call via the client with retry support.""" """Execute the MCP tool call via the client with retry support."""
logger.info(f"MCP tool '{tool_name}' called with params: {kwargs}") logger.info(f"MCP tool '{tool_name}' called with params: {kwargs}")
# HITL — OUTSIDE try/except so GraphInterrupt propagates to LangGraph
hitl_result = request_approval(
action_type="mcp_tool_call",
tool_name=tool_name,
params=kwargs,
context={
"mcp_server": connector_name,
"tool_description": tool_description,
"mcp_transport": "stdio",
"mcp_connector_id": connector_id,
},
trusted_tools=trusted_tools,
)
if hitl_result.rejected:
return "Tool call rejected by user."
call_kwargs = hitl_result.params
try: try:
# Connect to server and call tool (connect has built-in retry logic)
async with mcp_client.connect(): async with mcp_client.connect():
result = await mcp_client.call_tool(tool_name, kwargs) result = await mcp_client.call_tool(tool_name, call_kwargs)
return str(result) return str(result)
except RuntimeError as e: except RuntimeError as e:
# Connection failures after all retries
error_msg = f"MCP tool '{tool_name}' connection failed after retries: {e!s}" error_msg = f"MCP tool '{tool_name}' connection failed after retries: {e!s}"
logger.error(error_msg) logger.error(error_msg)
return f"Error: {error_msg}" return f"Error: {error_msg}"
except Exception as e: except Exception as e:
# Tool execution or other errors
error_msg = f"MCP tool '{tool_name}' execution failed: {e!s}" error_msg = f"MCP tool '{tool_name}' execution failed: {e!s}"
logger.exception(error_msg) logger.exception(error_msg)
return f"Error: {error_msg}" return f"Error: {error_msg}"
# Create StructuredTool with response_format to preserve exact schema
tool = StructuredTool( tool = StructuredTool(
name=tool_name, name=tool_name,
description=tool_description, description=tool_description,
coroutine=mcp_tool_call, coroutine=mcp_tool_call,
args_schema=input_model, args_schema=input_model,
# Store the original MCP schema as metadata so we can access it later metadata={
metadata={"mcp_input_schema": input_schema, "mcp_transport": "stdio"}, "mcp_input_schema": input_schema,
"mcp_transport": "stdio",
"hitl": True,
"hitl_dedup_key": next(iter(input_schema.get("required", [])), None),
},
) )
logger.info(f"Created MCP tool (stdio): '{tool_name}'") logger.info(f"Created MCP tool (stdio): '{tool_name}'")
@ -152,43 +159,54 @@ async def _create_mcp_tool_from_definition_http(
tool_def: dict[str, Any], tool_def: dict[str, Any],
url: str, url: str,
headers: dict[str, str], headers: dict[str, str],
*,
connector_name: str = "",
connector_id: int | None = None,
trusted_tools: list[str] | None = None,
) -> StructuredTool: ) -> StructuredTool:
"""Create a LangChain tool from an MCP tool definition (HTTP transport). """Create a LangChain tool from an MCP tool definition (HTTP transport).
Args: All MCP tools are unconditionally wrapped with HITL approval.
tool_def: Tool definition from MCP server with name, description, input_schema ``request_approval()`` is called OUTSIDE the try/except so that
url: URL of the MCP server ``GraphInterrupt`` propagates cleanly to LangGraph.
headers: HTTP headers for authentication
Returns:
LangChain StructuredTool instance
""" """
tool_name = tool_def.get("name", "unnamed_tool") tool_name = tool_def.get("name", "unnamed_tool")
tool_description = tool_def.get("description", "No description provided") tool_description = tool_def.get("description", "No description provided")
input_schema = tool_def.get("input_schema", {"type": "object", "properties": {}}) input_schema = tool_def.get("input_schema", {"type": "object", "properties": {}})
# Log the actual schema for debugging
logger.info(f"MCP HTTP tool '{tool_name}' input schema: {input_schema}") logger.info(f"MCP HTTP tool '{tool_name}' input schema: {input_schema}")
# Create dynamic input model from schema
input_model = _create_dynamic_input_model_from_schema(tool_name, input_schema) input_model = _create_dynamic_input_model_from_schema(tool_name, input_schema)
async def mcp_http_tool_call(**kwargs) -> str: async def mcp_http_tool_call(**kwargs) -> str:
"""Execute the MCP tool call via HTTP transport.""" """Execute the MCP tool call via HTTP transport."""
logger.info(f"MCP HTTP tool '{tool_name}' called with params: {kwargs}") logger.info(f"MCP HTTP tool '{tool_name}' called with params: {kwargs}")
# HITL — OUTSIDE try/except so GraphInterrupt propagates to LangGraph
hitl_result = request_approval(
action_type="mcp_tool_call",
tool_name=tool_name,
params=kwargs,
context={
"mcp_server": connector_name,
"tool_description": tool_description,
"mcp_transport": "http",
"mcp_connector_id": connector_id,
},
trusted_tools=trusted_tools,
)
if hitl_result.rejected:
return "Tool call rejected by user."
call_kwargs = hitl_result.params
try: try:
async with ( async with (
streamablehttp_client(url, headers=headers) as (read, write, _), streamablehttp_client(url, headers=headers) as (read, write, _),
ClientSession(read, write) as session, ClientSession(read, write) as session,
): ):
await session.initialize() await session.initialize()
response = await session.call_tool(tool_name, arguments=call_kwargs)
# Call the tool
response = await session.call_tool(tool_name, arguments=kwargs)
# Extract content from response
result = [] result = []
for content in response.content: for content in response.content:
if hasattr(content, "text"): if hasattr(content, "text"):
@ -209,7 +227,6 @@ async def _create_mcp_tool_from_definition_http(
logger.exception(error_msg) logger.exception(error_msg)
return f"Error: {error_msg}" return f"Error: {error_msg}"
# Create StructuredTool
tool = StructuredTool( tool = StructuredTool(
name=tool_name, name=tool_name,
description=tool_description, description=tool_description,
@ -219,6 +236,8 @@ async def _create_mcp_tool_from_definition_http(
"mcp_input_schema": input_schema, "mcp_input_schema": input_schema,
"mcp_transport": "http", "mcp_transport": "http",
"mcp_url": url, "mcp_url": url,
"hitl": True,
"hitl_dedup_key": next(iter(input_schema.get("required", [])), None),
}, },
) )
@ -230,20 +249,11 @@ async def _load_stdio_mcp_tools(
connector_id: int, connector_id: int,
connector_name: str, connector_name: str,
server_config: dict[str, Any], server_config: dict[str, Any],
trusted_tools: list[str] | None = None,
) -> list[StructuredTool]: ) -> list[StructuredTool]:
"""Load tools from a stdio-based MCP server. """Load tools from a stdio-based MCP server."""
Args:
connector_id: Connector ID for logging
connector_name: Connector name for logging
server_config: Server configuration with command, args, env
Returns:
List of tools from the MCP server
"""
tools: list[StructuredTool] = [] tools: list[StructuredTool] = []
# Validate required command field
command = server_config.get("command") command = server_config.get("command")
if not command or not isinstance(command, str): if not command or not isinstance(command, str):
logger.warning( logger.warning(
@ -251,7 +261,6 @@ async def _load_stdio_mcp_tools(
) )
return tools return tools
# Validate args field (must be list if present)
args = server_config.get("args", []) args = server_config.get("args", [])
if not isinstance(args, list): if not isinstance(args, list):
logger.warning( logger.warning(
@ -259,7 +268,6 @@ async def _load_stdio_mcp_tools(
) )
return tools return tools
# Validate env field (must be dict if present)
env = server_config.get("env", {}) env = server_config.get("env", {})
if not isinstance(env, dict): if not isinstance(env, dict):
logger.warning( logger.warning(
@ -267,10 +275,8 @@ async def _load_stdio_mcp_tools(
) )
return tools return tools
# Create MCP client
mcp_client = MCPClient(command, args, env) mcp_client = MCPClient(command, args, env)
# Connect and discover tools
async with mcp_client.connect(): async with mcp_client.connect():
tool_definitions = await mcp_client.list_tools() tool_definitions = await mcp_client.list_tools()
@ -279,10 +285,15 @@ async def _load_stdio_mcp_tools(
f"'{command}' (connector {connector_id})" f"'{command}' (connector {connector_id})"
) )
# Create LangChain tools from definitions
for tool_def in tool_definitions: for tool_def in tool_definitions:
try: try:
tool = await _create_mcp_tool_from_definition_stdio(tool_def, mcp_client) tool = await _create_mcp_tool_from_definition_stdio(
tool_def,
mcp_client,
connector_name=connector_name,
connector_id=connector_id,
trusted_tools=trusted_tools,
)
tools.append(tool) tools.append(tool)
except Exception as e: except Exception as e:
logger.exception( logger.exception(
@ -297,20 +308,11 @@ async def _load_http_mcp_tools(
connector_id: int, connector_id: int,
connector_name: str, connector_name: str,
server_config: dict[str, Any], server_config: dict[str, Any],
trusted_tools: list[str] | None = None,
) -> list[StructuredTool]: ) -> list[StructuredTool]:
"""Load tools from an HTTP-based MCP server. """Load tools from an HTTP-based MCP server."""
Args:
connector_id: Connector ID for logging
connector_name: Connector name for logging
server_config: Server configuration with url, headers
Returns:
List of tools from the MCP server
"""
tools: list[StructuredTool] = [] tools: list[StructuredTool] = []
# Validate required url field
url = server_config.get("url") url = server_config.get("url")
if not url or not isinstance(url, str): if not url or not isinstance(url, str):
logger.warning( logger.warning(
@ -318,7 +320,6 @@ async def _load_http_mcp_tools(
) )
return tools return tools
# Validate headers field (must be dict if present)
headers = server_config.get("headers", {}) headers = server_config.get("headers", {})
if not isinstance(headers, dict): if not isinstance(headers, dict):
logger.warning( logger.warning(
@ -326,7 +327,6 @@ async def _load_http_mcp_tools(
) )
return tools return tools
# Connect and discover tools via HTTP
try: try:
async with ( async with (
streamablehttp_client(url, headers=headers) as (read, write, _), streamablehttp_client(url, headers=headers) as (read, write, _),
@ -334,7 +334,6 @@ async def _load_http_mcp_tools(
): ):
await session.initialize() await session.initialize()
# List available tools
response = await session.list_tools() response = await session.list_tools()
tool_definitions = [] tool_definitions = []
for tool in response.tools: for tool in response.tools:
@ -353,11 +352,15 @@ async def _load_http_mcp_tools(
f"'{url}' (connector {connector_id})" f"'{url}' (connector {connector_id})"
) )
# Create LangChain tools from definitions
for tool_def in tool_definitions: for tool_def in tool_definitions:
try: try:
tool = await _create_mcp_tool_from_definition_http( tool = await _create_mcp_tool_from_definition_http(
tool_def, url, headers tool_def,
url,
headers,
connector_name=connector_name,
connector_id=connector_id,
trusted_tools=trusted_tools,
) )
tools.append(tool) tools.append(tool)
except Exception as e: except Exception as e:
@ -398,14 +401,6 @@ async def load_mcp_tools(
Results are cached per search space for up to 5 minutes to avoid Results are cached per search space for up to 5 minutes to avoid
re-spawning MCP server processes on every chat message. re-spawning MCP server processes on every chat message.
Args:
session: Database session
search_space_id: User's search space ID
Returns:
List of LangChain StructuredTool instances
""" """
_evict_expired_mcp_cache() _evict_expired_mcp_cache()
@ -436,6 +431,7 @@ async def load_mcp_tools(
try: try:
config = connector.config or {} config = connector.config or {}
server_config = config.get("server_config", {}) server_config = config.get("server_config", {})
trusted_tools = config.get("trusted_tools", [])
if not server_config or not isinstance(server_config, dict): if not server_config or not isinstance(server_config, dict):
logger.warning( logger.warning(
@ -447,11 +443,17 @@ async def load_mcp_tools(
if transport in ("streamable-http", "http", "sse"): if transport in ("streamable-http", "http", "sse"):
connector_tools = await _load_http_mcp_tools( connector_tools = await _load_http_mcp_tools(
connector.id, connector.name, server_config connector.id,
connector.name,
server_config,
trusted_tools=trusted_tools,
) )
else: else:
connector_tools = await _load_stdio_mcp_tools( connector_tools = await _load_stdio_mcp_tools(
connector.id, connector.name, server_config connector.id,
connector.name,
server_config,
trusted_tools=trusted_tools,
) )
tools.extend(connector_tools) tools.extend(connector_tools)

View file

@ -2,7 +2,7 @@ import logging
from typing import Any from typing import Any
from langchain_core.tools import tool from langchain_core.tools import tool
from langgraph.types import interrupt from app.agents.new_chat.tools.hitl import request_approval
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.connectors.notion_history import NotionAPIError, NotionHistoryConnector from app.connectors.notion_history import NotionAPIError, NotionHistoryConnector
@ -99,61 +99,29 @@ def create_create_notion_page_tool(
} }
logger.info(f"Requesting approval for creating Notion page: '{title}'") logger.info(f"Requesting approval for creating Notion page: '{title}'")
approval = interrupt( result = request_approval(
{ action_type="notion_page_creation",
"type": "notion_page_creation", tool_name="create_notion_page",
"action": { params={
"tool": "create_notion_page",
"params": {
"title": title, "title": title,
"content": content, "content": content,
"parent_page_id": None, "parent_page_id": None,
"connector_id": connector_id, "connector_id": connector_id,
}, },
}, context=context,
"context": context,
}
) )
decisions_raw = ( if result.rejected:
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":
logger.info("Notion page creation rejected by user") logger.info("Notion page creation rejected by user")
return { return {
"status": "rejected", "status": "rejected",
"message": "User declined. The page was not created. Do not ask again or suggest alternatives.", "message": "User declined. Do not retry or suggest alternatives.",
} }
edited_action = decision.get("edited_action") final_title = result.params.get("title", title)
final_params: dict[str, Any] = {} final_content = result.params.get("content", content)
if isinstance(edited_action, dict): final_parent_page_id = result.params.get("parent_page_id")
edited_args = edited_action.get("args") final_connector_id = result.params.get("connector_id", connector_id)
if isinstance(edited_args, dict):
final_params = edited_args
elif isinstance(decision.get("args"), dict):
# Some interrupt payloads place args directly on the decision.
final_params = decision["args"]
final_title = final_params.get("title", title)
final_content = final_params.get("content", content)
final_parent_page_id = final_params.get("parent_page_id")
final_connector_id = final_params.get("connector_id", connector_id)
if not final_title or not final_title.strip(): if not final_title or not final_title.strip():
logger.error("Title is empty or contains only whitespace") logger.error("Title is empty or contains only whitespace")

View file

@ -2,7 +2,7 @@ import logging
from typing import Any from typing import Any
from langchain_core.tools import tool from langchain_core.tools import tool
from langgraph.types import interrupt from app.agents.new_chat.tools.hitl import request_approval
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.connectors.notion_history import NotionAPIError, NotionHistoryConnector from app.connectors.notion_history import NotionAPIError, NotionHistoryConnector
@ -114,63 +114,29 @@ def create_delete_notion_page_tool(
f"Requesting approval for deleting Notion page: '{page_title}' (page_id={page_id}, delete_from_kb={delete_from_kb})" f"Requesting approval for deleting Notion page: '{page_title}' (page_id={page_id}, delete_from_kb={delete_from_kb})"
) )
# Request approval before deleting result = request_approval(
approval = interrupt( action_type="notion_page_deletion",
{ tool_name="delete_notion_page",
"type": "notion_page_deletion", params={
"action": {
"tool": "delete_notion_page",
"params": {
"page_id": page_id, "page_id": page_id,
"connector_id": connector_id_from_context, "connector_id": connector_id_from_context,
"delete_from_kb": delete_from_kb, "delete_from_kb": delete_from_kb,
}, },
}, context=context,
"context": context,
}
) )
decisions_raw = ( if result.rejected:
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":
logger.info("Notion page deletion rejected by user") logger.info("Notion page deletion rejected by user")
return { return {
"status": "rejected", "status": "rejected",
"message": "User declined. The page was not deleted. Do not ask again or suggest alternatives.", "message": "User declined. Do not retry or suggest alternatives.",
} }
# Extract edited action arguments (if user modified the checkbox) final_page_id = result.params.get("page_id", page_id)
edited_action = decision.get("edited_action") final_connector_id = result.params.get(
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):
# Some interrupt payloads place args directly on the decision.
final_params = decision["args"]
final_page_id = final_params.get("page_id", page_id)
final_connector_id = final_params.get(
"connector_id", connector_id_from_context "connector_id", connector_id_from_context
) )
final_delete_from_kb = final_params.get("delete_from_kb", delete_from_kb) final_delete_from_kb = result.params.get("delete_from_kb", delete_from_kb)
logger.info( logger.info(
f"Deleting Notion page with final params: page_id={final_page_id}, connector_id={final_connector_id}, delete_from_kb={final_delete_from_kb}" f"Deleting Notion page with final params: page_id={final_page_id}, connector_id={final_connector_id}, delete_from_kb={final_delete_from_kb}"

View file

@ -2,7 +2,7 @@ import logging
from typing import Any from typing import Any
from langchain_core.tools import tool from langchain_core.tools import tool
from langgraph.types import interrupt from app.agents.new_chat.tools.hitl import request_approval
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.connectors.notion_history import NotionAPIError, NotionHistoryConnector from app.connectors.notion_history import NotionAPIError, NotionHistoryConnector
@ -127,59 +127,27 @@ def create_update_notion_page_tool(
logger.info( logger.info(
f"Requesting approval for updating Notion page: '{page_title}' (page_id={page_id})" f"Requesting approval for updating Notion page: '{page_title}' (page_id={page_id})"
) )
approval = interrupt( result = request_approval(
{ action_type="notion_page_update",
"type": "notion_page_update", tool_name="update_notion_page",
"action": { params={
"tool": "update_notion_page",
"params": {
"page_id": page_id, "page_id": page_id,
"content": content, "content": content,
"connector_id": connector_id_from_context, "connector_id": connector_id_from_context,
}, },
}, context=context,
"context": context,
}
) )
decisions_raw = ( if result.rejected:
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":
logger.info("Notion page update rejected by user") logger.info("Notion page update rejected by user")
return { return {
"status": "rejected", "status": "rejected",
"message": "User declined. The page was not updated. Do not ask again or suggest alternatives.", "message": "User declined. Do not retry or suggest alternatives.",
} }
edited_action = decision.get("edited_action") final_page_id = result.params.get("page_id", page_id)
final_params: dict[str, Any] = {} final_content = result.params.get("content", content)
if isinstance(edited_action, dict): final_connector_id = result.params.get(
edited_args = edited_action.get("args")
if isinstance(edited_args, dict):
final_params = edited_args
elif isinstance(decision.get("args"), dict):
# Some interrupt payloads place args directly on the decision.
final_params = decision["args"]
final_page_id = final_params.get("page_id", page_id)
final_content = final_params.get("content", content)
final_connector_id = final_params.get(
"connector_id", connector_id_from_context "connector_id", connector_id_from_context
) )

View file

@ -5,7 +5,7 @@ from pathlib import Path
from typing import Any from typing import Any
from langchain_core.tools import tool from langchain_core.tools import tool
from langgraph.types import interrupt from app.agents.new_chat.tools.hitl import request_approval
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select from sqlalchemy.future import select
@ -145,54 +145,28 @@ def create_create_onedrive_file_tool(
"parent_folders": parent_folders, "parent_folders": parent_folders,
} }
approval = interrupt( result = request_approval(
{ action_type="onedrive_file_creation",
"type": "onedrive_file_creation", tool_name="create_onedrive_file",
"action": { params={
"tool": "create_onedrive_file",
"params": {
"name": name, "name": name,
"content": content, "content": content,
"connector_id": None, "connector_id": None,
"parent_folder_id": None, "parent_folder_id": None,
}, },
}, context=context,
"context": context,
}
) )
decisions_raw = ( if result.rejected:
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:
return {"status": "error", "message": "No approval decision received"}
decision = decisions[0]
decision_type = decision.get("type") or decision.get("decision_type")
if decision_type == "reject":
return { return {
"status": "rejected", "status": "rejected",
"message": "User declined. The file was not created.", "message": "User declined. Do not retry or suggest alternatives.",
} }
final_params: dict[str, Any] = {} final_name = result.params.get("name", name)
edited_action = decision.get("edited_action") final_content = result.params.get("content", content)
if isinstance(edited_action, dict): final_connector_id = result.params.get("connector_id")
edited_args = edited_action.get("args") final_parent_folder_id = result.params.get("parent_folder_id")
if isinstance(edited_args, dict):
final_params = edited_args
elif isinstance(decision.get("args"), dict):
final_params = decision["args"]
final_name = final_params.get("name", name)
final_content = final_params.get("content", content)
final_connector_id = final_params.get("connector_id")
final_parent_folder_id = final_params.get("parent_folder_id")
if not final_name or not final_name.strip(): if not final_name or not final_name.strip():
return {"status": "error", "message": "File name cannot be empty."} return {"status": "error", "message": "File name cannot be empty."}

View file

@ -2,7 +2,7 @@ import logging
from typing import Any from typing import Any
from langchain_core.tools import tool from langchain_core.tools import tool
from langgraph.types import interrupt from app.agents.new_chat.tools.hitl import request_approval
from sqlalchemy import String, and_, cast, func from sqlalchemy import String, and_, cast, func
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select from sqlalchemy.future import select
@ -174,53 +174,26 @@ def create_delete_onedrive_file_tool(
}, },
} }
approval = interrupt( result = request_approval(
{ action_type="onedrive_file_trash",
"type": "onedrive_file_trash", tool_name="delete_onedrive_file",
"action": { params={
"tool": "delete_onedrive_file",
"params": {
"file_id": file_id, "file_id": file_id,
"connector_id": connector.id, "connector_id": connector.id,
"delete_from_kb": delete_from_kb, "delete_from_kb": delete_from_kb,
}, },
}, context=context,
"context": context,
}
) )
decisions_raw = ( if result.rejected:
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:
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 { return {
"status": "rejected", "status": "rejected",
"message": "User declined. The file was not trashed. Do not ask again or suggest alternatives.", "message": "User declined. Do not retry or suggest alternatives.",
} }
final_params: dict[str, Any] = {} final_file_id = result.params.get("file_id", file_id)
edited_action = decision.get("edited_action") final_connector_id = result.params.get("connector_id", connector.id)
if isinstance(edited_action, dict): final_delete_from_kb = result.params.get("delete_from_kb", delete_from_kb)
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_file_id = final_params.get("file_id", file_id)
final_connector_id = final_params.get("connector_id", connector.id)
final_delete_from_kb = final_params.get("delete_from_kb", delete_from_kb)
if final_connector_id != connector.id: if final_connector_id != connector.id:
result = await db_session.execute( result = await db_session.execute(

View file

@ -139,9 +139,19 @@ async def get_editor_content(
status_code=409, status_code=409,
detail="This document is still being processed. Please wait a moment and try again.", detail="This document is still being processed. Please wait a moment and try again.",
) )
if state == "failed":
reason = (
doc_status.get("reason", "Unknown error")
if isinstance(doc_status, dict)
else "Unknown error"
)
raise HTTPException(
status_code=422,
detail=f"Processing failed: {reason}. You can delete this document and re-upload it.",
)
raise HTTPException( raise HTTPException(
status_code=400, status_code=400,
detail="This document has no viewable content yet. It may still be syncing. Try again in a few seconds, or re-upload if the issue persists.", detail="This document has no content. It may not have been processed correctly. Try deleting and re-uploading it.",
) )
markdown_content = "\n\n".join(chunk_contents) markdown_content = "\n\n".join(chunk_contents)

View file

@ -636,9 +636,16 @@ async def delete_search_source_connector(
) )
# Delete the connector record # Delete the connector record
search_space_id = db_connector.search_space_id
is_mcp = db_connector.connector_type == SearchSourceConnectorType.MCP_CONNECTOR
await session.delete(db_connector) await session.delete(db_connector)
await session.commit() await session.commit()
if is_mcp:
from app.agents.new_chat.tools.mcp_tool import invalidate_mcp_tools_cache
invalidate_mcp_tools_cache(search_space_id)
logger.info( logger.info(
f"Connector {connector_id} ({connector_name}) deleted successfully. " f"Connector {connector_id} ({connector_name}) deleted successfully. "
f"Total documents deleted: {total_deleted}" f"Total documents deleted: {total_deleted}"
@ -3624,3 +3631,114 @@ async def get_drive_picker_token(
status_code=500, status_code=500,
detail="Failed to retrieve access token. Check server logs for details.", detail="Failed to retrieve access token. Check server logs for details.",
) from e ) from e
# =============================================================================
# MCP Tool Trust (Allow-List) Routes
# =============================================================================
class MCPTrustToolRequest(BaseModel):
tool_name: str
@router.post("/connectors/mcp/{connector_id}/trust-tool")
async def trust_mcp_tool(
connector_id: int,
body: MCPTrustToolRequest,
session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user),
):
"""Add a tool to the MCP connector's trusted (always-allow) list.
Once trusted, the tool executes without HITL approval on subsequent calls.
"""
try:
result = await session.execute(
select(SearchSourceConnector).filter(
SearchSourceConnector.id == connector_id,
SearchSourceConnector.connector_type
== SearchSourceConnectorType.MCP_CONNECTOR,
)
)
connector = result.scalars().first()
if not connector:
raise HTTPException(status_code=404, detail="MCP connector not found")
config = dict(connector.config or {})
trusted: list[str] = list(config.get("trusted_tools", []))
if body.tool_name not in trusted:
trusted.append(body.tool_name)
config["trusted_tools"] = trusted
connector.config = config
from sqlalchemy.orm.attributes import flag_modified
flag_modified(connector, "config")
await session.commit()
from app.agents.new_chat.tools.mcp_tool import invalidate_mcp_tools_cache
invalidate_mcp_tools_cache(connector.search_space_id)
return {"status": "ok", "trusted_tools": trusted}
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to trust MCP tool: {e!s}", exc_info=True)
await session.rollback()
raise HTTPException(
status_code=500, detail=f"Failed to trust tool: {e!s}"
) from e
@router.post("/connectors/mcp/{connector_id}/untrust-tool")
async def untrust_mcp_tool(
connector_id: int,
body: MCPTrustToolRequest,
session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user),
):
"""Remove a tool from the MCP connector's trusted list.
The tool will require HITL approval again on subsequent calls.
"""
try:
result = await session.execute(
select(SearchSourceConnector).filter(
SearchSourceConnector.id == connector_id,
SearchSourceConnector.connector_type
== SearchSourceConnectorType.MCP_CONNECTOR,
)
)
connector = result.scalars().first()
if not connector:
raise HTTPException(status_code=404, detail="MCP connector not found")
config = dict(connector.config or {})
trusted: list[str] = list(config.get("trusted_tools", []))
if body.tool_name in trusted:
trusted.remove(body.tool_name)
config["trusted_tools"] = trusted
connector.config = config
from sqlalchemy.orm.attributes import flag_modified
flag_modified(connector, "config")
await session.commit()
from app.agents.new_chat.tools.mcp_tool import invalidate_mcp_tools_cache
invalidate_mcp_tools_cache(connector.search_space_id)
return {"status": "ok", "trusted_tools": trusted}
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to untrust MCP tool: {e!s}", exc_info=True)
await session.rollback()
raise HTTPException(
status_code=500, detail=f"Failed to untrust tool: {e!s}"
) from e

View file

@ -798,7 +798,7 @@ export default function NewChatPage() {
}); });
} else { } else {
const tcId = `interrupt-${action.name}`; const tcId = `interrupt-${action.name}`;
addToolCall(contentPartsState, TOOLS_WITH_UI, tcId, action.name, action.args); addToolCall(contentPartsState, TOOLS_WITH_UI, tcId, action.name, action.args, true);
updateToolCall(contentPartsState, tcId, { updateToolCall(contentPartsState, tcId, {
result: { __interrupt__: true, ...interruptData }, result: { __interrupt__: true, ...interruptData },
}); });
@ -1125,7 +1125,7 @@ export default function NewChatPage() {
}); });
} else { } else {
const tcId = `interrupt-${action.name}`; const tcId = `interrupt-${action.name}`;
addToolCall(contentPartsState, TOOLS_WITH_UI, tcId, action.name, action.args); addToolCall(contentPartsState, TOOLS_WITH_UI, tcId, action.name, action.args, true);
updateToolCall(contentPartsState, tcId, { updateToolCall(contentPartsState, tcId, {
result: { result: {
__interrupt__: true, __interrupt__: true,

View file

@ -78,7 +78,7 @@ export function ProfileContent() {
</div> </div>
) : ( ) : (
<form onSubmit={handleSubmit} className="space-y-6"> <form onSubmit={handleSubmit} className="space-y-6">
<div className="rounded-lg border bg-card p-6"> <div className="rounded-lg bg-card">
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<div className="space-y-2"> <div className="space-y-2">
<Label>{t("profile_avatar")}</Label> <Label>{t("profile_avatar")}</Label>

View file

@ -499,10 +499,14 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
const empty = text.length === 0 && mentionedDocs.size === 0; const empty = text.length === 0 && mentionedDocs.size === 0;
setIsEmpty(empty); setIsEmpty(empty);
// Check for @ mentions // Unified trigger scan: find the leftmost @ or / in the current word.
// Whichever trigger was typed first owns the token — the other character
// is treated as part of the query, not as a separate trigger.
const selection = window.getSelection(); const selection = window.getSelection();
let shouldTriggerMention = false; let shouldTriggerMention = false;
let mentionQuery = ""; let mentionQuery = "";
let shouldTriggerAction = false;
let actionQuery = "";
if (selection && selection.rangeCount > 0) { if (selection && selection.rangeCount > 0) {
const range = selection.getRangeAt(0); const range = selection.getRangeAt(0);
@ -512,60 +516,37 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
const textContent = textNode.textContent || ""; const textContent = textNode.textContent || "";
const cursorPos = range.startOffset; const cursorPos = range.startOffset;
// Look for @ before cursor let wordStart = 0;
let atIndex = -1;
for (let i = cursorPos - 1; i >= 0; i--) { for (let i = cursorPos - 1; i >= 0; i--) {
if (textContent[i] === "@") {
atIndex = i;
break;
}
// Stop if we hit a space (@ must be at word boundary)
if (textContent[i] === " " || textContent[i] === "\n") { if (textContent[i] === " " || textContent[i] === "\n") {
wordStart = i + 1;
break; break;
} }
} }
if (atIndex !== -1) { let triggerChar: "@" | "/" | null = null;
const query = textContent.slice(atIndex + 1, cursorPos); let triggerIndex = -1;
// Only trigger if query doesn't start with space for (let i = wordStart; i < cursorPos; i++) {
if (textContent[i] === "@" || textContent[i] === "/") {
triggerChar = textContent[i] as "@" | "/";
triggerIndex = i;
break;
}
}
if (triggerChar === "@" && triggerIndex !== -1) {
const query = textContent.slice(triggerIndex + 1, cursorPos);
if (!query.startsWith(" ")) { if (!query.startsWith(" ")) {
shouldTriggerMention = true; shouldTriggerMention = true;
mentionQuery = query; mentionQuery = query;
} }
} } else if (triggerChar === "/" && triggerIndex !== -1) {
}
}
// Check for / actions (same pattern as @)
let shouldTriggerAction = false;
let actionQuery = "";
if (!shouldTriggerMention && selection && selection.rangeCount > 0) {
const range = selection.getRangeAt(0);
const textNode = range.startContainer;
if (textNode.nodeType === Node.TEXT_NODE) {
const textContent = textNode.textContent || "";
const cursorPos = range.startOffset;
let slashIndex = -1;
for (let i = cursorPos - 1; i >= 0; i--) {
if (textContent[i] === "/") {
slashIndex = i;
break;
}
if (textContent[i] === " " || textContent[i] === "\n") {
break;
}
}
if ( if (
slashIndex !== -1 && triggerIndex === 0 ||
(slashIndex === 0 || textContent[triggerIndex - 1] === " " ||
textContent[slashIndex - 1] === " " || textContent[triggerIndex - 1] === "\n"
textContent[slashIndex - 1] === "\n")
) { ) {
const query = textContent.slice(slashIndex + 1, cursorPos); const query = textContent.slice(triggerIndex + 1, cursorPos);
if (!query.startsWith(" ")) { if (!query.startsWith(" ")) {
shouldTriggerAction = true; shouldTriggerAction = true;
actionQuery = query; actionQuery = query;
@ -573,6 +554,7 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
} }
} }
} }
}
// If no @ found before cursor, check if text contains @ at all // If no @ found before cursor, check if text contains @ at all
// If text is empty or doesn't contain @, close the mention // If text is empty or doesn't contain @, close the mention

View file

@ -28,8 +28,7 @@ import {
import { AnimatePresence, motion } from "motion/react"; import { AnimatePresence, motion } from "motion/react";
import Image from "next/image"; import Image from "next/image";
import { useParams } from "next/navigation"; import { useParams } from "next/navigation";
import { type FC, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { type FC, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { import {
agentToolsAtom, agentToolsAtom,
disabledToolsAtom, disabledToolsAtom,
@ -124,15 +123,17 @@ const ThreadContent: FC = () => {
}} }}
/> />
<AuiIf condition={({ thread }) => !thread.isEmpty}>
<div className="grow" />
</AuiIf>
<ThreadPrimitive.ViewportFooter <ThreadPrimitive.ViewportFooter
className="aui-thread-viewport-footer sticky bottom-0 z-10 mx-auto flex w-full max-w-(--thread-max-width) flex-col gap-4 overflow-visible rounded-t-3xl bg-main-panel pb-4 md:pb-6" className="aui-thread-viewport-footer sticky bottom-0 z-10 mx-auto flex w-full max-w-(--thread-max-width) flex-col gap-4 overflow-visible rounded-t-3xl bg-main-panel pb-4 md:pb-6"
style={{ paddingBottom: "max(1rem, env(safe-area-inset-bottom))" }} style={{ paddingBottom: "max(1rem, env(safe-area-inset-bottom))" }}
> >
<ThreadScrollToBottom /> <ThreadScrollToBottom />
<AuiIf condition={({ thread }) => !thread.isEmpty}> <AuiIf condition={({ thread }) => !thread.isEmpty}>
<div className="fade-in slide-in-from-bottom-4 animate-in duration-500 ease-out fill-mode-both">
<Composer /> <Composer />
</div>
</AuiIf> </AuiIf>
</ThreadPrimitive.ViewportFooter> </ThreadPrimitive.ViewportFooter>
</ThreadPrimitive.Viewport> </ThreadPrimitive.Viewport>
@ -339,10 +340,7 @@ const Composer: FC = () => {
const [showPromptPicker, setShowPromptPicker] = useState(false); const [showPromptPicker, setShowPromptPicker] = useState(false);
const [mentionQuery, setMentionQuery] = useState(""); const [mentionQuery, setMentionQuery] = useState("");
const [actionQuery, setActionQuery] = useState(""); const [actionQuery, setActionQuery] = useState("");
const [containerPos, setContainerPos] = useState({ bottom: "200px", left: "50%", top: "auto" });
const editorRef = useRef<InlineMentionEditorRef>(null); const editorRef = useRef<InlineMentionEditorRef>(null);
const editorContainerRef = useRef<HTMLDivElement>(null);
const composerBoxRef = useRef<HTMLDivElement>(null);
const documentPickerRef = useRef<DocumentMentionPickerRef>(null); const documentPickerRef = useRef<DocumentMentionPickerRef>(null);
const promptPickerRef = useRef<PromptPickerRef>(null); const promptPickerRef = useRef<PromptPickerRef>(null);
const viewportRef = useRef<Element | null>(null); const viewportRef = useRef<Element | null>(null);
@ -363,38 +361,13 @@ const Composer: FC = () => {
viewportRef.current = document.querySelector(".aui-thread-viewport"); viewportRef.current = document.querySelector(".aui-thread-viewport");
}, []); }, []);
// Compute picker positions using ResizeObserver to avoid layout reads during render
useLayoutEffect(() => {
if (!editorContainerRef.current) return;
const updatePosition = () => {
if (!editorContainerRef.current) return;
const rect = editorContainerRef.current.getBoundingClientRect();
const composerRect = composerBoxRef.current?.getBoundingClientRect();
setContainerPos({
bottom: `${window.innerHeight - rect.top + 8}px`,
left: `${rect.left}px`,
top: composerRect ? `${composerRect.bottom + 8}px` : "auto",
});
};
updatePosition();
const ro = new ResizeObserver(updatePosition);
ro.observe(editorContainerRef.current);
if (composerBoxRef.current) {
ro.observe(composerBoxRef.current);
}
return () => ro.disconnect();
}, []);
const electronAPI = useElectronAPI(); const electronAPI = useElectronAPI();
const [clipboardInitialText, setClipboardInitialText] = useState<string | undefined>(); const [clipboardInitialText, setClipboardInitialText] = useState<string | undefined>();
const clipboardLoadedRef = useRef(false); const clipboardLoadedRef = useRef(false);
useEffect(() => { useEffect(() => {
if (!electronAPI || clipboardLoadedRef.current) return; if (!electronAPI || clipboardLoadedRef.current) return;
clipboardLoadedRef.current = true; clipboardLoadedRef.current = true;
electronAPI.getQuickAskText().then((text) => { electronAPI.getQuickAskText().then((text: string) => {
if (text) { if (text) {
setClipboardInitialText(text); setClipboardInitialText(text);
} }
@ -587,23 +560,15 @@ const Composer: FC = () => {
// Submit message (blocked during streaming, document picker open, or AI responding to another user) // Submit message (blocked during streaming, document picker open, or AI responding to another user)
const handleSubmit = useCallback(() => { const handleSubmit = useCallback(() => {
if (isThreadRunning || isBlockedByOtherUser) { if (isThreadRunning || isBlockedByOtherUser) return;
return; if (showDocumentPopover || showPromptPicker) return;
}
if (!showDocumentPopover && !showPromptPicker) {
if (clipboardInitialText) { if (clipboardInitialText) {
const userText = editorRef.current?.getText() ?? ""; const userText = editorRef.current?.getText() ?? "";
const combined = userText ? `${userText}\n\n${clipboardInitialText}` : clipboardInitialText; const combined = userText ? `${userText}\n\n${clipboardInitialText}` : clipboardInitialText;
aui.composer().setText(combined); aui.composer().setText(combined);
setClipboardInitialText(undefined); setClipboardInitialText(undefined);
} }
aui.composer().send();
editorRef.current?.clear();
setMentionedDocuments([]);
setSidebarDocs([]);
}
if (isThreadRunning || isBlockedByOtherUser) return;
if (showDocumentPopover) return;
const viewportEl = viewportRef.current; const viewportEl = viewportRef.current;
const heightBefore = viewportEl?.scrollHeight ?? 0; const heightBefore = viewportEl?.scrollHeight ?? 0;
@ -617,18 +582,14 @@ const Composer: FC = () => {
// assistant message so that scrolling-to-bottom actually positions the // assistant message so that scrolling-to-bottom actually positions the
// user message at the TOP of the viewport. That slack height is // user message at the TOP of the viewport. That slack height is
// calculated asynchronously (ResizeObserver → style → layout). // calculated asynchronously (ResizeObserver → style → layout).
// // Poll via rAF for ~500ms, re-scrolling whenever scrollHeight changes.
// We poll via rAF for ~2 s, re-scrolling whenever scrollHeight changes
// (user msg render → assistant placeholder → ViewportSlack min-height →
// first streamed content). Backup setTimeout calls cover cases where
// the batcher's 50 ms throttle delays the DOM update past the rAF.
const scrollToBottom = () => const scrollToBottom = () =>
threadViewportStore.getState().scrollToBottom({ behavior: "instant" }); threadViewportStore.getState().scrollToBottom({ behavior: "instant" });
let lastHeight = heightBefore; let lastHeight = heightBefore;
let frames = 0; let frames = 0;
let cancelled = false; let cancelled = false;
const POLL_FRAMES = 120; const POLL_FRAMES = 30;
const pollAndScroll = () => { const pollAndScroll = () => {
if (cancelled) return; if (cancelled) return;
@ -648,16 +609,11 @@ const Composer: FC = () => {
const t1 = setTimeout(scrollToBottom, 100); const t1 = setTimeout(scrollToBottom, 100);
const t2 = setTimeout(scrollToBottom, 300); const t2 = setTimeout(scrollToBottom, 300);
const t3 = setTimeout(scrollToBottom, 600);
// Cleanup if component unmounts during the polling window. The ref is
// checked inside pollAndScroll; timeouts are cleared in the return below.
// Store cleanup fn so it can be called from a useEffect cleanup if needed.
submitCleanupRef.current = () => { submitCleanupRef.current = () => {
cancelled = true; cancelled = true;
clearTimeout(t1); clearTimeout(t1);
clearTimeout(t2); clearTimeout(t2);
clearTimeout(t3);
}; };
}, [ }, [
showDocumentPopover, showDocumentPopover,
@ -705,28 +661,54 @@ const Composer: FC = () => {
); );
return ( return (
<ComposerPrimitive.Root <ComposerPrimitive.Root className="aui-composer-root relative flex w-full flex-col gap-2">
className="aui-composer-root relative flex w-full flex-col gap-2"
style={showPromptPicker && clipboardInitialText ? { marginBottom: 220 } : undefined}
>
<ChatSessionStatus <ChatSessionStatus
isAiResponding={isAiResponding} isAiResponding={isAiResponding}
respondingToUserId={respondingToUserId} respondingToUserId={respondingToUserId}
currentUserId={currentUser?.id ?? null} currentUserId={currentUser?.id ?? null}
members={members ?? []} members={members ?? []}
/> />
{showDocumentPopover && (
<div className="absolute bottom-full left-0 z-[9999] mb-2">
<DocumentMentionPicker
ref={documentPickerRef}
searchSpaceId={Number(search_space_id)}
onSelectionChange={handleDocumentsMention}
onDone={() => {
setShowDocumentPopover(false);
setMentionQuery("");
}}
initialSelectedDocuments={mentionedDocuments}
externalSearch={mentionQuery}
/>
</div>
)}
{showPromptPicker && (
<div <div
ref={composerBoxRef} className={cn(
className="aui-composer-attachment-dropzone flex w-full flex-col overflow-hidden rounded-2xl border-input bg-muted pt-2 outline-none transition-shadow" "absolute left-0 z-[9999]",
clipboardInitialText ? "top-full mt-2" : "bottom-full mb-2"
)}
> >
<PromptPicker
ref={promptPickerRef}
onSelect={clipboardInitialText ? handleQuickAskSelect : handleActionSelect}
onDone={() => {
setShowPromptPicker(false);
setActionQuery("");
}}
externalSearch={actionQuery}
/>
</div>
)}
<div className="aui-composer-attachment-dropzone flex w-full flex-col overflow-hidden rounded-2xl border-input bg-muted pt-2 outline-none transition-shadow">
{clipboardInitialText && ( {clipboardInitialText && (
<ClipboardChip <ClipboardChip
text={clipboardInitialText} text={clipboardInitialText}
onDismiss={() => setClipboardInitialText(undefined)} onDismiss={() => setClipboardInitialText(undefined)}
/> />
)} )}
{/* Inline editor with @mention support */} <div className="aui-composer-input-wrapper px-4 pt-3 pb-6">
<div ref={editorContainerRef} className="aui-composer-input-wrapper px-4 pt-3 pb-6">
<InlineMentionEditor <InlineMentionEditor
ref={editorRef} ref={editorRef}
placeholder={currentPlaceholder} placeholder={currentPlaceholder}
@ -741,49 +723,6 @@ const Composer: FC = () => {
className="min-h-[24px]" className="min-h-[24px]"
/> />
</div> </div>
{/* Document picker popover (portal to body for proper z-index stacking) */}
{showDocumentPopover &&
typeof document !== "undefined" &&
createPortal(
<DocumentMentionPicker
ref={documentPickerRef}
searchSpaceId={Number(search_space_id)}
onSelectionChange={handleDocumentsMention}
onDone={() => {
setShowDocumentPopover(false);
setMentionQuery("");
}}
initialSelectedDocuments={mentionedDocuments}
externalSearch={mentionQuery}
containerStyle={{
bottom: containerPos.bottom,
left: containerPos.left,
}}
/>,
document.body
)}
{showPromptPicker &&
typeof document !== "undefined" &&
createPortal(
<PromptPicker
ref={promptPickerRef}
onSelect={clipboardInitialText ? handleQuickAskSelect : handleActionSelect}
onDone={() => {
setShowPromptPicker(false);
setActionQuery("");
}}
externalSearch={actionQuery}
containerStyle={{
position: "fixed",
...(clipboardInitialText
? { top: containerPos.top }
: { bottom: containerPos.bottom }),
left: containerPos.left,
zIndex: 50,
}}
/>,
document.body
)}
<ComposerAction isBlockedByOtherUser={isBlockedByOtherUser} /> <ComposerAction isBlockedByOtherUser={isBlockedByOtherUser} />
<ConnectorIndicator showTrigger={false} /> <ConnectorIndicator showTrigger={false} />
<ConnectToolsBanner isThreadEmpty={isThreadEmpty} /> <ConnectToolsBanner isThreadEmpty={isThreadEmpty} />

View file

@ -1,14 +1,16 @@
import type { ToolCallMessagePartComponent } from "@assistant-ui/react"; import type { ToolCallMessagePartComponent } from "@assistant-ui/react";
import { CheckIcon, ChevronDownIcon, ChevronUpIcon, XCircleIcon } from "lucide-react"; import { CheckIcon, ChevronDownIcon, ChevronUpIcon, XCircleIcon } from "lucide-react";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { GenericHitlApprovalToolUI } from "@/components/tool-ui/generic-hitl-approval";
import { getToolIcon } from "@/contracts/enums/toolIcons"; import { getToolIcon } from "@/contracts/enums/toolIcons";
import { isInterruptResult } from "@/lib/hitl";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
function formatToolName(name: string): string { function formatToolName(name: string): string {
return name.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()); return name.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
} }
export const ToolFallback: ToolCallMessagePartComponent = ({ const DefaultToolFallbackInner: ToolCallMessagePartComponent = ({
toolName, toolName,
argsText, argsText,
result, result,
@ -145,3 +147,10 @@ export const ToolFallback: ToolCallMessagePartComponent = ({
</div> </div>
); );
}; };
export const ToolFallback: ToolCallMessagePartComponent = (props) => {
if (isInterruptResult(props.result)) {
return <GenericHitlApprovalToolUI {...props} />;
}
return <DefaultToolFallbackInner {...props} />;
};

View file

@ -82,11 +82,12 @@ export const DocumentNode = React.memo(function DocumentNode({
onContextMenuOpenChange, onContextMenuOpenChange,
}: DocumentNodeProps) { }: DocumentNodeProps) {
const statusState = doc.status?.state ?? "ready"; const statusState = doc.status?.state ?? "ready";
const isSelectable = statusState !== "pending" && statusState !== "processing"; const isFailed = statusState === "failed";
const isProcessing = statusState === "pending" || statusState === "processing";
const isUnavailable = isProcessing || isFailed;
const isSelectable = !isUnavailable;
const isEditable = const isEditable =
EDITABLE_DOCUMENT_TYPES.has(doc.document_type) && EDITABLE_DOCUMENT_TYPES.has(doc.document_type) && !isUnavailable;
statusState !== "pending" &&
statusState !== "processing";
const handleCheckChange = useCallback(() => { const handleCheckChange = useCallback(() => {
if (isSelectable) { if (isSelectable) {
@ -103,7 +104,6 @@ export const DocumentNode = React.memo(function DocumentNode({
[doc.id] [doc.id]
); );
const isProcessing = statusState === "pending" || statusState === "processing";
const [dropdownOpen, setDropdownOpen] = useState(false); const [dropdownOpen, setDropdownOpen] = useState(false);
const [exporting, setExporting] = useState<string | null>(null); const [exporting, setExporting] = useState<string | null>(null);
const [titleTooltipOpen, setTitleTooltipOpen] = useState(false); const [titleTooltipOpen, setTitleTooltipOpen] = useState(false);
@ -261,7 +261,7 @@ export const DocumentNode = React.memo(function DocumentNode({
className="w-40" className="w-40"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
<DropdownMenuItem onClick={() => onPreview(doc)} disabled={isProcessing}> <DropdownMenuItem onClick={() => onPreview(doc)} disabled={isUnavailable}>
<Eye className="mr-2 h-4 w-4" /> <Eye className="mr-2 h-4 w-4" />
Open Open
</DropdownMenuItem> </DropdownMenuItem>
@ -277,7 +277,7 @@ export const DocumentNode = React.memo(function DocumentNode({
</DropdownMenuItem> </DropdownMenuItem>
{onExport && ( {onExport && (
<DropdownMenuSub> <DropdownMenuSub>
<DropdownMenuSubTrigger disabled={isProcessing}> <DropdownMenuSubTrigger disabled={isUnavailable}>
<Download className="mr-2 h-4 w-4" /> <Download className="mr-2 h-4 w-4" />
Export Export
</DropdownMenuSubTrigger> </DropdownMenuSubTrigger>
@ -287,7 +287,7 @@ export const DocumentNode = React.memo(function DocumentNode({
</DropdownMenuSub> </DropdownMenuSub>
)} )}
{onVersionHistory && isVersionableType(doc.document_type) && ( {onVersionHistory && isVersionableType(doc.document_type) && (
<DropdownMenuItem disabled={isProcessing} onClick={() => onVersionHistory(doc)}> <DropdownMenuItem disabled={isUnavailable} onClick={() => onVersionHistory(doc)}>
<History className="mr-2 h-4 w-4" /> <History className="mr-2 h-4 w-4" />
Versions Versions
</DropdownMenuItem> </DropdownMenuItem>
@ -304,7 +304,7 @@ export const DocumentNode = React.memo(function DocumentNode({
{contextMenuOpen && ( {contextMenuOpen && (
<ContextMenuContent className="w-40" onClick={(e) => e.stopPropagation()}> <ContextMenuContent className="w-40" onClick={(e) => e.stopPropagation()}>
<ContextMenuItem onClick={() => onPreview(doc)} disabled={isProcessing}> <ContextMenuItem onClick={() => onPreview(doc)} disabled={isUnavailable}>
<Eye className="mr-2 h-4 w-4" /> <Eye className="mr-2 h-4 w-4" />
Open Open
</ContextMenuItem> </ContextMenuItem>
@ -320,7 +320,7 @@ export const DocumentNode = React.memo(function DocumentNode({
</ContextMenuItem> </ContextMenuItem>
{onExport && ( {onExport && (
<ContextMenuSub> <ContextMenuSub>
<ContextMenuSubTrigger disabled={isProcessing}> <ContextMenuSubTrigger disabled={isUnavailable}>
<Download className="mr-2 h-4 w-4" /> <Download className="mr-2 h-4 w-4" />
Export Export
</ContextMenuSubTrigger> </ContextMenuSubTrigger>
@ -330,7 +330,7 @@ export const DocumentNode = React.memo(function DocumentNode({
</ContextMenuSub> </ContextMenuSub>
)} )}
{onVersionHistory && isVersionableType(doc.document_type) && ( {onVersionHistory && isVersionableType(doc.document_type) && (
<ContextMenuItem disabled={isProcessing} onClick={() => onVersionHistory(doc)}> <ContextMenuItem disabled={isUnavailable} onClick={() => onVersionHistory(doc)}>
<History className="mr-2 h-4 w-4" /> <History className="mr-2 h-4 w-4" />
Versions Versions
</ContextMenuItem> </ContextMenuItem>

View file

@ -1,6 +1,6 @@
"use client"; "use client";
import { Download, FolderPlus, ListFilter, Loader2, Search, Upload, X } from "lucide-react"; import { FolderPlus, ListFilter, Search, Upload, X } from "lucide-react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import React, { useCallback, useMemo, useRef, useState } from "react"; import React, { useCallback, useMemo, useRef, useState } from "react";
import { useDocumentUploadDialog } from "@/components/assistant-ui/document-upload-popup"; import { useDocumentUploadDialog } from "@/components/assistant-ui/document-upload-popup";
@ -20,8 +20,6 @@ export function DocumentsFilters({
onToggleType, onToggleType,
activeTypes, activeTypes,
onCreateFolder, onCreateFolder,
onExportKB,
isExporting,
}: { }: {
typeCounts: Partial<Record<DocumentTypeEnum, number>>; typeCounts: Partial<Record<DocumentTypeEnum, number>>;
onSearch: (v: string) => void; onSearch: (v: string) => void;
@ -29,8 +27,6 @@ export function DocumentsFilters({
onToggleType: (type: DocumentTypeEnum, checked: boolean) => void; onToggleType: (type: DocumentTypeEnum, checked: boolean) => void;
activeTypes: DocumentTypeEnum[]; activeTypes: DocumentTypeEnum[];
onCreateFolder?: () => void; onCreateFolder?: () => void;
onExportKB?: () => void;
isExporting?: boolean;
}) { }) {
const t = useTranslations("documents"); const t = useTranslations("documents");
const id = React.useId(); const id = React.useId();
@ -88,31 +84,6 @@ export function DocumentsFilters({
</Tooltip> </Tooltip>
)} )}
{onExportKB && (
<Tooltip>
<TooltipTrigger asChild>
<ToggleGroupItem
value="export"
disabled={isExporting}
className="h-9 w-9 shrink-0 border-sidebar-border text-sidebar-foreground/60 hover:text-sidebar-foreground hover:border-sidebar-border bg-sidebar"
onClick={(e) => {
e.preventDefault();
onExportKB();
}}
>
{isExporting ? (
<Loader2 size={14} className="animate-spin" />
) : (
<Download size={14} />
)}
</ToggleGroupItem>
</TooltipTrigger>
<TooltipContent>
{isExporting ? "Exporting…" : "Export knowledge base"}
</TooltipContent>
</Tooltip>
)}
<Popover> <Popover>
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>

View file

@ -532,16 +532,14 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
const isOutOfSync = currentThreadState.id !== null && !params?.chat_id; const isOutOfSync = currentThreadState.id !== null && !params?.chat_id;
if (isOutOfSync) { if (isOutOfSync) {
// First sync Next.js router by navigating to the current chat's actual URL
// This updates the router's internal state to match the browser URL
resetCurrentThread(); resetCurrentThread();
router.replace(`/dashboard/${searchSpaceId}/new-chat/${currentThreadState.id}`); // Immediately set the browser URL so the page remounts with a clean /new-chat path
// Allow router to sync, then navigate to fresh new-chat window.history.replaceState(null, "", `/dashboard/${searchSpaceId}/new-chat`);
setTimeout(() => { // Force-remount the page component to reset all React state synchronously
router.push(`/dashboard/${searchSpaceId}/new-chat`); setChatResetKey((k) => k + 1);
}, 0); // Sync Next.js router internals so useParams/usePathname stay correct going forward
router.replace(`/dashboard/${searchSpaceId}/new-chat`);
} else { } else {
// Normal navigation - router is in sync
router.push(`/dashboard/${searchSpaceId}/new-chat`); router.push(`/dashboard/${searchSpaceId}/new-chat`);
} }
}, [router, searchSpaceId, currentThreadState.id, params?.chat_id, resetCurrentThread]); }, [router, searchSpaceId, currentThreadState.id, params?.chat_id, resetCurrentThread]);

View file

@ -406,22 +406,13 @@ export function DocumentsSidebar({
setFolderPickerOpen(true); setFolderPickerOpen(true);
}, []); }, []);
const [isExportingKB, setIsExportingKB] = useState(false); const [, setIsExportingKB] = useState(false);
const [exportWarningOpen, setExportWarningOpen] = useState(false); const [exportWarningOpen, setExportWarningOpen] = useState(false);
const [exportWarningContext, setExportWarningContext] = useState<{ const [exportWarningContext, setExportWarningContext] = useState<{
type: "kb" | "folder"; folder: FolderDisplay;
folder?: FolderDisplay;
pendingCount: number; pendingCount: number;
} | null>(null); } | null>(null);
const pendingDocuments = useMemo(
() =>
treeDocuments.filter(
(d) => d.status?.state === "pending" || d.status?.state === "processing"
),
[treeDocuments]
);
const doExport = useCallback(async (url: string, downloadName: string) => { const doExport = useCallback(async (url: string, downloadName: string) => {
const response = await authenticatedFetch(url, { method: "GET" }); const response = await authenticatedFetch(url, { method: "GET" });
if (!response.ok) { if (!response.ok) {
@ -440,50 +431,11 @@ export function DocumentsSidebar({
URL.revokeObjectURL(blobUrl); URL.revokeObjectURL(blobUrl);
}, []); }, []);
const handleExportKB = useCallback(async () => {
if (isExportingKB) return;
if (pendingDocuments.length > 0) {
setExportWarningContext({ type: "kb", pendingCount: pendingDocuments.length });
setExportWarningOpen(true);
return;
}
setIsExportingKB(true);
try {
await doExport(
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/search-spaces/${searchSpaceId}/export`,
"knowledge-base.zip"
);
toast.success("Knowledge base exported");
} catch (err) {
console.error("KB export failed:", err);
toast.error(err instanceof Error ? err.message : "Export failed");
} finally {
setIsExportingKB(false);
}
}, [searchSpaceId, isExportingKB, pendingDocuments.length, doExport]);
const handleExportWarningConfirm = useCallback(async () => { const handleExportWarningConfirm = useCallback(async () => {
setExportWarningOpen(false); setExportWarningOpen(false);
const ctx = exportWarningContext; const ctx = exportWarningContext;
if (!ctx) return; if (!ctx?.folder) return;
if (ctx.type === "kb") {
setIsExportingKB(true);
try {
await doExport(
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/search-spaces/${searchSpaceId}/export`,
"knowledge-base.zip"
);
toast.success("Knowledge base exported");
} catch (err) {
console.error("KB export failed:", err);
toast.error(err instanceof Error ? err.message : "Export failed");
} finally {
setIsExportingKB(false);
}
} else if (ctx.type === "folder" && ctx.folder) {
setIsExportingKB(true); setIsExportingKB(true);
try { try {
const safeName = const safeName =
@ -502,7 +454,6 @@ export function DocumentsSidebar({
} finally { } finally {
setIsExportingKB(false); setIsExportingKB(false);
} }
}
setExportWarningContext(null); setExportWarningContext(null);
}, [exportWarningContext, searchSpaceId, doExport]); }, [exportWarningContext, searchSpaceId, doExport]);
@ -530,7 +481,6 @@ export function DocumentsSidebar({
const folderPendingCount = getPendingCountInSubtree(folder.id); const folderPendingCount = getPendingCountInSubtree(folder.id);
if (folderPendingCount > 0) { if (folderPendingCount > 0) {
setExportWarningContext({ setExportWarningContext({
type: "folder",
folder, folder,
pendingCount: folderPendingCount, pendingCount: folderPendingCount,
}); });
@ -679,7 +629,8 @@ export function DocumentsSidebar({
(d) => (d) =>
d.folderId === parentId && d.folderId === parentId &&
d.status?.state !== "pending" && d.status?.state !== "pending" &&
d.status?.state !== "processing" d.status?.state !== "processing" &&
d.status?.state !== "failed"
); );
const childFolders = foldersByParent[String(parentId)] ?? []; const childFolders = foldersByParent[String(parentId)] ?? [];
const descendantDocs = childFolders.flatMap((cf) => collectSubtreeDocs(cf.id)); const descendantDocs = childFolders.flatMap((cf) => collectSubtreeDocs(cf.id));
@ -954,8 +905,6 @@ export function DocumentsSidebar({
onToggleType={onToggleType} onToggleType={onToggleType}
activeTypes={activeTypes} activeTypes={activeTypes}
onCreateFolder={() => handleCreateFolder(null)} onCreateFolder={() => handleCreateFolder(null)}
onExportKB={handleExportKB}
isExporting={isExportingKB}
/> />
</div> </div>

View file

@ -29,8 +29,6 @@ interface DocumentMentionPickerProps {
onDone: () => void; onDone: () => void;
initialSelectedDocuments?: Pick<Document, "id" | "title" | "document_type">[]; initialSelectedDocuments?: Pick<Document, "id" | "title" | "document_type">[];
externalSearch?: string; externalSearch?: string;
/** Positioning styles for the container */
containerStyle?: React.CSSProperties;
} }
const PAGE_SIZE = 20; const PAGE_SIZE = 20;
@ -75,7 +73,6 @@ export const DocumentMentionPicker = forwardRef<
onDone, onDone,
initialSelectedDocuments = [], initialSelectedDocuments = [],
externalSearch = "", externalSearch = "",
containerStyle,
}, },
ref ref
) { ) {
@ -394,19 +391,9 @@ export const DocumentMentionPicker = forwardRef<
[selectableDocuments, highlightedIndex, handleSelectDocument, onDone] [selectableDocuments, highlightedIndex, handleSelectDocument, onDone]
); );
// Hide popup when there are no documents to display (regardless of fetch state)
// Search continues in background; popup reappears when results arrive
if (!actualLoading && actualDocuments.length === 0) {
return null;
}
return ( return (
<div <div
className="fixed shadow-2xl rounded-lg border border-border dark:border-white/5 overflow-hidden bg-popover dark:bg-neutral-900 flex flex-col w-[280px] sm:w-[320px] select-none" className="shadow-2xl rounded-lg border border-border dark:border-white/5 overflow-hidden bg-popover dark:bg-neutral-900 flex flex-col w-[280px] sm:w-[320px] select-none"
style={{
zIndex: 9999,
...containerStyle,
}}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
role="listbox" role="listbox"
tabIndex={-1} tabIndex={-1}
@ -547,7 +534,11 @@ export const DocumentMentionPicker = forwardRef<
</div> </div>
)} )}
</div> </div>
) : null} ) : (
<div className="py-1 px-2">
<p className="px-3 py-2 text-xs text-muted-foreground">No matching documents</p>
</div>
)}
</div> </div>
</div> </div>
); );

View file

@ -1,7 +1,7 @@
"use client"; "use client";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import { Bot, Check, ChevronDown, Edit3, Eye, ImageIcon, Plus, Search, Zap } from "lucide-react"; import { Bot, Check, ChevronDown, Edit3, ImageIcon, Plus, ScanEye, Search, Zap } from "lucide-react";
import { type UIEvent, useCallback, useMemo, useState } from "react"; import { type UIEvent, useCallback, useMemo, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { import {
@ -387,7 +387,7 @@ export function ModelSelector({
</span> </span>
</> </>
) : ( ) : (
<Eye className="size-4 text-muted-foreground" /> <ScanEye className="size-4 text-muted-foreground" />
)} )}
</> </>
)} )}
@ -425,7 +425,7 @@ export function ModelSelector({
value="vision" value="vision"
className="gap-1.5 text-sm font-medium rounded-none text-muted-foreground transition-all duration-200 h-full bg-transparent data-[state=active]:bg-transparent shadow-none data-[state=active]:shadow-none border-b-[1.5px] border-transparent data-[state=active]:border-foreground dark:data-[state=active]:border-white data-[state=active]:text-foreground" className="gap-1.5 text-sm font-medium rounded-none text-muted-foreground transition-all duration-200 h-full bg-transparent data-[state=active]:bg-transparent shadow-none data-[state=active]:shadow-none border-b-[1.5px] border-transparent data-[state=active]:border-foreground dark:data-[state=active]:border-white data-[state=active]:text-foreground"
> >
<Eye className="size-3.5" /> <ScanEye className="size-3.5" />
Vision Vision
</TabsTrigger> </TabsTrigger>
</TabsList> </TabsList>
@ -458,9 +458,15 @@ export function ModelSelector({
> >
<CommandEmpty className="py-8 text-center"> <CommandEmpty className="py-8 text-center">
<div className="flex flex-col items-center gap-2"> <div className="flex flex-col items-center gap-2">
{llmGlobalConfigs?.length || llmUserConfigs?.length ? (
<>
<Search className="size-8 text-muted-foreground" /> <Search className="size-8 text-muted-foreground" />
<p className="text-sm text-muted-foreground">No models found</p> <p className="text-sm text-muted-foreground">No models found</p>
<p className="text-xs text-muted-foreground/60">Try a different search term</p> <p className="text-xs text-muted-foreground/60">Try a different search term</p>
</>
) : (
<p className="text-sm text-muted-foreground">No models found</p>
)}
</div> </div>
</CommandEmpty> </CommandEmpty>
@ -645,9 +651,15 @@ export function ModelSelector({
> >
<CommandEmpty className="py-8 text-center"> <CommandEmpty className="py-8 text-center">
<div className="flex flex-col items-center gap-2"> <div className="flex flex-col items-center gap-2">
{imageGlobalConfigs?.length || imageUserConfigs?.length ? (
<>
<Search className="size-8 text-muted-foreground" /> <Search className="size-8 text-muted-foreground" />
<p className="text-sm text-muted-foreground">No image models found</p> <p className="text-sm text-muted-foreground">No image models found</p>
<p className="text-xs text-muted-foreground/60">Try a different search term</p> <p className="text-xs text-muted-foreground/60">Try a different search term</p>
</>
) : (
<p className="text-sm text-muted-foreground">No image models found</p>
)}
</div> </div>
</CommandEmpty> </CommandEmpty>
@ -817,9 +829,15 @@ export function ModelSelector({
> >
<CommandEmpty className="py-8 text-center"> <CommandEmpty className="py-8 text-center">
<div className="flex flex-col items-center gap-2"> <div className="flex flex-col items-center gap-2">
{visionGlobalConfigs?.length || visionUserConfigs?.length ? (
<>
<Search className="size-8 text-muted-foreground" /> <Search className="size-8 text-muted-foreground" />
<p className="text-sm text-muted-foreground">No vision models found</p> <p className="text-sm text-muted-foreground">No vision models found</p>
<p className="text-xs text-muted-foreground/60">Try a different search term</p> <p className="text-xs text-muted-foreground/60">Try a different search term</p>
</>
) : (
<p className="text-sm text-muted-foreground">No vision models found</p>
)}
</div> </div>
</CommandEmpty> </CommandEmpty>

View file

@ -15,7 +15,7 @@ import {
import { promptsAtom } from "@/atoms/prompts/prompts-query.atoms"; import { promptsAtom } from "@/atoms/prompts/prompts-query.atoms";
import { userSettingsDialogAtom } from "@/atoms/settings/settings-dialog.atoms"; import { userSettingsDialogAtom } from "@/atoms/settings/settings-dialog.atoms";
import { Spinner } from "@/components/ui/spinner"; import { Skeleton } from "@/components/ui/skeleton";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
export interface PromptPickerRef { export interface PromptPickerRef {
@ -28,11 +28,10 @@ interface PromptPickerProps {
onSelect: (action: { name: string; prompt: string; mode: "transform" | "explore" }) => void; onSelect: (action: { name: string; prompt: string; mode: "transform" | "explore" }) => void;
onDone: () => void; onDone: () => void;
externalSearch?: string; externalSearch?: string;
containerStyle?: React.CSSProperties;
} }
export const PromptPicker = forwardRef<PromptPickerRef, PromptPickerProps>(function PromptPicker( export const PromptPicker = forwardRef<PromptPickerRef, PromptPickerProps>(function PromptPicker(
{ onSelect, onDone, externalSearch = "", containerStyle }, { onSelect, onDone, externalSearch = "" },
ref ref
) { ) {
const setUserSettingsDialog = useSetAtom(userSettingsDialogAtom); const setUserSettingsDialog = useSetAtom(userSettingsDialogAtom);
@ -60,13 +59,21 @@ export const PromptPicker = forwardRef<PromptPickerRef, PromptPickerProps>(funct
} }
} }
const createPromptIndex = filtered.length;
const totalItems = filtered.length + 1;
const handleSelect = useCallback( const handleSelect = useCallback(
(index: number) => { (index: number) => {
if (index === createPromptIndex) {
onDone();
setUserSettingsDialog({ open: true, initialTab: "prompts" });
return;
}
const action = filtered[index]; const action = filtered[index];
if (!action) return; if (!action) return;
onSelect({ name: action.name, prompt: action.prompt, mode: action.mode }); onSelect({ name: action.name, prompt: action.prompt, mode: action.mode });
}, },
[filtered, onSelect] [filtered, onSelect, createPromptIndex, onDone, setUserSettingsDialog]
); );
useEffect(() => { useEffect(() => {
@ -93,35 +100,56 @@ export const PromptPicker = forwardRef<PromptPickerRef, PromptPickerProps>(funct
() => ({ () => ({
selectHighlighted: () => handleSelect(highlightedIndex), selectHighlighted: () => handleSelect(highlightedIndex),
moveUp: () => { moveUp: () => {
if (filtered.length === 0) return;
shouldScrollRef.current = true; shouldScrollRef.current = true;
setHighlightedIndex((prev) => (prev > 0 ? prev - 1 : filtered.length - 1)); setHighlightedIndex((prev) => (prev > 0 ? prev - 1 : totalItems - 1));
}, },
moveDown: () => { moveDown: () => {
if (filtered.length === 0) return;
shouldScrollRef.current = true; shouldScrollRef.current = true;
setHighlightedIndex((prev) => (prev < filtered.length - 1 ? prev + 1 : 0)); setHighlightedIndex((prev) => (prev < totalItems - 1 ? prev + 1 : 0));
}, },
}), }),
[filtered.length, highlightedIndex, handleSelect] [totalItems, highlightedIndex, handleSelect]
); );
return ( return (
<div <div className="shadow-2xl rounded-lg border border-border dark:border-white/5 overflow-hidden bg-popover dark:bg-neutral-900 flex flex-col w-[280px] sm:w-[320px] select-none">
className="w-64 rounded-lg border bg-popover shadow-lg overflow-hidden" <div ref={scrollContainerRef} className="max-h-[180px] sm:max-h-[280px] overflow-y-auto">
style={containerStyle}
>
<div ref={scrollContainerRef} className="max-h-48 overflow-y-auto py-1">
{isLoading ? ( {isLoading ? (
<div className="flex items-center justify-center py-3"> <div className="py-1 px-2">
<Spinner className="size-4" /> <div className="px-3 py-2">
<Skeleton className="h-[16px] w-24" />
</div>
{["a", "b", "c", "d", "e"].map((id, i) => (
<div
key={id}
className={cn(
"w-full flex items-center gap-2 px-3 py-2 text-left rounded-md",
i >= 3 && "hidden sm:flex"
)}
>
<span className="shrink-0">
<Skeleton className="h-4 w-4" />
</span>
<span className="flex-1 text-sm">
<Skeleton className="h-[20px]" style={{ width: `${60 + ((i * 7) % 30)}%` }} />
</span>
</div>
))}
</div> </div>
) : isError ? ( ) : isError ? (
<div className="py-1 px-2">
<p className="px-3 py-2 text-xs text-destructive">Failed to load prompts</p> <p className="px-3 py-2 text-xs text-destructive">Failed to load prompts</p>
</div>
) : filtered.length === 0 ? ( ) : filtered.length === 0 ? (
<div className="py-1 px-2">
<p className="px-3 py-2 text-xs text-muted-foreground">No matching prompts</p> <p className="px-3 py-2 text-xs text-muted-foreground">No matching prompts</p>
</div>
) : ( ) : (
filtered.map((action, index) => ( <div className="py-1 px-2">
<div className="px-3 py-2 text-xs font-bold text-muted-foreground/55">
Saved Prompts
</div>
{filtered.map((action, index) => (
<button <button
key={action.id} key={action.id}
ref={(el) => { ref={(el) => {
@ -132,31 +160,39 @@ export const PromptPicker = forwardRef<PromptPickerRef, PromptPickerProps>(funct
onClick={() => handleSelect(index)} onClick={() => handleSelect(index)}
onMouseEnter={() => setHighlightedIndex(index)} onMouseEnter={() => setHighlightedIndex(index)}
className={cn( className={cn(
"flex w-full items-center gap-2 px-3 py-1.5 text-sm cursor-pointer", "w-full flex items-center gap-2 px-3 py-2 text-left text-sm transition-colors rounded-md cursor-pointer",
index === highlightedIndex ? "bg-accent" : "hover:bg-accent/50" index === highlightedIndex && "bg-accent"
)} )}
> >
<span className="text-muted-foreground"> <span className="shrink-0 text-muted-foreground">
<Zap className="size-3.5" /> <Zap className="size-4" />
</span> </span>
<span className="truncate">{action.name}</span> <span className="flex-1 text-sm truncate">{action.name}</span>
</button> </button>
)) ))}
)}
<div className="my-1 h-px bg-border mx-2" /> <div className="mx-2 my-1 border-t border-border dark:border-white/5" />
<button <button
type="button" ref={(el) => {
onClick={() => { if (el) itemRefs.current.set(createPromptIndex, el);
onDone(); else itemRefs.current.delete(createPromptIndex);
setUserSettingsDialog({ open: true, initialTab: "prompts" });
}} }}
className="flex w-full items-center gap-2 px-3 py-1.5 text-sm text-muted-foreground hover:text-foreground hover:bg-accent/50 cursor-pointer" type="button"
onClick={() => handleSelect(createPromptIndex)}
onMouseEnter={() => setHighlightedIndex(createPromptIndex)}
className={cn(
"w-full flex items-center gap-2 px-3 py-2 text-left text-sm transition-colors rounded-md cursor-pointer text-muted-foreground",
highlightedIndex === createPromptIndex ? "bg-accent text-foreground" : "hover:text-foreground hover:bg-accent/50"
)}
> >
<Plus className="size-3.5" /> <span className="shrink-0">
<Plus className="size-4" />
</span>
<span>Create prompt</span> <span>Create prompt</span>
</button> </button>
</div> </div>
)}
</div>
</div> </div>
); );
}); });

View file

@ -10,7 +10,6 @@ import {
MessageSquareQuote, MessageSquareQuote,
RefreshCw, RefreshCw,
Trash2, Trash2,
Wand2,
} from "lucide-react"; } from "lucide-react";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { membersAtom, myAccessAtom } from "@/atoms/members/members-query.atoms"; import { membersAtom, myAccessAtom } from "@/atoms/members/members-query.atoms";
@ -43,7 +42,7 @@ import { useMediaQuery } from "@/hooks/use-media-query";
import { getProviderIcon } from "@/lib/provider-icons"; import { getProviderIcon } from "@/lib/provider-icons";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
interface ModelConfigManagerProps { interface AgentModelManagerProps {
searchSpaceId: number; searchSpaceId: number;
} }
@ -55,7 +54,7 @@ function getInitials(name: string): string {
return name.slice(0, 2).toUpperCase(); return name.slice(0, 2).toUpperCase();
} }
export function ModelConfigManager({ searchSpaceId }: ModelConfigManagerProps) { export function AgentModelManager({ searchSpaceId }: AgentModelManagerProps) {
const isDesktop = useMediaQuery("(min-width: 768px)"); const isDesktop = useMediaQuery("(min-width: 768px)");
// Mutations // Mutations
const { mutateAsync: deleteConfig, isPending: isDeleting } = useAtomValue( const { mutateAsync: deleteConfig, isPending: isDeleting } = useAtomValue(
@ -208,29 +207,27 @@ export function ModelConfigManager({ searchSpaceId }: ModelConfigManagerProps) {
{["skeleton-a", "skeleton-b", "skeleton-c"].map((key) => ( {["skeleton-a", "skeleton-b", "skeleton-c"].map((key) => (
<Card key={key} className="border-border/60"> <Card key={key} className="border-border/60">
<CardContent className="p-4 flex flex-col gap-3"> <CardContent className="p-4 flex flex-col gap-3">
{/* Header */} {/* Header: Icon + Name */}
<div className="flex items-start justify-between gap-2"> <div className="flex items-start gap-2.5">
<Skeleton className="size-4 rounded-full shrink-0 mt-0.5" />
<div className="space-y-1.5 flex-1 min-w-0"> <div className="space-y-1.5 flex-1 min-w-0">
<Skeleton className="h-4 w-28 md:w-32" /> <Skeleton className="h-4 w-28 md:w-32" />
<Skeleton className="h-3 w-40 md:w-48" /> <Skeleton className="h-3 w-40 md:w-48" />
</div> </div>
</div> </div>
{/* Provider + Model */}
<div className="flex items-center gap-2">
<Skeleton className="h-5 w-16 rounded-full" />
<Skeleton className="h-5 w-24 rounded-md" />
</div>
{/* Feature badges */} {/* Feature badges */}
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<Skeleton className="h-5 w-20 rounded-full" /> <Skeleton className="h-5 w-20 rounded-full" />
<Skeleton className="h-5 w-16 rounded-full" />
</div> </div>
{/* Footer */} {/* Footer */}
<div className="flex items-center gap-2 pt-2 border-t border-border/40"> <div className="flex items-center pt-2 border-t border-border/40">
<Skeleton className="h-3 w-20" /> <Skeleton className="h-3 w-20 flex-1" />
<Skeleton className="h-3 w-3 rounded-full shrink-0 mx-1" />
<div className="flex-1 flex items-center justify-end gap-1.5">
<Skeleton className="h-4 w-4 rounded-full" /> <Skeleton className="h-4 w-4 rounded-full" />
<Skeleton className="h-3 w-16" /> <Skeleton className="h-3 w-16" />
</div> </div>
</div>
</CardContent> </CardContent>
</Card> </Card>
))} ))}
@ -262,8 +259,12 @@ export function ModelConfigManager({ searchSpaceId }: ModelConfigManagerProps) {
<div key={config.id}> <div key={config.id}>
<Card className="group relative overflow-hidden transition-all duration-200 border-border/60 hover:shadow-md h-full"> <Card className="group relative overflow-hidden transition-all duration-200 border-border/60 hover:shadow-md h-full">
<CardContent className="p-4 flex flex-col gap-3 h-full"> <CardContent className="p-4 flex flex-col gap-3 h-full">
{/* Header: Name + Actions */} {/* Header: Icon + Name + Actions */}
<div className="flex items-start justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2.5 min-w-0 flex-1">
<div className="shrink-0">
{getProviderIcon(config.provider, { className: "size-4" })}
</div>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<h4 className="text-sm font-semibold tracking-tight truncate"> <h4 className="text-sm font-semibold tracking-tight truncate">
{config.name} {config.name}
@ -274,8 +275,9 @@ export function ModelConfigManager({ searchSpaceId }: ModelConfigManagerProps) {
</p> </p>
)} )}
</div> </div>
</div>
{(canUpdate || canDelete) && ( {(canUpdate || canDelete) && (
<div className="flex items-center gap-0.5 shrink-0 sm:opacity-0 sm:group-hover:opacity-100 transition-opacity duration-150"> <div className="flex items-center gap-1 shrink-0 sm:w-0 sm:overflow-hidden sm:group-hover:w-auto sm:opacity-0 sm:group-hover:opacity-100 transition-all duration-150">
{canUpdate && ( {canUpdate && (
<TooltipProvider> <TooltipProvider>
<Tooltip open={isDesktop ? undefined : false}> <Tooltip open={isDesktop ? undefined : false}>
@ -284,7 +286,7 @@ export function ModelConfigManager({ searchSpaceId }: ModelConfigManagerProps) {
variant="ghost" variant="ghost"
size="icon" size="icon"
onClick={() => openEditDialog(config)} onClick={() => openEditDialog(config)}
className="h-7 w-7 text-muted-foreground hover:text-foreground" className="h-7 w-7 rounded-lg text-muted-foreground hover:text-foreground"
> >
<Edit3 className="h-3 w-3" /> <Edit3 className="h-3 w-3" />
</Button> </Button>
@ -301,7 +303,7 @@ export function ModelConfigManager({ searchSpaceId }: ModelConfigManagerProps) {
variant="ghost" variant="ghost"
size="icon" size="icon"
onClick={() => setConfigToDelete(config)} onClick={() => setConfigToDelete(config)}
className="h-7 w-7 text-muted-foreground hover:text-destructive" className="h-7 w-7 rounded-lg text-muted-foreground hover:text-destructive"
> >
<Trash2 className="h-3 w-3" /> <Trash2 className="h-3 w-3" />
</Button> </Button>
@ -314,20 +316,12 @@ export function ModelConfigManager({ searchSpaceId }: ModelConfigManagerProps) {
)} )}
</div> </div>
{/* Provider + Model */}
<div className="flex items-center gap-2 flex-wrap">
{getProviderIcon(config.provider, { className: "size-3.5 shrink-0" })}
<code className="text-[11px] font-mono text-muted-foreground bg-muted/60 px-2 py-0.5 rounded-md truncate max-w-[160px]">
{config.model_name}
</code>
</div>
{/* Feature badges */} {/* Feature badges */}
<div className="flex items-center gap-1.5 flex-wrap"> <div className="flex items-center gap-1.5 flex-wrap">
{config.citations_enabled && ( {config.citations_enabled && (
<Badge <Badge
variant="outline" variant="secondary"
className="text-[10px] px-1.5 py-0.5 border-emerald-500/30 text-emerald-700 dark:text-emerald-300 bg-emerald-500/5" className="text-[10px] px-1.5 py-0.5 border-0 text-muted-foreground bg-muted"
> >
<MessageSquareQuote className="h-2.5 w-2.5 mr-1" /> <MessageSquareQuote className="h-2.5 w-2.5 mr-1" />
Citations Citations
@ -336,8 +330,8 @@ export function ModelConfigManager({ searchSpaceId }: ModelConfigManagerProps) {
{!config.use_default_system_instructions && {!config.use_default_system_instructions &&
config.system_instructions && ( config.system_instructions && (
<Badge <Badge
variant="outline" variant="secondary"
className="text-[10px] px-1.5 py-0.5 border-blue-500/30 text-blue-700 dark:text-blue-300 bg-blue-500/5" className="text-[10px] px-1.5 py-0.5 border-0 text-muted-foreground bg-muted"
> >
<FileText className="h-2.5 w-2.5 mr-1" /> <FileText className="h-2.5 w-2.5 mr-1" />
Custom Custom
@ -346,8 +340,8 @@ export function ModelConfigManager({ searchSpaceId }: ModelConfigManagerProps) {
</div> </div>
{/* Footer: Date + Creator */} {/* Footer: Date + Creator */}
<div className="flex items-center gap-2 pt-2 border-t border-border/40 mt-auto"> <div className="flex items-center pt-2 border-t border-border/40 mt-auto">
<span className="text-[11px] text-muted-foreground/60"> <span className="shrink-0 text-[11px] text-muted-foreground/60 whitespace-nowrap">
{new Date(config.created_at).toLocaleDateString(undefined, { {new Date(config.created_at).toLocaleDateString(undefined, {
year: "numeric", year: "numeric",
month: "short", month: "short",
@ -356,11 +350,11 @@ export function ModelConfigManager({ searchSpaceId }: ModelConfigManagerProps) {
</span> </span>
{member && ( {member && (
<> <>
<Dot className="h-4 w-4 text-muted-foreground/30" /> <Dot className="h-4 w-4 text-muted-foreground/30 shrink-0" />
<TooltipProvider> <TooltipProvider>
<Tooltip open={isDesktop ? undefined : false}> <Tooltip open={isDesktop ? undefined : false}>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<div className="flex items-center gap-1.5 cursor-default"> <div className="min-w-0 flex items-center gap-1.5 cursor-default">
<Avatar className="size-4.5 shrink-0"> <Avatar className="size-4.5 shrink-0">
{member.avatarUrl && ( {member.avatarUrl && (
<AvatarImage src={member.avatarUrl} alt={member.name} /> <AvatarImage src={member.avatarUrl} alt={member.name} />
@ -369,7 +363,7 @@ export function ModelConfigManager({ searchSpaceId }: ModelConfigManagerProps) {
{getInitials(member.name)} {getInitials(member.name)}
</AvatarFallback> </AvatarFallback>
</Avatar> </Avatar>
<span className="text-[11px] text-muted-foreground/60 truncate max-w-[120px]"> <span className="text-[11px] text-muted-foreground/60 truncate">
{member.name} {member.name}
</span> </span>
</div> </div>

View file

@ -2,18 +2,18 @@
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import { Info } from "lucide-react"; import { FolderArchive, Info } from "lucide-react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { updateSearchSpaceMutationAtom } from "@/atoms/search-spaces/search-space-mutation.atoms"; import { updateSearchSpaceMutationAtom } from "@/atoms/search-spaces/search-space-mutation.atoms";
import { Alert, AlertDescription } from "@/components/ui/alert"; import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service"; import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service";
import { authenticatedFetch } from "@/lib/auth-utils";
import { cacheKeys } from "@/lib/query-client/cache-keys"; import { cacheKeys } from "@/lib/query-client/cache-keys";
import { Spinner } from "../ui/spinner"; import { Spinner } from "../ui/spinner";
@ -40,6 +40,37 @@ export function GeneralSettingsManager({ searchSpaceId }: GeneralSettingsManager
const [name, setName] = useState(""); const [name, setName] = useState("");
const [description, setDescription] = useState(""); const [description, setDescription] = useState("");
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [isExporting, setIsExporting] = useState(false);
const handleExportKB = useCallback(async () => {
if (isExporting) return;
setIsExporting(true);
try {
const response = await authenticatedFetch(
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/search-spaces/${searchSpaceId}/export`,
{ method: "GET" }
);
if (!response.ok) {
const errorData = await response.json().catch(() => ({ detail: "Export failed" }));
throw new Error(errorData.detail || "Export failed");
}
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "knowledge-base.zip";
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
toast.success("Knowledge base exported");
} catch (err) {
console.error("KB export failed:", err);
toast.error(err instanceof Error ? err.message : "Export failed");
} finally {
setIsExporting(false);
}
}, [searchSpaceId, isExporting]);
// Initialize state from fetched search space // Initialize state from fetched search space
useEffect(() => { useEffect(() => {
@ -83,16 +114,10 @@ export function GeneralSettingsManager({ searchSpaceId }: GeneralSettingsManager
if (loading) { if (loading) {
return ( return (
<div className="space-y-4 md:space-y-6"> <div className="space-y-4 md:space-y-6">
<Card> <div className="flex flex-col gap-6">
<CardHeader className="px-3 md:px-6 pt-3 md:pt-6 pb-2 md:pb-3">
<Skeleton className="h-5 md:h-6 w-36 md:w-48" />
<Skeleton className="h-3 md:h-4 w-full max-w-md mt-2" />
</CardHeader>
<CardContent className="space-y-3 md:space-y-4 px-3 md:px-6 pb-3 md:pb-6">
<Skeleton className="h-10 md:h-12 w-full" /> <Skeleton className="h-10 md:h-12 w-full" />
<Skeleton className="h-10 md:h-12 w-full" /> <Skeleton className="h-10 md:h-12 w-full" />
</CardContent> </div>
</Card>
</div> </div>
); );
} }
@ -113,23 +138,14 @@ export function GeneralSettingsManager({ searchSpaceId }: GeneralSettingsManager
<Alert className="bg-muted/50 py-3 md:py-4"> <Alert className="bg-muted/50 py-3 md:py-4">
<Info className="h-3 w-3 md:h-4 md:w-4 shrink-0" /> <Info className="h-3 w-3 md:h-4 md:w-4 shrink-0" />
<AlertDescription className="text-xs md:text-sm"> <AlertDescription className="text-xs md:text-sm">
Update your search space name and description. These details help identify and organize Update your search space name and description.
your workspace.
</AlertDescription> </AlertDescription>
</Alert> </Alert>
{/* Search Space Details Card */} <form onSubmit={onSubmit} className="space-y-6">
<form onSubmit={onSubmit} className="space-y-4 md:space-y-6"> <div className="flex flex-col gap-6">
<Card> <div className="space-y-2">
<CardHeader className="px-3 md:px-6 pt-3 md:pt-6 pb-2 md:pb-3"> <Label htmlFor="search-space-name">
<CardTitle className="text-base md:text-lg">Search Space Details</CardTitle>
<CardDescription className="text-xs md:text-sm">
Manage the basic information for this search space.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4 md:space-y-5 px-3 md:px-6 pb-3 md:pb-6">
<div className="space-y-1.5 md:space-y-2">
<Label htmlFor="search-space-name" className="text-sm md:text-base font-medium">
{t("general_name_label")} {t("general_name_label")}
</Label> </Label>
<Input <Input
@ -137,18 +153,14 @@ export function GeneralSettingsManager({ searchSpaceId }: GeneralSettingsManager
placeholder={t("general_name_placeholder")} placeholder={t("general_name_placeholder")}
value={name} value={name}
onChange={(e) => setName(e.target.value)} onChange={(e) => setName(e.target.value)}
className="text-sm md:text-base h-9 md:h-10"
/> />
<p className="text-[10px] md:text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{t("general_name_description")} {t("general_name_description")}
</p> </p>
</div> </div>
<div className="space-y-1.5 md:space-y-2"> <div className="space-y-2">
<Label <Label htmlFor="search-space-description">
htmlFor="search-space-description"
className="text-sm md:text-base font-medium"
>
{t("general_description_label")}{" "} {t("general_description_label")}{" "}
<span className="text-muted-foreground font-normal">({tCommon("optional")})</span> <span className="text-muted-foreground font-normal">({tCommon("optional")})</span>
</Label> </Label>
@ -157,17 +169,14 @@ export function GeneralSettingsManager({ searchSpaceId }: GeneralSettingsManager
placeholder={t("general_description_placeholder")} placeholder={t("general_description_placeholder")}
value={description} value={description}
onChange={(e) => setDescription(e.target.value)} onChange={(e) => setDescription(e.target.value)}
className="text-sm md:text-base h-9 md:h-10"
/> />
<p className="text-[10px] md:text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{t("general_description_description")} {t("general_description_description")}
</p> </p>
</div> </div>
</CardContent> </div>
</Card>
{/* Action Buttons */} <div className="flex justify-end">
<div className="flex justify-end pt-3 md:pt-4">
<Button <Button
type="submit" type="submit"
variant="outline" variant="outline"
@ -179,6 +188,29 @@ export function GeneralSettingsManager({ searchSpaceId }: GeneralSettingsManager
</Button> </Button>
</div> </div>
</form> </form>
<div className="border-t pt-6 flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div className="space-y-1">
<Label>Export knowledge base</Label>
<p className="text-xs text-muted-foreground">
Download all documents in this search space as a ZIP of markdown files.
</p>
</div>
<Button
type="button"
variant="secondary"
size="sm"
disabled={isExporting}
onClick={handleExportKB}
className="relative w-fit shrink-0"
>
<span className={isExporting ? "opacity-0" : ""}>
<FolderArchive className="h-3 w-3 opacity-60" />
</span>
<span className={isExporting ? "opacity-0" : ""}>Export</span>
{isExporting && <Spinner size="sm" className="absolute" />}
</Button>
</div>
</div> </div>
); );
} }

View file

@ -1,7 +1,7 @@
"use client"; "use client";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import { AlertCircle, Dot, Edit3, Info, RefreshCw, Trash2, Wand2 } from "lucide-react"; import { AlertCircle, Dot, Edit3, Info, RefreshCw, Trash2 } from "lucide-react";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { deleteImageGenConfigMutationAtom } from "@/atoms/image-gen-config/image-gen-config-mutation.atoms"; import { deleteImageGenConfigMutationAtom } from "@/atoms/image-gen-config/image-gen-config-mutation.atoms";
import { import {
@ -209,21 +209,21 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
{["skeleton-a", "skeleton-b", "skeleton-c"].map((key) => ( {["skeleton-a", "skeleton-b", "skeleton-c"].map((key) => (
<Card key={key} className="border-border/60"> <Card key={key} className="border-border/60">
<CardContent className="p-4 flex flex-col gap-3"> <CardContent className="p-4 flex flex-col gap-3">
<div className="flex items-start justify-between gap-2"> <div className="flex items-center gap-2.5">
<Skeleton className="size-4 rounded-full shrink-0" />
<div className="space-y-1.5 flex-1 min-w-0"> <div className="space-y-1.5 flex-1 min-w-0">
<Skeleton className="h-4 w-28 md:w-32" /> <Skeleton className="h-4 w-28 md:w-32" />
<Skeleton className="h-3 w-40 md:w-48" /> <Skeleton className="h-3 w-40 md:w-48" />
</div> </div>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center pt-2 border-t border-border/40">
<Skeleton className="h-5 w-16 rounded-full" /> <Skeleton className="h-3 w-20 flex-1" />
<Skeleton className="h-5 w-24 rounded-md" /> <Skeleton className="h-3 w-3 rounded-full shrink-0 mx-1" />
</div> <div className="flex-1 flex items-center justify-end gap-1.5">
<div className="flex items-center gap-2 pt-2 border-t border-border/40">
<Skeleton className="h-3 w-20" />
<Skeleton className="h-4 w-4 rounded-full" /> <Skeleton className="h-4 w-4 rounded-full" />
<Skeleton className="h-3 w-16" /> <Skeleton className="h-3 w-16" />
</div> </div>
</div>
</CardContent> </CardContent>
</Card> </Card>
))} ))}
@ -255,8 +255,12 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
<div key={config.id}> <div key={config.id}>
<Card className="group relative overflow-hidden transition-all duration-200 border-border/60 hover:shadow-md h-full"> <Card className="group relative overflow-hidden transition-all duration-200 border-border/60 hover:shadow-md h-full">
<CardContent className="p-4 flex flex-col gap-3 h-full"> <CardContent className="p-4 flex flex-col gap-3 h-full">
{/* Header: Name + Actions */} {/* Header: Icon + Name + Actions */}
<div className="flex items-start justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2.5 min-w-0 flex-1">
<div className="shrink-0">
{getProviderIcon(config.provider, { className: "size-4" })}
</div>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<h4 className="text-sm font-semibold tracking-tight truncate"> <h4 className="text-sm font-semibold tracking-tight truncate">
{config.name} {config.name}
@ -267,8 +271,9 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
</p> </p>
)} )}
</div> </div>
</div>
{(canUpdate || canDelete) && ( {(canUpdate || canDelete) && (
<div className="flex items-center gap-0.5 shrink-0 sm:opacity-0 sm:group-hover:opacity-100 transition-opacity duration-150"> <div className="flex items-center gap-1 shrink-0 sm:w-0 sm:overflow-hidden sm:group-hover:w-auto sm:opacity-0 sm:group-hover:opacity-100 transition-all duration-150">
{canUpdate && ( {canUpdate && (
<TooltipProvider> <TooltipProvider>
<Tooltip open={isDesktop ? undefined : false}> <Tooltip open={isDesktop ? undefined : false}>
@ -277,7 +282,7 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
variant="ghost" variant="ghost"
size="icon" size="icon"
onClick={() => openEditDialog(config)} onClick={() => openEditDialog(config)}
className="h-7 w-7 text-muted-foreground hover:text-foreground" className="h-7 w-7 rounded-lg text-muted-foreground hover:text-foreground"
> >
<Edit3 className="h-3 w-3" /> <Edit3 className="h-3 w-3" />
</Button> </Button>
@ -294,7 +299,7 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
variant="ghost" variant="ghost"
size="icon" size="icon"
onClick={() => setConfigToDelete(config)} onClick={() => setConfigToDelete(config)}
className="h-7 w-7 text-muted-foreground hover:text-destructive" className="h-7 w-7 rounded-lg text-muted-foreground hover:text-destructive"
> >
<Trash2 className="h-3 w-3" /> <Trash2 className="h-3 w-3" />
</Button> </Button>
@ -307,17 +312,9 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
)} )}
</div> </div>
{/* Provider + Model */}
<div className="flex items-center gap-2 flex-wrap">
{getProviderIcon(config.provider, { className: "size-3.5 shrink-0" })}
<code className="text-[11px] font-mono text-muted-foreground bg-muted/60 px-2 py-0.5 rounded-md truncate max-w-[160px]">
{config.model_name}
</code>
</div>
{/* Footer: Date + Creator */} {/* Footer: Date + Creator */}
<div className="flex items-center gap-2 pt-2 border-t border-border/40 mt-auto"> <div className="flex items-center pt-2 border-t border-border/40 mt-auto">
<span className="text-[11px] text-muted-foreground/60"> <span className="shrink-0 text-[11px] text-muted-foreground/60 whitespace-nowrap">
{new Date(config.created_at).toLocaleDateString(undefined, { {new Date(config.created_at).toLocaleDateString(undefined, {
year: "numeric", year: "numeric",
month: "short", month: "short",
@ -326,11 +323,11 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
</span> </span>
{member && ( {member && (
<> <>
<Dot className="h-4 w-4 text-muted-foreground/30" /> <Dot className="h-4 w-4 text-muted-foreground/30 shrink-0" />
<TooltipProvider> <TooltipProvider>
<Tooltip open={isDesktop ? undefined : false}> <Tooltip open={isDesktop ? undefined : false}>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<div className="flex items-center gap-1.5 cursor-default"> <div className="min-w-0 flex items-center gap-1.5 cursor-default">
<Avatar className="size-4.5 shrink-0"> <Avatar className="size-4.5 shrink-0">
{member.avatarUrl && ( {member.avatarUrl && (
<AvatarImage src={member.avatarUrl} alt={member.name} /> <AvatarImage src={member.avatarUrl} alt={member.name} />
@ -339,7 +336,7 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
{getInitials(member.name)} {getInitials(member.name)}
</AvatarFallback> </AvatarFallback>
</Avatar> </Avatar>
<span className="text-[11px] text-muted-foreground/60 truncate max-w-[120px]"> <span className="text-[11px] text-muted-foreground/60 truncate">
{member.name} {member.name}
</span> </span>
</div> </div>

View file

@ -6,7 +6,7 @@ import {
Bot, Bot,
CircleCheck, CircleCheck,
CircleDashed, CircleDashed,
Eye, ScanEye,
FileText, FileText,
ImageIcon, ImageIcon,
RefreshCw, RefreshCw,
@ -74,7 +74,7 @@ const ROLE_DESCRIPTIONS = {
configType: "image" as const, configType: "image" as const,
}, },
vision: { vision: {
icon: Eye, icon: ScanEye,
title: "Vision LLM", title: "Vision LLM",
description: "Vision-capable model for screenshot analysis and context extraction", description: "Vision-capable model for screenshot analysis and context extraction",
color: "text-muted-foreground", color: "text-muted-foreground",

View file

@ -4,6 +4,7 @@ import { useQuery } from "@tanstack/react-query";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import { import {
Bot, Bot,
ChevronDown,
Edit2, Edit2,
FileText, FileText,
Globe, Globe,
@ -47,7 +48,6 @@ import {
DialogDescription, DialogDescription,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { import {
DropdownMenu, DropdownMenu,
@ -58,7 +58,6 @@ import {
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Spinner } from "@/components/ui/spinner"; import { Spinner } from "@/components/ui/spinner";
import type { PermissionInfo } from "@/contracts/types/permissions.types"; import type { PermissionInfo } from "@/contracts/types/permissions.types";
import type { import type {
@ -319,100 +318,6 @@ export function RolesManager({ searchSpaceId }: { searchSpaceId: number }) {
); );
} }
// ============ Role Permissions Display ============
function RolePermissionsDialog({
permissions,
roleName,
children,
}: {
permissions: string[];
roleName: string;
children: React.ReactNode;
}) {
const isFullAccess = permissions.includes("*");
const grouped: Record<string, string[]> = {};
if (!isFullAccess) {
for (const perm of permissions) {
const [category, action] = perm.split(":");
if (!grouped[category]) grouped[category] = [];
grouped[category].push(action);
}
}
const sortedCategories = Object.keys(grouped).sort((a, b) => {
const orderA = CATEGORY_CONFIG[a]?.order ?? 99;
const orderB = CATEGORY_CONFIG[b]?.order ?? 99;
return orderA - orderB;
});
const categoryCount = sortedCategories.length;
return (
<Dialog>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent className="w-[92vw] max-w-md p-0 gap-0">
<DialogHeader className="p-4 md:p-5">
<DialogTitle className="text-base">{roleName} Permissions</DialogTitle>
<DialogDescription className="text-xs">
{isFullAccess
? "This role has unrestricted access to all resources"
: `${permissions.length} permissions across ${categoryCount} categories`}
</DialogDescription>
</DialogHeader>
{isFullAccess ? (
<div className="flex items-center gap-3 px-4 md:px-5 py-6">
<div className="h-9 w-9 rounded-lg bg-muted/60 flex items-center justify-center shrink-0">
<Shield className="h-4 w-4 text-muted-foreground" />
</div>
<div>
<p className="text-sm font-medium">Full access</p>
<p className="text-xs text-muted-foreground">
All permissions granted across every category
</p>
</div>
</div>
) : (
<ScrollArea className="max-h-[55vh]">
<div className="divide-y divide-border/50">
{sortedCategories.map((category) => {
const actions = grouped[category];
const config = CATEGORY_CONFIG[category] || {
label: category,
icon: FileText,
};
const IconComponent = config.icon;
return (
<div
key={category}
className="flex items-center justify-between gap-3 px-4 md:px-5 py-2.5"
>
<div className="flex items-center gap-2 shrink-0">
<IconComponent className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-sm text-muted-foreground">{config.label}</span>
</div>
<div className="flex flex-wrap justify-end gap-1">
{actions.map((action) => (
<span
key={action}
className="px-1.5 py-0.5 rounded bg-muted text-muted-foreground text-[11px] font-medium"
>
{ACTION_LABELS[action] || action.replace(/_/g, " ")}
</span>
))}
</div>
</div>
);
})}
</div>
</ScrollArea>
)}
</DialogContent>
</Dialog>
);
}
function PermissionsBadge({ permissions }: { permissions: string[] }) { function PermissionsBadge({ permissions }: { permissions: string[] }) {
if (permissions.includes("*")) { if (permissions.includes("*")) {
return ( return (
@ -463,6 +368,7 @@ function RolesContent({
}) { }) {
const [showCreateRole, setShowCreateRole] = useState(false); const [showCreateRole, setShowCreateRole] = useState(false);
const [editingRoleId, setEditingRoleId] = useState<number | null>(null); const [editingRoleId, setEditingRoleId] = useState<number | null>(null);
const [expandedRoleId, setExpandedRoleId] = useState<number | null>(null);
if (loading) { if (loading) {
return ( return (
@ -508,12 +414,32 @@ function RolesContent({
)} )}
<div className="space-y-3"> <div className="space-y-3">
{roles.map((role) => ( {roles.map((role) => {
<div key={role.id}> const isExpanded = expandedRoleId === role.id;
<div className="w-full text-left relative flex items-center gap-4 rounded-lg border border-border/60 p-4 transition-colors hover:bg-muted/30"> const isFullAccess = role.permissions.includes("*");
<div className="flex-1 min-w-0">
<RolePermissionsDialog permissions={role.permissions} roleName={role.name}> const grouped: Record<string, string[]> = {};
<button type="button" className="w-full text-left cursor-pointer"> if (!isFullAccess) {
for (const perm of role.permissions) {
const [category, action] = perm.split(":");
if (!grouped[category]) grouped[category] = [];
grouped[category].push(action);
}
}
const sortedCategories = Object.keys(grouped).sort((a, b) => {
const orderA = CATEGORY_CONFIG[a]?.order ?? 99;
const orderB = CATEGORY_CONFIG[b]?.order ?? 99;
return orderA - orderB;
});
return (
<div key={role.id} className="rounded-lg border border-border/60 overflow-hidden">
<div className="flex items-center gap-4 p-4 transition-colors hover:bg-muted/30">
<button
type="button"
className="flex-1 min-w-0 text-left cursor-pointer"
onClick={() => setExpandedRoleId(isExpanded ? null : role.id)}
>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="font-medium text-sm">{role.name}</span> <span className="font-medium text-sm">{role.name}</span>
{role.is_system_role && ( {role.is_system_role && (
@ -533,8 +459,6 @@ function RolesContent({
</p> </p>
)} )}
</button> </button>
</RolePermissionsDialog>
</div>
<div className="shrink-0"> <div className="shrink-0">
<PermissionsBadge permissions={role.permissions} /> <PermissionsBadge permissions={role.permissions} />
@ -590,12 +514,73 @@ function RolesContent({
</DropdownMenu> </DropdownMenu>
</div> </div>
)} )}
<button
type="button"
className="shrink-0 p-1 cursor-pointer"
onClick={() => setExpandedRoleId(isExpanded ? null : role.id)}
>
<ChevronDown
className={cn(
"h-4 w-4 text-muted-foreground transition-transform duration-200",
isExpanded && "rotate-180"
)}
/>
</button>
</div> </div>
{isExpanded && (
<div className="border-t border-border/40 px-4 py-3">
{isFullAccess ? (
<div className="flex items-center gap-3 py-2">
<Shield className="h-4 w-4 text-muted-foreground shrink-0" />
<p className="text-sm text-muted-foreground">
Full access all permissions granted across every category
</p>
</div> </div>
) : (
<div className="divide-y divide-border/30">
{sortedCategories.map((category) => {
const actions = grouped[category];
const config = CATEGORY_CONFIG[category] || {
label: category,
icon: FileText,
};
const IconComponent = config.icon;
return (
<div
key={category}
className="flex items-center justify-between gap-3 py-2.5"
>
<div className="flex items-center gap-2 shrink-0">
<IconComponent className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-sm text-muted-foreground">
{config.label}
</span>
</div>
<div className="flex flex-wrap justify-end gap-1">
{actions.map((action) => (
<span
key={action}
className="px-1.5 py-0.5 rounded bg-muted text-muted-foreground text-[11px] font-medium"
>
{ACTION_LABELS[action] || action.replace(/_/g, " ")}
</span>
))} ))}
</div> </div>
</div> </div>
); );
})}
</div>
)}
</div>
)}
</div>
);
})}
</div>
</div>
);
} }
// ============ Permissions Editor (shared by Create and Edit) ============ // ============ Permissions Editor (shared by Create and Edit) ============
@ -676,25 +661,29 @@ function PermissionsEditor({
return ( return (
<div key={category} className="rounded-lg border border-border/60 overflow-hidden"> <div key={category} className="rounded-lg border border-border/60 overflow-hidden">
<div className="flex items-center justify-between px-3 py-2.5 hover:bg-muted/40 transition-colors">
<button <button
type="button" type="button"
className="w-full flex items-center justify-between px-3 py-2.5 cursor-pointer hover:bg-muted/40 transition-colors" className="flex-1 flex items-center gap-2.5 cursor-pointer"
onClick={() => toggleCategoryExpanded(category)} onClick={() => toggleCategoryExpanded(category)}
> >
<div className="flex items-center gap-2.5">
<IconComponent className="h-4 w-4 text-muted-foreground shrink-0" /> <IconComponent className="h-4 w-4 text-muted-foreground shrink-0" />
<span className="font-medium text-sm">{config.label}</span> <span className="font-medium text-sm">{config.label}</span>
<span className="text-[11px] text-muted-foreground tabular-nums"> <span className="text-[11px] text-muted-foreground tabular-nums">
{stats.selected}/{stats.total} {stats.selected}/{stats.total}
</span> </span>
</div> </button>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Checkbox <Checkbox
checked={stats.allSelected} checked={stats.allSelected}
onCheckedChange={() => onToggleCategory(category)} onCheckedChange={() => onToggleCategory(category)}
onClick={(e) => e.stopPropagation()}
aria-label={`Select all ${config.label} permissions`} aria-label={`Select all ${config.label} permissions`}
/> />
<button
type="button"
className="cursor-pointer"
onClick={() => toggleCategoryExpanded(category)}
>
<div <div
className={cn("transition-transform duration-200", isExpanded && "rotate-180")} className={cn("transition-transform duration-200", isExpanded && "rotate-180")}
> >
@ -714,8 +703,9 @@ function PermissionsEditor({
/> />
</svg> </svg>
</div> </div>
</div>
</button> </button>
</div>
</div>
{isExpanded && ( {isExpanded && (
<div className="border-t border-border/60"> <div className="border-t border-border/60">
@ -726,28 +716,29 @@ function PermissionsEditor({
const isSelected = selectedPermissions.includes(perm.value); const isSelected = selectedPermissions.includes(perm.value);
return ( return (
<button <div
key={perm.value} key={perm.value}
type="button"
className={cn( className={cn(
"w-full flex items-center justify-between gap-3 px-2.5 py-2 rounded-md cursor-pointer transition-colors", "flex items-center justify-between gap-3 px-2.5 py-2 rounded-md transition-colors",
isSelected ? "bg-muted/60 hover:bg-muted/80" : "hover:bg-muted/40" isSelected ? "bg-muted/60 hover:bg-muted/80" : "hover:bg-muted/40"
)} )}
>
<button
type="button"
className="flex-1 min-w-0 text-left cursor-pointer"
onClick={() => onTogglePermission(perm.value)} onClick={() => onTogglePermission(perm.value)}
> >
<div className="flex-1 min-w-0 text-left">
<span className="text-sm font-medium">{actionLabel}</span> <span className="text-sm font-medium">{actionLabel}</span>
<p className="text-xs text-muted-foreground truncate"> <p className="text-xs text-muted-foreground truncate">
{perm.description} {perm.description}
</p> </p>
</div> </button>
<Checkbox <Checkbox
checked={isSelected} checked={isSelected}
onCheckedChange={() => onTogglePermission(perm.value)} onCheckedChange={() => onTogglePermission(perm.value)}
onClick={(e) => e.stopPropagation()}
className="shrink-0" className="shrink-0"
/> />
</button> </div>
); );
})} })}
</div> </div>

View file

@ -7,7 +7,7 @@ import {
Brain, Brain,
CircleUser, CircleUser,
Earth, Earth,
Eye, ScanEye,
ImageIcon, ImageIcon,
ListChecks, ListChecks,
UserKey, UserKey,
@ -25,10 +25,10 @@ const GeneralSettingsManager = dynamic(
})), })),
{ ssr: false } { ssr: false }
); );
const ModelConfigManager = dynamic( const AgentModelManager = dynamic(
() => () =>
import("@/components/settings/model-config-manager").then((m) => ({ import("@/components/settings/agent-model-manager").then((m) => ({
default: m.ModelConfigManager, default: m.AgentModelManager,
})), })),
{ ssr: false } { ssr: false }
); );
@ -88,7 +88,7 @@ export function SearchSpaceSettingsDialog({ searchSpaceId }: SearchSpaceSettings
const navItems = [ const navItems = [
{ value: "general", label: t("nav_general"), icon: <CircleUser className="h-4 w-4" /> }, { value: "general", label: t("nav_general"), icon: <CircleUser className="h-4 w-4" /> },
{ value: "roles", label: t("nav_role_assignments"), icon: <ListChecks className="h-4 w-4" /> }, { value: "roles", label: t("nav_role_assignments"), icon: <ListChecks className="h-4 w-4" /> },
{ value: "models", label: t("nav_agent_configs"), icon: <Bot className="h-4 w-4" /> }, { value: "models", label: t("nav_agent_models"), icon: <Bot className="h-4 w-4" /> },
{ {
value: "image-models", value: "image-models",
label: t("nav_image_models"), label: t("nav_image_models"),
@ -97,7 +97,7 @@ export function SearchSpaceSettingsDialog({ searchSpaceId }: SearchSpaceSettings
{ {
value: "vision-models", value: "vision-models",
label: t("nav_vision_models"), label: t("nav_vision_models"),
icon: <Eye className="h-4 w-4" />, icon: <ScanEye className="h-4 w-4" />,
}, },
{ value: "team-roles", label: t("nav_team_roles"), icon: <UserKey className="h-4 w-4" /> }, { value: "team-roles", label: t("nav_team_roles"), icon: <UserKey className="h-4 w-4" /> },
{ {
@ -115,7 +115,7 @@ export function SearchSpaceSettingsDialog({ searchSpaceId }: SearchSpaceSettings
const content: Record<string, React.ReactNode> = { const content: Record<string, React.ReactNode> = {
general: <GeneralSettingsManager searchSpaceId={searchSpaceId} />, general: <GeneralSettingsManager searchSpaceId={searchSpaceId} />,
models: <ModelConfigManager searchSpaceId={searchSpaceId} />, models: <AgentModelManager searchSpaceId={searchSpaceId} />,
roles: <LLMRoleManager searchSpaceId={searchSpaceId} />, roles: <LLMRoleManager searchSpaceId={searchSpaceId} />,
"image-models": <ImageModelManager searchSpaceId={searchSpaceId} />, "image-models": <ImageModelManager searchSpaceId={searchSpaceId} />,
"vision-models": <VisionModelManager searchSpaceId={searchSpaceId} />, "vision-models": <VisionModelManager searchSpaceId={searchSpaceId} />,

View file

@ -208,21 +208,21 @@ export function VisionModelManager({ searchSpaceId }: VisionModelManagerProps) {
{["skeleton-a", "skeleton-b", "skeleton-c"].map((key) => ( {["skeleton-a", "skeleton-b", "skeleton-c"].map((key) => (
<Card key={key} className="border-border/60"> <Card key={key} className="border-border/60">
<CardContent className="p-4 flex flex-col gap-3"> <CardContent className="p-4 flex flex-col gap-3">
<div className="flex items-start justify-between gap-2"> <div className="flex items-center gap-2.5">
<Skeleton className="size-4 rounded-full shrink-0" />
<div className="space-y-1.5 flex-1 min-w-0"> <div className="space-y-1.5 flex-1 min-w-0">
<Skeleton className="h-4 w-28 md:w-32" /> <Skeleton className="h-4 w-28 md:w-32" />
<Skeleton className="h-3 w-40 md:w-48" /> <Skeleton className="h-3 w-40 md:w-48" />
</div> </div>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center pt-2 border-t border-border/40">
<Skeleton className="h-5 w-16 rounded-full" /> <Skeleton className="h-3 w-20 flex-1" />
<Skeleton className="h-5 w-24 rounded-md" /> <Skeleton className="h-3 w-3 rounded-full shrink-0 mx-1" />
</div> <div className="flex-1 flex items-center justify-end gap-1.5">
<div className="flex items-center gap-2 pt-2 border-t border-border/40">
<Skeleton className="h-3 w-20" />
<Skeleton className="h-4 w-4 rounded-full" /> <Skeleton className="h-4 w-4 rounded-full" />
<Skeleton className="h-3 w-16" /> <Skeleton className="h-3 w-16" />
</div> </div>
</div>
</CardContent> </CardContent>
</Card> </Card>
))} ))}
@ -253,7 +253,12 @@ export function VisionModelManager({ searchSpaceId }: VisionModelManagerProps) {
<div key={config.id}> <div key={config.id}>
<Card className="group relative overflow-hidden transition-all duration-200 border-border/60 hover:shadow-md h-full"> <Card className="group relative overflow-hidden transition-all duration-200 border-border/60 hover:shadow-md h-full">
<CardContent className="p-4 flex flex-col gap-3 h-full"> <CardContent className="p-4 flex flex-col gap-3 h-full">
<div className="flex items-start justify-between gap-2"> {/* Header: Icon + Name + Actions */}
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2.5 min-w-0 flex-1">
<div className="shrink-0">
{getProviderIcon(config.provider, { className: "size-4" })}
</div>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<h4 className="text-sm font-semibold tracking-tight truncate"> <h4 className="text-sm font-semibold tracking-tight truncate">
{config.name} {config.name}
@ -264,8 +269,9 @@ export function VisionModelManager({ searchSpaceId }: VisionModelManagerProps) {
</p> </p>
)} )}
</div> </div>
</div>
{(canUpdate || canDelete) && ( {(canUpdate || canDelete) && (
<div className="flex items-center gap-0.5 shrink-0 sm:opacity-0 sm:group-hover:opacity-100 transition-opacity duration-150"> <div className="flex items-center gap-1 shrink-0 sm:w-0 sm:overflow-hidden sm:group-hover:w-auto sm:opacity-0 sm:group-hover:opacity-100 transition-all duration-150">
{canUpdate && ( {canUpdate && (
<TooltipProvider> <TooltipProvider>
<Tooltip open={isDesktop ? undefined : false}> <Tooltip open={isDesktop ? undefined : false}>
@ -274,7 +280,7 @@ export function VisionModelManager({ searchSpaceId }: VisionModelManagerProps) {
variant="ghost" variant="ghost"
size="icon" size="icon"
onClick={() => openEditDialog(config)} onClick={() => openEditDialog(config)}
className="h-7 w-7 text-muted-foreground hover:text-foreground" className="h-6 w-6 text-muted-foreground hover:text-foreground"
> >
<Edit3 className="h-3 w-3" /> <Edit3 className="h-3 w-3" />
</Button> </Button>
@ -291,7 +297,7 @@ export function VisionModelManager({ searchSpaceId }: VisionModelManagerProps) {
variant="ghost" variant="ghost"
size="icon" size="icon"
onClick={() => setConfigToDelete(config)} onClick={() => setConfigToDelete(config)}
className="h-7 w-7 text-muted-foreground hover:text-destructive" className="h-7 w-7 rounded-lg text-muted-foreground hover:text-destructive"
> >
<Trash2 className="h-3 w-3" /> <Trash2 className="h-3 w-3" />
</Button> </Button>
@ -304,17 +310,9 @@ export function VisionModelManager({ searchSpaceId }: VisionModelManagerProps) {
)} )}
</div> </div>
<div className="flex items-center gap-2 flex-wrap"> {/* Footer: Date + Creator */}
{getProviderIcon(config.provider, { <div className="flex items-center pt-2 border-t border-border/40 mt-auto">
className: "size-3.5 shrink-0", <span className="shrink-0 text-[11px] text-muted-foreground/60 whitespace-nowrap">
})}
<code className="text-[11px] font-mono text-muted-foreground bg-muted/60 px-2 py-0.5 rounded-md truncate max-w-[160px]">
{config.model_name}
</code>
</div>
<div className="flex items-center gap-2 pt-2 border-t border-border/40 mt-auto">
<span className="text-[11px] text-muted-foreground/60">
{new Date(config.created_at).toLocaleDateString(undefined, { {new Date(config.created_at).toLocaleDateString(undefined, {
year: "numeric", year: "numeric",
month: "short", month: "short",
@ -323,11 +321,11 @@ export function VisionModelManager({ searchSpaceId }: VisionModelManagerProps) {
</span> </span>
{member && ( {member && (
<> <>
<Dot className="h-4 w-4 text-muted-foreground/30" /> <Dot className="h-4 w-4 text-muted-foreground/30 shrink-0" />
<TooltipProvider> <TooltipProvider>
<Tooltip open={isDesktop ? undefined : false}> <Tooltip open={isDesktop ? undefined : false}>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<div className="flex items-center gap-1.5 cursor-default"> <div className="min-w-0 flex items-center gap-1.5 cursor-default">
<Avatar className="size-4.5 shrink-0"> <Avatar className="size-4.5 shrink-0">
{member.avatarUrl && ( {member.avatarUrl && (
<AvatarImage src={member.avatarUrl} alt={member.name} /> <AvatarImage src={member.avatarUrl} alt={member.name} />
@ -336,7 +334,7 @@ export function VisionModelManager({ searchSpaceId }: VisionModelManagerProps) {
{getInitials(member.name)} {getInitials(member.name)}
</AvatarFallback> </AvatarFallback>
</Avatar> </Avatar>
<span className="text-[11px] text-muted-foreground/60 truncate max-w-[120px]"> <span className="text-[11px] text-muted-foreground/60 truncate">
{member.name} {member.name}
</span> </span>
</div> </div>

View file

@ -15,6 +15,8 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
import type { InterruptResult, HitlDecision } from "@/lib/hitl";
import { useHitlPhase } from "@/hooks/use-hitl-phase"; import { useHitlPhase } from "@/hooks/use-hitl-phase";
interface ConfluenceAccount { interface ConfluenceAccount {
@ -30,24 +32,10 @@ interface ConfluenceSpace {
name: string; name: string;
} }
interface InterruptResult { type CreateConfluencePageInterruptContext = {
__interrupt__: true;
__decided__?: "approve" | "reject" | "edit";
__completed__?: boolean;
action_requests: Array<{
name: string;
args: Record<string, unknown>;
}>;
review_configs: Array<{
action_name: string;
allowed_decisions: Array<"approve" | "edit" | "reject">;
}>;
interrupt_type?: string;
context?: {
accounts?: ConfluenceAccount[]; accounts?: ConfluenceAccount[];
spaces?: ConfluenceSpace[]; spaces?: ConfluenceSpace[];
error?: string; error?: string;
};
} }
interface SuccessResult { interface SuccessResult {
@ -76,21 +64,12 @@ interface InsufficientPermissionsResult {
} }
type CreateConfluencePageResult = type CreateConfluencePageResult =
| InterruptResult | InterruptResult<CreateConfluencePageInterruptContext>
| SuccessResult | SuccessResult
| ErrorResult | ErrorResult
| AuthErrorResult | AuthErrorResult
| InsufficientPermissionsResult; | InsufficientPermissionsResult;
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 { function isErrorResult(result: unknown): result is ErrorResult {
return ( return (
typeof result === "object" && typeof result === "object" &&
@ -124,12 +103,8 @@ function ApprovalCard({
onDecision, onDecision,
}: { }: {
args: { title: string; content?: string; space_id?: string }; args: { title: string; content?: string; space_id?: string };
interruptData: InterruptResult; interruptData: InterruptResult<CreateConfluencePageInterruptContext>;
onDecision: (decision: { onDecision: (decision: HitlDecision) => void;
type: "approve" | "reject" | "edit";
message?: string;
edited_action?: { name: string; args: Record<string, unknown> };
}) => void;
}) { }) {
const { phase, setProcessing, setRejected } = useHitlPhase(interruptData); const { phase, setProcessing, setRejected } = useHitlPhase(interruptData);
const [isPanelOpen, setIsPanelOpen] = useState(false); const [isPanelOpen, setIsPanelOpen] = useState(false);
@ -464,18 +439,16 @@ export const CreateConfluencePageToolUI = ({
{ title: string; content?: string; space_id?: string }, { title: string; content?: string; space_id?: string },
CreateConfluencePageResult CreateConfluencePageResult
>) => { >) => {
const { dispatch } = useHitlDecision();
if (!result) return null; if (!result) return null;
if (isInterruptResult(result)) { if (isInterruptResult(result)) {
return ( return (
<ApprovalCard <ApprovalCard
args={args} args={args}
interruptData={result} interruptData={result as InterruptResult<CreateConfluencePageInterruptContext>}
onDecision={(decision) => { onDecision={(decision) => dispatch([decision])}
window.dispatchEvent(
new CustomEvent("hitl-decision", { detail: { decisions: [decision] } })
);
}}
/> />
); );
} }

View file

@ -6,22 +6,11 @@ import { useCallback, useEffect, useState } from "react";
import { TextShimmerLoader } from "@/components/prompt-kit/loader"; import { TextShimmerLoader } from "@/components/prompt-kit/loader";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox"; import { Checkbox } from "@/components/ui/checkbox";
import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
import type { InterruptResult, HitlDecision } from "@/lib/hitl";
import { useHitlPhase } from "@/hooks/use-hitl-phase"; import { useHitlPhase } from "@/hooks/use-hitl-phase";
interface InterruptResult { type DeleteConfluencePageInterruptContext = {
__interrupt__: true;
__decided__?: "approve" | "reject";
__completed__?: boolean;
action_requests: Array<{
name: string;
args: Record<string, unknown>;
}>;
review_configs: Array<{
action_name: string;
allowed_decisions: Array<"approve" | "reject">;
}>;
interrupt_type?: string;
context?: {
account?: { account?: {
id: number; id: number;
name: string; name: string;
@ -37,7 +26,6 @@ interface InterruptResult {
indexed_at?: string; indexed_at?: string;
}; };
error?: string; error?: string;
};
} }
interface SuccessResult { interface SuccessResult {
@ -77,7 +65,7 @@ interface InsufficientPermissionsResult {
} }
type DeleteConfluencePageResult = type DeleteConfluencePageResult =
| InterruptResult | InterruptResult<DeleteConfluencePageInterruptContext>
| SuccessResult | SuccessResult
| ErrorResult | ErrorResult
| NotFoundResult | NotFoundResult
@ -85,15 +73,6 @@ type DeleteConfluencePageResult =
| AuthErrorResult | AuthErrorResult
| InsufficientPermissionsResult; | InsufficientPermissionsResult;
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 { function isErrorResult(result: unknown): result is ErrorResult {
return ( return (
typeof result === "object" && typeof result === "object" &&
@ -145,12 +124,8 @@ function ApprovalCard({
interruptData, interruptData,
onDecision, onDecision,
}: { }: {
interruptData: InterruptResult; interruptData: InterruptResult<DeleteConfluencePageInterruptContext>;
onDecision: (decision: { onDecision: (decision: HitlDecision) => void;
type: "approve" | "reject";
message?: string;
edited_action?: { name: string; args: Record<string, unknown> };
}) => void;
}) { }) {
const { phase, setProcessing, setRejected } = useHitlPhase(interruptData); const { phase, setProcessing, setRejected } = useHitlPhase(interruptData);
const [deleteFromKb, setDeleteFromKb] = useState(false); const [deleteFromKb, setDeleteFromKb] = useState(false);
@ -402,18 +377,15 @@ export const DeleteConfluencePageToolUI = ({
{ page_title_or_id: string; delete_from_kb?: boolean }, { page_title_or_id: string; delete_from_kb?: boolean },
DeleteConfluencePageResult DeleteConfluencePageResult
>) => { >) => {
const { dispatch } = useHitlDecision();
if (!result) return null; if (!result) return null;
if (isInterruptResult(result)) { if (isInterruptResult(result)) {
return ( return (
<ApprovalCard <ApprovalCard
interruptData={result} interruptData={result as InterruptResult<DeleteConfluencePageInterruptContext>}
onDecision={(decision) => { onDecision={(decision) => dispatch([decision])}
const event = new CustomEvent("hitl-decision", {
detail: { decisions: [decision] },
});
window.dispatchEvent(event);
}}
/> />
); );
} }

View file

@ -8,22 +8,11 @@ import { openHitlEditPanelAtom } from "@/atoms/chat/hitl-edit-panel.atom";
import { PlateEditor } from "@/components/editor/plate-editor"; import { PlateEditor } from "@/components/editor/plate-editor";
import { TextShimmerLoader } from "@/components/prompt-kit/loader"; import { TextShimmerLoader } from "@/components/prompt-kit/loader";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
import type { InterruptResult, HitlDecision } from "@/lib/hitl";
import { useHitlPhase } from "@/hooks/use-hitl-phase"; import { useHitlPhase } from "@/hooks/use-hitl-phase";
interface InterruptResult { type UpdateConfluencePageInterruptContext = {
__interrupt__: true;
__decided__?: "approve" | "reject" | "edit";
__completed__?: boolean;
action_requests: Array<{
name: string;
args: Record<string, unknown>;
}>;
review_configs: Array<{
action_name: string;
allowed_decisions: Array<"approve" | "edit" | "reject">;
}>;
interrupt_type?: string;
context?: {
account?: { account?: {
id: number; id: number;
name: string; name: string;
@ -40,7 +29,6 @@ interface InterruptResult {
indexed_at?: string; indexed_at?: string;
}; };
error?: string; error?: string;
};
} }
interface SuccessResult { interface SuccessResult {
@ -74,22 +62,13 @@ interface InsufficientPermissionsResult {
} }
type UpdateConfluencePageResult = type UpdateConfluencePageResult =
| InterruptResult | InterruptResult<UpdateConfluencePageInterruptContext>
| SuccessResult | SuccessResult
| ErrorResult | ErrorResult
| NotFoundResult | NotFoundResult
| AuthErrorResult | AuthErrorResult
| InsufficientPermissionsResult; | InsufficientPermissionsResult;
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 { function isErrorResult(result: unknown): result is ErrorResult {
return ( return (
typeof result === "object" && typeof result === "object" &&
@ -136,12 +115,8 @@ function ApprovalCard({
new_title?: string; new_title?: string;
new_content?: string; new_content?: string;
}; };
interruptData: InterruptResult; interruptData: InterruptResult<UpdateConfluencePageInterruptContext>;
onDecision: (decision: { onDecision: (decision: HitlDecision) => void;
type: "approve" | "reject" | "edit";
message?: string;
edited_action?: { name: string; args: Record<string, unknown> };
}) => void;
}) { }) {
const { phase, setProcessing, setRejected } = useHitlPhase(interruptData); const { phase, setProcessing, setRejected } = useHitlPhase(interruptData);
@ -502,18 +477,16 @@ export const UpdateConfluencePageToolUI = ({
}, },
UpdateConfluencePageResult UpdateConfluencePageResult
>) => { >) => {
const { dispatch } = useHitlDecision();
if (!result) return null; if (!result) return null;
if (isInterruptResult(result)) { if (isInterruptResult(result)) {
return ( return (
<ApprovalCard <ApprovalCard
args={args} args={args}
interruptData={result} interruptData={result as InterruptResult<UpdateConfluencePageInterruptContext>}
onDecision={(decision) => { onDecision={(decision) => dispatch([decision])}
window.dispatchEvent(
new CustomEvent("hitl-decision", { detail: { decisions: [decision] } })
);
}}
/> />
); );
} }

View file

@ -16,6 +16,8 @@ import {
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { useHitlPhase } from "@/hooks/use-hitl-phase"; import { useHitlPhase } from "@/hooks/use-hitl-phase";
import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
import type { InterruptResult, HitlDecision } from "@/lib/hitl";
interface DropboxAccount { interface DropboxAccount {
id: number; id: number;
@ -29,21 +31,11 @@ interface SupportedType {
label: string; label: string;
} }
interface InterruptResult { type DropboxCreateFileContext = {
__interrupt__: true;
__decided__?: "approve" | "reject" | "edit";
__completed__?: boolean;
action_requests: Array<{ name: string; args: Record<string, unknown> }>;
review_configs: Array<{
action_name: string;
allowed_decisions: Array<"approve" | "edit" | "reject">;
}>;
context?: {
accounts?: DropboxAccount[]; accounts?: DropboxAccount[];
parent_folders?: Record<number, Array<{ folder_path: string; name: string }>>; parent_folders?: Record<number, Array<{ folder_path: string; name: string }>>;
supported_types?: SupportedType[]; supported_types?: SupportedType[];
error?: string; error?: string;
};
} }
interface SuccessResult { interface SuccessResult {
@ -65,16 +57,7 @@ interface AuthErrorResult {
connector_type?: string; connector_type?: string;
} }
type CreateDropboxFileResult = InterruptResult | SuccessResult | ErrorResult | AuthErrorResult; type CreateDropboxFileResult = InterruptResult<DropboxCreateFileContext> | 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 { function isErrorResult(result: unknown): result is ErrorResult {
return ( return (
@ -100,12 +83,8 @@ function ApprovalCard({
onDecision, onDecision,
}: { }: {
args: { name: string; file_type?: string; content?: string }; args: { name: string; file_type?: string; content?: string };
interruptData: InterruptResult; interruptData: InterruptResult<DropboxCreateFileContext>;
onDecision: (decision: { onDecision: (decision: HitlDecision) => void;
type: "approve" | "reject" | "edit";
message?: string;
edited_action?: { name: string; args: Record<string, unknown> };
}) => void;
}) { }) {
const { phase, setProcessing, setRejected } = useHitlPhase(interruptData); const { phase, setProcessing, setRejected } = useHitlPhase(interruptData);
const [isPanelOpen, setIsPanelOpen] = useState(false); const [isPanelOpen, setIsPanelOpen] = useState(false);
@ -455,17 +434,14 @@ export const CreateDropboxFileToolUI = ({
{ name: string; file_type?: string; content?: string }, { name: string; file_type?: string; content?: string },
CreateDropboxFileResult CreateDropboxFileResult
>) => { >) => {
const { dispatch } = useHitlDecision();
if (!result) return null; if (!result) return null;
if (isInterruptResult(result)) { if (isInterruptResult(result)) {
return ( return (
<ApprovalCard <ApprovalCard
args={args} args={args}
interruptData={result} interruptData={result as InterruptResult<DropboxCreateFileContext>}
onDecision={(decision) => { onDecision={(decision) => dispatch([decision])}
window.dispatchEvent(
new CustomEvent("hitl-decision", { detail: { decisions: [decision] } })
);
}}
/> />
); );
} }

View file

@ -7,6 +7,8 @@ import { TextShimmerLoader } from "@/components/prompt-kit/loader";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox"; import { Checkbox } from "@/components/ui/checkbox";
import { useHitlPhase } from "@/hooks/use-hitl-phase"; import { useHitlPhase } from "@/hooks/use-hitl-phase";
import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
import type { InterruptResult, HitlDecision } from "@/lib/hitl";
interface DropboxAccount { interface DropboxAccount {
id: number; id: number;
@ -22,13 +24,10 @@ interface DropboxFile {
document_id?: number; document_id?: number;
} }
interface InterruptResult { type DropboxTrashFileContext = {
__interrupt__: true; account?: DropboxAccount;
__decided__?: "approve" | "reject"; file?: DropboxFile;
__completed__?: boolean; error?: string;
action_requests: Array<{ name: string; args: Record<string, unknown> }>;
review_configs: Array<{ action_name: string; allowed_decisions: Array<"approve" | "reject"> }>;
context?: { account?: DropboxAccount; file?: DropboxFile; error?: string };
} }
interface SuccessResult { interface SuccessResult {
@ -52,20 +51,12 @@ interface AuthErrorResult {
} }
type DeleteDropboxFileResult = type DeleteDropboxFileResult =
| InterruptResult | InterruptResult<DropboxTrashFileContext>
| SuccessResult | SuccessResult
| ErrorResult | ErrorResult
| NotFoundResult | NotFoundResult
| AuthErrorResult; | 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 { function isErrorResult(result: unknown): result is ErrorResult {
return ( return (
typeof result === "object" && typeof result === "object" &&
@ -95,12 +86,8 @@ function ApprovalCard({
interruptData, interruptData,
onDecision, onDecision,
}: { }: {
interruptData: InterruptResult; interruptData: InterruptResult<DropboxTrashFileContext>;
onDecision: (decision: { onDecision: (decision: HitlDecision) => void;
type: "approve" | "reject";
message?: string;
edited_action?: { name: string; args: Record<string, unknown> };
}) => void;
}) { }) {
const { phase, setProcessing, setRejected } = useHitlPhase(interruptData); const { phase, setProcessing, setRejected } = useHitlPhase(interruptData);
const [deleteFromKb, setDeleteFromKb] = useState(false); const [deleteFromKb, setDeleteFromKb] = useState(false);
@ -308,16 +295,13 @@ export const DeleteDropboxFileToolUI = ({
{ file_name: string; delete_from_kb?: boolean }, { file_name: string; delete_from_kb?: boolean },
DeleteDropboxFileResult DeleteDropboxFileResult
>) => { >) => {
const { dispatch } = useHitlDecision();
if (!result) return null; if (!result) return null;
if (isInterruptResult(result)) { if (isInterruptResult(result)) {
return ( return (
<ApprovalCard <ApprovalCard
interruptData={result} interruptData={result as InterruptResult<DropboxTrashFileContext>}
onDecision={(decision) => { onDecision={(decision) => dispatch([decision])}
window.dispatchEvent(
new CustomEvent("hitl-decision", { detail: { decisions: [decision] } })
);
}}
/> />
); );
} }

View file

@ -0,0 +1,264 @@
"use client";
import type { ToolCallMessagePartComponent } from "@assistant-ui/react";
import { CornerDownLeftIcon, Pen } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { TextShimmerLoader } from "@/components/prompt-kit/loader";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { useHitlPhase } from "@/hooks/use-hitl-phase";
import { connectorsApiService } from "@/lib/apis/connectors-api.service";
import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
import type { HitlDecision, InterruptResult } from "@/lib/hitl";
function ParamEditor({
params,
onChange,
disabled,
}: {
params: Record<string, unknown>;
onChange: (updated: Record<string, unknown>) => void;
disabled: boolean;
}) {
const entries = Object.entries(params);
if (entries.length === 0) return null;
return (
<div className="space-y-2">
{entries.map(([key, value]) => {
const strValue = value == null ? "" : String(value);
const isLong = strValue.length > 120;
const fieldId = `hitl-param-${key}`;
return (
<div key={key} className="space-y-1">
<label htmlFor={fieldId} className="text-xs font-medium text-muted-foreground">
{key}
</label>
{isLong ? (
<Textarea
id={fieldId}
value={strValue}
disabled={disabled}
rows={3}
onChange={(e) => onChange({ ...params, [key]: e.target.value })}
className="text-xs"
/>
) : (
<Input
id={fieldId}
value={strValue}
disabled={disabled}
onChange={(e) => onChange({ ...params, [key]: e.target.value })}
className="text-xs"
/>
)}
</div>
);
})}
</div>
);
}
function GenericApprovalCard({
toolName,
args,
interruptData,
onDecision,
}: {
toolName: string;
args: Record<string, unknown>;
interruptData: InterruptResult;
onDecision: (decision: HitlDecision) => void;
}) {
const { phase, setProcessing, setRejected } = useHitlPhase(interruptData);
const [editedParams, setEditedParams] = useState<Record<string, unknown>>(args);
const [isEditing, setIsEditing] = useState(false);
const displayName = toolName.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
const mcpServer = interruptData.context?.mcp_server as string | undefined;
const toolDescription = interruptData.context?.tool_description as string | undefined;
const mcpConnectorId = interruptData.context?.mcp_connector_id as number | undefined;
const isMCPTool = mcpConnectorId != null;
const reviewConfig = interruptData.review_configs?.[0];
const allowedDecisions = reviewConfig?.allowed_decisions ?? ["approve", "reject"];
const canEdit = allowedDecisions.includes("edit");
const hasChanged = useMemo(() => {
return JSON.stringify(editedParams) !== JSON.stringify(args);
}, [editedParams, args]);
const handleApprove = useCallback(() => {
if (phase !== "pending") return;
const isEdited = isEditing && hasChanged;
setProcessing();
onDecision({
type: isEdited ? "edit" : "approve",
edited_action: isEdited
? { name: interruptData.action_requests[0]?.name ?? toolName, args: editedParams }
: undefined,
});
}, [
phase,
setProcessing,
isEditing,
hasChanged,
onDecision,
interruptData,
toolName,
editedParams,
]);
const handleAlwaysAllow = useCallback(() => {
if (phase !== "pending" || !isMCPTool) return;
setProcessing();
onDecision({ type: "approve" });
connectorsApiService.trustMCPTool(mcpConnectorId, toolName).catch((err) => {
console.error("Failed to trust MCP tool:", err);
});
}, [phase, setProcessing, onDecision, isMCPTool, mcpConnectorId, toolName]);
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey && !e.ctrlKey && !e.metaKey && phase === "pending") {
handleApprove();
}
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [handleApprove, phase]);
return (
<div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 transition-[box-shadow] duration-300">
{/* Header */}
<div className="flex items-start justify-between px-5 pt-5 pb-4 select-none">
<div>
<p className="text-sm font-semibold text-foreground">
{phase === "rejected"
? `${displayName} — Rejected`
: phase === "processing" || phase === "complete"
? `${displayName} — Approved`
: displayName}
</p>
{phase === "processing" ? (
<TextShimmerLoader text="Executing..." size="sm" />
) : phase === "complete" ? (
<p className="text-xs text-muted-foreground mt-0.5">Action completed</p>
) : phase === "rejected" ? (
<p className="text-xs text-muted-foreground mt-0.5">Action was cancelled</p>
) : (
<p className="text-xs text-muted-foreground mt-0.5">
Requires your approval to proceed
</p>
)}
{mcpServer && (
<p className="text-[10px] text-muted-foreground/70 mt-1">
via <span className="font-medium">{mcpServer}</span>
</p>
)}
</div>
{phase === "pending" && canEdit && !isEditing && (
<Button
size="sm"
variant="ghost"
className="rounded-lg text-muted-foreground -mt-1 -mr-2"
onClick={() => setIsEditing(true)}
>
<Pen className="size-3.5" />
Edit
</Button>
)}
</div>
{/* Description */}
{toolDescription && phase === "pending" && (
<>
<div className="mx-5 h-px bg-border/50" />
<div className="px-5 py-3">
<p className="text-xs text-muted-foreground">{toolDescription}</p>
</div>
</>
)}
{/* Parameters */}
{Object.keys(args).length > 0 && (
<>
<div className="mx-5 h-px bg-border/50" />
<div className="px-5 py-4 space-y-2">
<p className="text-xs font-medium text-muted-foreground">Parameters</p>
{phase === "pending" && isEditing ? (
<ParamEditor
params={editedParams}
onChange={setEditedParams}
disabled={phase !== "pending"}
/>
) : (
<pre className="text-xs text-foreground/80 whitespace-pre-wrap break-all bg-muted/50 rounded-lg p-3">
{JSON.stringify(args, null, 2)}
</pre>
)}
</div>
</>
)}
{/* Action buttons */}
{phase === "pending" && (
<>
<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}>
{isEditing && hasChanged ? "Approve with edits" : "Approve"}
<CornerDownLeftIcon className="size-3 opacity-60" />
</Button>
)}
{isMCPTool && (
<Button
size="sm"
className="rounded-lg"
onClick={handleAlwaysAllow}
>
Always Allow
</Button>
)}
{allowedDecisions.includes("reject") && (
<Button
size="sm"
variant="ghost"
className="rounded-lg text-muted-foreground"
onClick={() => {
setRejected();
onDecision({ type: "reject", message: "User rejected the action." });
}}
>
Reject
</Button>
)}
</div>
</>
)}
</div>
);
}
export const GenericHitlApprovalToolUI: ToolCallMessagePartComponent = ({
toolName,
args,
result,
}) => {
const { dispatch } = useHitlDecision();
if (!result || !isInterruptResult(result)) return null;
return (
<GenericApprovalCard
toolName={toolName}
args={args as Record<string, unknown>}
interruptData={result}
onDecision={(decision) => dispatch([decision])}
/>
);
};

View file

@ -16,6 +16,8 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
import type { HitlDecision, InterruptResult } from "@/lib/hitl";
import { useHitlPhase } from "@/hooks/use-hitl-phase"; import { useHitlPhase } from "@/hooks/use-hitl-phase";
interface GmailAccount { interface GmailAccount {
@ -25,22 +27,9 @@ interface GmailAccount {
auth_expired?: boolean; auth_expired?: boolean;
} }
interface InterruptResult { type GmailCreateDraftContext = {
__interrupt__: true;
__decided__?: "approve" | "reject" | "edit";
__completed__?: boolean;
action_requests: Array<{
name: string;
args: Record<string, unknown>;
}>;
review_configs: Array<{
action_name: string;
allowed_decisions: Array<"approve" | "edit" | "reject">;
}>;
context?: {
accounts?: GmailAccount[]; accounts?: GmailAccount[];
error?: string; error?: string;
};
} }
interface SuccessResult { interface SuccessResult {
@ -68,21 +57,12 @@ interface InsufficientPermissionsResult {
} }
type CreateGmailDraftResult = type CreateGmailDraftResult =
| InterruptResult | InterruptResult<GmailCreateDraftContext>
| SuccessResult | SuccessResult
| ErrorResult | ErrorResult
| InsufficientPermissionsResult | InsufficientPermissionsResult
| AuthErrorResult; | 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 { function isErrorResult(result: unknown): result is ErrorResult {
return ( return (
typeof result === "object" && typeof result === "object" &&
@ -116,12 +96,8 @@ function ApprovalCard({
onDecision, onDecision,
}: { }: {
args: { to: string; subject: string; body: string; cc?: string; bcc?: string }; args: { to: string; subject: string; body: string; cc?: string; bcc?: string };
interruptData: InterruptResult; interruptData: InterruptResult<GmailCreateDraftContext>;
onDecision: (decision: { onDecision: (decision: HitlDecision) => void;
type: "approve" | "reject" | "edit";
message?: string;
edited_action?: { name: string; args: Record<string, unknown> };
}) => void;
}) { }) {
const { phase, setProcessing, setRejected } = useHitlPhase(interruptData); const { phase, setProcessing, setRejected } = useHitlPhase(interruptData);
const [isPanelOpen, setIsPanelOpen] = useState(false); const [isPanelOpen, setIsPanelOpen] = useState(false);
@ -473,18 +449,16 @@ export const CreateGmailDraftToolUI = ({
{ to: string; subject: string; body: string; cc?: string; bcc?: string }, { to: string; subject: string; body: string; cc?: string; bcc?: string },
CreateGmailDraftResult CreateGmailDraftResult
>) => { >) => {
const { dispatch } = useHitlDecision();
if (!result) return null; if (!result) return null;
if (isInterruptResult(result)) { if (isInterruptResult(result)) {
return ( return (
<ApprovalCard <ApprovalCard
args={args} args={args}
interruptData={result} interruptData={result as InterruptResult<GmailCreateDraftContext>}
onDecision={(decision) => { onDecision={(decision) => dispatch([decision])}
window.dispatchEvent(
new CustomEvent("hitl-decision", { detail: { decisions: [decision] } })
);
}}
/> />
); );
} }

View file

@ -16,6 +16,8 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
import type { HitlDecision, InterruptResult } from "@/lib/hitl";
import { useHitlPhase } from "@/hooks/use-hitl-phase"; import { useHitlPhase } from "@/hooks/use-hitl-phase";
interface GmailAccount { interface GmailAccount {
@ -25,22 +27,9 @@ interface GmailAccount {
auth_expired?: boolean; auth_expired?: boolean;
} }
interface InterruptResult { type GmailSendEmailContext = {
__interrupt__: true;
__decided__?: "approve" | "reject" | "edit";
__completed__?: boolean;
action_requests: Array<{
name: string;
args: Record<string, unknown>;
}>;
review_configs: Array<{
action_name: string;
allowed_decisions: Array<"approve" | "edit" | "reject">;
}>;
context?: {
accounts?: GmailAccount[]; accounts?: GmailAccount[];
error?: string; error?: string;
};
} }
interface SuccessResult { interface SuccessResult {
@ -67,21 +56,12 @@ interface InsufficientPermissionsResult {
} }
type SendGmailEmailResult = type SendGmailEmailResult =
| InterruptResult | InterruptResult<GmailSendEmailContext>
| SuccessResult | SuccessResult
| ErrorResult | ErrorResult
| InsufficientPermissionsResult | InsufficientPermissionsResult
| AuthErrorResult; | 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 { function isErrorResult(result: unknown): result is ErrorResult {
return ( return (
typeof result === "object" && typeof result === "object" &&
@ -115,12 +95,8 @@ function ApprovalCard({
onDecision, onDecision,
}: { }: {
args: { to: string; subject: string; body: string; cc?: string; bcc?: string }; args: { to: string; subject: string; body: string; cc?: string; bcc?: string };
interruptData: InterruptResult; interruptData: InterruptResult<GmailSendEmailContext>;
onDecision: (decision: { onDecision: (decision: HitlDecision) => void;
type: "approve" | "reject" | "edit";
message?: string;
edited_action?: { name: string; args: Record<string, unknown> };
}) => void;
}) { }) {
const { phase, setProcessing, setRejected } = useHitlPhase(interruptData); const { phase, setProcessing, setRejected } = useHitlPhase(interruptData);
const [isPanelOpen, setIsPanelOpen] = useState(false); const [isPanelOpen, setIsPanelOpen] = useState(false);
@ -471,18 +447,16 @@ export const SendGmailEmailToolUI = ({
{ to: string; subject: string; body: string; cc?: string; bcc?: string }, { to: string; subject: string; body: string; cc?: string; bcc?: string },
SendGmailEmailResult SendGmailEmailResult
>) => { >) => {
const { dispatch } = useHitlDecision();
if (!result) return null; if (!result) return null;
if (isInterruptResult(result)) { if (isInterruptResult(result)) {
return ( return (
<ApprovalCard <ApprovalCard
args={args} args={args}
interruptData={result} interruptData={result as InterruptResult<GmailSendEmailContext>}
onDecision={(decision) => { onDecision={(decision) => dispatch([decision])}
window.dispatchEvent(
new CustomEvent("hitl-decision", { detail: { decisions: [decision] } })
);
}}
/> />
); );
} }

View file

@ -6,6 +6,8 @@ import { useCallback, useEffect, useState } from "react";
import { TextShimmerLoader } from "@/components/prompt-kit/loader"; import { TextShimmerLoader } from "@/components/prompt-kit/loader";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox"; import { Checkbox } from "@/components/ui/checkbox";
import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
import type { HitlDecision, InterruptResult } from "@/lib/hitl";
import { useHitlPhase } from "@/hooks/use-hitl-phase"; import { useHitlPhase } from "@/hooks/use-hitl-phase";
interface GmailAccount { interface GmailAccount {
@ -25,23 +27,10 @@ interface GmailMessage {
document_id: number; document_id: number;
} }
interface InterruptResult { type GmailTrashEmailContext = {
__interrupt__: true;
__decided__?: "approve" | "reject";
__completed__?: boolean;
action_requests: Array<{
name: string;
args: Record<string, unknown>;
}>;
review_configs: Array<{
action_name: string;
allowed_decisions: Array<"approve" | "reject">;
}>;
context?: {
account?: GmailAccount; account?: GmailAccount;
email?: GmailMessage; email?: GmailMessage;
error?: string; error?: string;
};
} }
interface SuccessResult { interface SuccessResult {
@ -74,22 +63,13 @@ interface InsufficientPermissionsResult {
} }
type TrashGmailEmailResult = type TrashGmailEmailResult =
| InterruptResult | InterruptResult<GmailTrashEmailContext>
| SuccessResult | SuccessResult
| ErrorResult | ErrorResult
| NotFoundResult | NotFoundResult
| InsufficientPermissionsResult | InsufficientPermissionsResult
| AuthErrorResult; | 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 { function isErrorResult(result: unknown): result is ErrorResult {
return ( return (
typeof result === "object" && typeof result === "object" &&
@ -134,12 +114,8 @@ function ApprovalCard({
interruptData, interruptData,
onDecision, onDecision,
}: { }: {
interruptData: InterruptResult; interruptData: InterruptResult<GmailTrashEmailContext>;
onDecision: (decision: { onDecision: (decision: HitlDecision) => void;
type: "approve" | "reject";
message?: string;
edited_action?: { name: string; args: Record<string, unknown> };
}) => void;
}) { }) {
const { phase, setProcessing, setRejected } = useHitlPhase(interruptData); const { phase, setProcessing, setRejected } = useHitlPhase(interruptData);
const [deleteFromKb, setDeleteFromKb] = useState(false); const [deleteFromKb, setDeleteFromKb] = useState(false);
@ -385,18 +361,15 @@ export const TrashGmailEmailToolUI = ({
{ email_subject_or_id: string; delete_from_kb?: boolean }, { email_subject_or_id: string; delete_from_kb?: boolean },
TrashGmailEmailResult TrashGmailEmailResult
>) => { >) => {
const { dispatch } = useHitlDecision();
if (!result) return null; if (!result) return null;
if (isInterruptResult(result)) { if (isInterruptResult(result)) {
return ( return (
<ApprovalCard <ApprovalCard
interruptData={result} interruptData={result as InterruptResult<GmailTrashEmailContext>}
onDecision={(decision) => { onDecision={(decision) => dispatch([decision])}
const event = new CustomEvent("hitl-decision", {
detail: { decisions: [decision] },
});
window.dispatchEvent(event);
}}
/> />
); );
} }

View file

@ -9,6 +9,8 @@ import { openHitlEditPanelAtom } from "@/atoms/chat/hitl-edit-panel.atom";
import { PlateEditor } from "@/components/editor/plate-editor"; import { PlateEditor } from "@/components/editor/plate-editor";
import { TextShimmerLoader } from "@/components/prompt-kit/loader"; import { TextShimmerLoader } from "@/components/prompt-kit/loader";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
import type { HitlDecision, InterruptResult } from "@/lib/hitl";
import { useHitlPhase } from "@/hooks/use-hitl-phase"; import { useHitlPhase } from "@/hooks/use-hitl-phase";
interface GmailAccount { interface GmailAccount {
@ -28,25 +30,12 @@ interface GmailMessage {
document_id: number; document_id: number;
} }
interface InterruptResult { type GmailUpdateDraftContext = {
__interrupt__: true;
__decided__?: "approve" | "reject" | "edit";
__completed__?: boolean;
action_requests: Array<{
name: string;
args: Record<string, unknown>;
}>;
review_configs: Array<{
action_name: string;
allowed_decisions: Array<"approve" | "edit" | "reject">;
}>;
context?: {
account?: GmailAccount; account?: GmailAccount;
email?: GmailMessage; email?: GmailMessage;
draft_id?: string; draft_id?: string;
existing_body?: string; existing_body?: string;
error?: string; error?: string;
};
} }
interface SuccessResult { interface SuccessResult {
@ -78,22 +67,13 @@ interface InsufficientPermissionsResult {
} }
type UpdateGmailDraftResult = type UpdateGmailDraftResult =
| InterruptResult | InterruptResult<GmailUpdateDraftContext>
| SuccessResult | SuccessResult
| ErrorResult | ErrorResult
| NotFoundResult | NotFoundResult
| InsufficientPermissionsResult | InsufficientPermissionsResult
| AuthErrorResult; | 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 { function isErrorResult(result: unknown): result is ErrorResult {
return ( return (
typeof result === "object" && typeof result === "object" &&
@ -143,12 +123,8 @@ function ApprovalCard({
cc?: string; cc?: string;
bcc?: string; bcc?: string;
}; };
interruptData: InterruptResult; interruptData: InterruptResult<GmailUpdateDraftContext>;
onDecision: (decision: { onDecision: (decision: HitlDecision) => void;
type: "approve" | "reject" | "edit";
message?: string;
edited_action?: { name: string; args: Record<string, unknown> };
}) => void;
}) { }) {
const { phase, setProcessing, setRejected } = useHitlPhase(interruptData); const { phase, setProcessing, setRejected } = useHitlPhase(interruptData);
const [isPanelOpen, setIsPanelOpen] = useState(false); const [isPanelOpen, setIsPanelOpen] = useState(false);
@ -522,20 +498,16 @@ export const UpdateGmailDraftToolUI = ({
}, },
UpdateGmailDraftResult UpdateGmailDraftResult
>) => { >) => {
const { dispatch } = useHitlDecision();
if (!result) return null; if (!result) return null;
if (isInterruptResult(result)) { if (isInterruptResult(result)) {
return ( return (
<ApprovalCard <ApprovalCard
args={args} args={args}
interruptData={result} interruptData={result as InterruptResult<GmailUpdateDraftContext>}
onDecision={(decision) => { onDecision={(decision) => dispatch([decision])}
window.dispatchEvent(
new CustomEvent("hitl-decision", {
detail: { decisions: [decision] },
})
);
}}
/> />
); );
} }

View file

@ -16,6 +16,8 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
import type { HitlDecision, InterruptResult } from "@/lib/hitl";
import { useHitlPhase } from "@/hooks/use-hitl-phase"; import { useHitlPhase } from "@/hooks/use-hitl-phase";
interface GoogleCalendarAccount { interface GoogleCalendarAccount {
@ -30,24 +32,11 @@ interface CalendarEntry {
primary?: boolean; primary?: boolean;
} }
interface InterruptResult { type CalendarCreateEventContext = {
__interrupt__: true;
__decided__?: "approve" | "reject" | "edit";
__completed__?: boolean;
action_requests: Array<{
name: string;
args: Record<string, unknown>;
}>;
review_configs: Array<{
action_name: string;
allowed_decisions: Array<"approve" | "edit" | "reject">;
}>;
context?: {
accounts?: GoogleCalendarAccount[]; accounts?: GoogleCalendarAccount[];
calendars?: CalendarEntry[]; calendars?: CalendarEntry[];
timezone?: string; timezone?: string;
error?: string; error?: string;
};
} }
interface SuccessResult { interface SuccessResult {
@ -75,21 +64,12 @@ interface InsufficientPermissionsResult {
} }
type CreateCalendarEventResult = type CreateCalendarEventResult =
| InterruptResult | InterruptResult<CalendarCreateEventContext>
| SuccessResult | SuccessResult
| ErrorResult | ErrorResult
| InsufficientPermissionsResult | InsufficientPermissionsResult
| AuthErrorResult; | 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 { function isErrorResult(result: unknown): result is ErrorResult {
return ( return (
typeof result === "object" && typeof result === "object" &&
@ -141,12 +121,8 @@ function ApprovalCard({
location?: string; location?: string;
attendees?: string[]; attendees?: string[];
}; };
interruptData: InterruptResult; interruptData: InterruptResult<CalendarCreateEventContext>;
onDecision: (decision: { onDecision: (decision: HitlDecision) => void;
type: "approve" | "reject" | "edit";
message?: string;
edited_action?: { name: string; args: Record<string, unknown> };
}) => void;
}) { }) {
const { phase, setProcessing, setRejected } = useHitlPhase(interruptData); const { phase, setProcessing, setRejected } = useHitlPhase(interruptData);
const [isPanelOpen, setIsPanelOpen] = useState(false); const [isPanelOpen, setIsPanelOpen] = useState(false);
@ -620,18 +596,16 @@ export const CreateCalendarEventToolUI = ({
}, },
CreateCalendarEventResult CreateCalendarEventResult
>) => { >) => {
const { dispatch } = useHitlDecision();
if (!result) return null; if (!result) return null;
if (isInterruptResult(result)) { if (isInterruptResult(result)) {
return ( return (
<ApprovalCard <ApprovalCard
args={args} args={args}
interruptData={result} interruptData={result as InterruptResult<CalendarCreateEventContext>}
onDecision={(decision) => { onDecision={(decision) => dispatch([decision])}
window.dispatchEvent(
new CustomEvent("hitl-decision", { detail: { decisions: [decision] } })
);
}}
/> />
); );
} }

View file

@ -6,6 +6,8 @@ import { useCallback, useEffect, useState } from "react";
import { TextShimmerLoader } from "@/components/prompt-kit/loader"; import { TextShimmerLoader } from "@/components/prompt-kit/loader";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox"; import { Checkbox } from "@/components/ui/checkbox";
import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
import type { HitlDecision, InterruptResult } from "@/lib/hitl";
import { useHitlPhase } from "@/hooks/use-hitl-phase"; import { useHitlPhase } from "@/hooks/use-hitl-phase";
interface GoogleCalendarAccount { interface GoogleCalendarAccount {
@ -27,23 +29,10 @@ interface CalendarEvent {
indexed_at?: string; indexed_at?: string;
} }
interface InterruptResult { type CalendarDeleteEventContext = {
__interrupt__: true;
__decided__?: "approve" | "reject";
__completed__?: boolean;
action_requests: Array<{
name: string;
args: Record<string, unknown>;
}>;
review_configs: Array<{
action_name: string;
allowed_decisions: Array<"approve" | "reject">;
}>;
context?: {
account?: GoogleCalendarAccount; account?: GoogleCalendarAccount;
event?: CalendarEvent; event?: CalendarEvent;
error?: string; error?: string;
};
} }
interface SuccessResult { interface SuccessResult {
@ -83,7 +72,7 @@ interface InsufficientPermissionsResult {
} }
type DeleteCalendarEventResult = type DeleteCalendarEventResult =
| InterruptResult | InterruptResult<CalendarDeleteEventContext>
| SuccessResult | SuccessResult
| ErrorResult | ErrorResult
| NotFoundResult | NotFoundResult
@ -91,15 +80,6 @@ type DeleteCalendarEventResult =
| InsufficientPermissionsResult | InsufficientPermissionsResult
| AuthErrorResult; | 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 { function isErrorResult(result: unknown): result is ErrorResult {
return ( return (
typeof result === "object" && typeof result === "object" &&
@ -162,12 +142,8 @@ function ApprovalCard({
interruptData, interruptData,
onDecision, onDecision,
}: { }: {
interruptData: InterruptResult; interruptData: InterruptResult<CalendarDeleteEventContext>;
onDecision: (decision: { onDecision: (decision: HitlDecision) => void;
type: "approve" | "reject";
message?: string;
edited_action?: { name: string; args: Record<string, unknown> };
}) => void;
}) { }) {
const { phase, setProcessing, setRejected } = useHitlPhase(interruptData); const { phase, setProcessing, setRejected } = useHitlPhase(interruptData);
const [deleteFromKb, setDeleteFromKb] = useState(false); const [deleteFromKb, setDeleteFromKb] = useState(false);
@ -437,18 +413,15 @@ export const DeleteCalendarEventToolUI = ({
{ event_title_or_id: string; delete_from_kb?: boolean }, { event_title_or_id: string; delete_from_kb?: boolean },
DeleteCalendarEventResult DeleteCalendarEventResult
>) => { >) => {
const { dispatch } = useHitlDecision();
if (!result) return null; if (!result) return null;
if (isInterruptResult(result)) { if (isInterruptResult(result)) {
return ( return (
<ApprovalCard <ApprovalCard
interruptData={result} interruptData={result as InterruptResult<CalendarDeleteEventContext>}
onDecision={(decision) => { onDecision={(decision) => dispatch([decision])}
const event = new CustomEvent("hitl-decision", {
detail: { decisions: [decision] },
});
window.dispatchEvent(event);
}}
/> />
); );
} }

View file

@ -16,6 +16,8 @@ import { openHitlEditPanelAtom } from "@/atoms/chat/hitl-edit-panel.atom";
import { PlateEditor } from "@/components/editor/plate-editor"; import { PlateEditor } from "@/components/editor/plate-editor";
import { TextShimmerLoader } from "@/components/prompt-kit/loader"; import { TextShimmerLoader } from "@/components/prompt-kit/loader";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
import type { HitlDecision, InterruptResult } from "@/lib/hitl";
import { useHitlPhase } from "@/hooks/use-hitl-phase"; import { useHitlPhase } from "@/hooks/use-hitl-phase";
interface GoogleCalendarAccount { interface GoogleCalendarAccount {
@ -37,23 +39,10 @@ interface CalendarEvent {
indexed_at?: string; indexed_at?: string;
} }
interface InterruptResult { type CalendarUpdateEventContext = {
__interrupt__: true;
__decided__?: "approve" | "reject" | "edit";
__completed__?: boolean;
action_requests: Array<{
name: string;
args: Record<string, unknown>;
}>;
review_configs: Array<{
action_name: string;
allowed_decisions: Array<"approve" | "edit" | "reject">;
}>;
context?: {
account?: GoogleCalendarAccount; account?: GoogleCalendarAccount;
event?: CalendarEvent; event?: CalendarEvent;
error?: string; error?: string;
};
} }
interface SuccessResult { interface SuccessResult {
@ -86,22 +75,13 @@ interface InsufficientPermissionsResult {
} }
type UpdateCalendarEventResult = type UpdateCalendarEventResult =
| InterruptResult | InterruptResult<CalendarUpdateEventContext>
| SuccessResult | SuccessResult
| ErrorResult | ErrorResult
| NotFoundResult | NotFoundResult
| InsufficientPermissionsResult | InsufficientPermissionsResult
| AuthErrorResult; | 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 { function isErrorResult(result: unknown): result is ErrorResult {
return ( return (
typeof result === "object" && typeof result === "object" &&
@ -163,12 +143,8 @@ function ApprovalCard({
new_location?: string; new_location?: string;
new_attendees?: string[]; new_attendees?: string[];
}; };
interruptData: InterruptResult; interruptData: InterruptResult<CalendarUpdateEventContext>;
onDecision: (decision: { onDecision: (decision: HitlDecision) => void;
type: "approve" | "reject" | "edit";
message?: string;
edited_action?: { name: string; args: Record<string, unknown> };
}) => void;
}) { }) {
const { phase, setProcessing, setRejected } = useHitlPhase(interruptData); const { phase, setProcessing, setRejected } = useHitlPhase(interruptData);
const actionArgs = interruptData.action_requests[0]?.args ?? {}; const actionArgs = interruptData.action_requests[0]?.args ?? {};
@ -686,18 +662,16 @@ export const UpdateCalendarEventToolUI = ({
}, },
UpdateCalendarEventResult UpdateCalendarEventResult
>) => { >) => {
const { dispatch } = useHitlDecision();
if (!result) return null; if (!result) return null;
if (isInterruptResult(result)) { if (isInterruptResult(result)) {
return ( return (
<ApprovalCard <ApprovalCard
args={args} args={args}
interruptData={result} interruptData={result as InterruptResult<CalendarUpdateEventContext>}
onDecision={(decision) => { onDecision={(decision) => dispatch([decision])}
window.dispatchEvent(
new CustomEvent("hitl-decision", { detail: { decisions: [decision] } })
);
}}
/> />
); );
} }

View file

@ -16,6 +16,8 @@ import {
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { useHitlPhase } from "@/hooks/use-hitl-phase"; import { useHitlPhase } from "@/hooks/use-hitl-phase";
import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
import type { InterruptResult, HitlDecision } from "@/lib/hitl";
interface GoogleDriveAccount { interface GoogleDriveAccount {
id: number; id: number;
@ -23,24 +25,11 @@ interface GoogleDriveAccount {
auth_expired?: boolean; auth_expired?: boolean;
} }
interface InterruptResult { type DriveCreateFileContext = {
__interrupt__: true;
__decided__?: "approve" | "reject" | "edit";
__completed__?: boolean;
action_requests: Array<{
name: string;
args: Record<string, unknown>;
}>;
review_configs: Array<{
action_name: string;
allowed_decisions: Array<"approve" | "edit" | "reject">;
}>;
context?: {
accounts?: GoogleDriveAccount[]; accounts?: GoogleDriveAccount[];
supported_types?: string[]; supported_types?: string[];
parent_folders?: Record<number, Array<{ folder_id: string; name: string }>>; parent_folders?: Record<number, Array<{ folder_id: string; name: string }>>;
error?: string; error?: string;
};
} }
interface SuccessResult { interface SuccessResult {
@ -69,21 +58,12 @@ interface AuthErrorResult {
} }
type CreateGoogleDriveFileResult = type CreateGoogleDriveFileResult =
| InterruptResult | InterruptResult<DriveCreateFileContext>
| SuccessResult | SuccessResult
| ErrorResult | ErrorResult
| InsufficientPermissionsResult | InsufficientPermissionsResult
| AuthErrorResult; | 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 { function isErrorResult(result: unknown): result is ErrorResult {
return ( return (
typeof result === "object" && typeof result === "object" &&
@ -122,12 +102,8 @@ function ApprovalCard({
onDecision, onDecision,
}: { }: {
args: { name: string; file_type: string; content?: string }; args: { name: string; file_type: string; content?: string };
interruptData: InterruptResult; interruptData: InterruptResult<DriveCreateFileContext>;
onDecision: (decision: { onDecision: (decision: HitlDecision) => void;
type: "approve" | "reject" | "edit";
message?: string;
edited_action?: { name: string; args: Record<string, unknown> };
}) => void;
}) { }) {
const { phase, setProcessing, setRejected } = useHitlPhase(interruptData); const { phase, setProcessing, setRejected } = useHitlPhase(interruptData);
const [isPanelOpen, setIsPanelOpen] = useState(false); const [isPanelOpen, setIsPanelOpen] = useState(false);
@ -499,18 +475,15 @@ export const CreateGoogleDriveFileToolUI = ({
{ name: string; file_type: string; content?: string }, { name: string; file_type: string; content?: string },
CreateGoogleDriveFileResult CreateGoogleDriveFileResult
>) => { >) => {
const { dispatch } = useHitlDecision();
if (!result) return null; if (!result) return null;
if (isInterruptResult(result)) { if (isInterruptResult(result)) {
return ( return (
<ApprovalCard <ApprovalCard
args={args} args={args}
interruptData={result} interruptData={result as InterruptResult<DriveCreateFileContext>}
onDecision={(decision) => { onDecision={(decision) => dispatch([decision])}
window.dispatchEvent(
new CustomEvent("hitl-decision", { detail: { decisions: [decision] } })
);
}}
/> />
); );
} }

View file

@ -7,6 +7,8 @@ import { TextShimmerLoader } from "@/components/prompt-kit/loader";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox"; import { Checkbox } from "@/components/ui/checkbox";
import { useHitlPhase } from "@/hooks/use-hitl-phase"; import { useHitlPhase } from "@/hooks/use-hitl-phase";
import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
import type { InterruptResult, HitlDecision } from "@/lib/hitl";
interface GoogleDriveAccount { interface GoogleDriveAccount {
id: number; id: number;
@ -21,23 +23,10 @@ interface GoogleDriveFile {
web_view_link: string; web_view_link: string;
} }
interface InterruptResult { type DriveTrashFileContext = {
__interrupt__: true;
__decided__?: "approve" | "reject";
__completed__?: boolean;
action_requests: Array<{
name: string;
args: Record<string, unknown>;
}>;
review_configs: Array<{
action_name: string;
allowed_decisions: Array<"approve" | "reject">;
}>;
context?: {
account?: GoogleDriveAccount; account?: GoogleDriveAccount;
file?: GoogleDriveFile; file?: GoogleDriveFile;
error?: string; error?: string;
};
} }
interface SuccessResult { interface SuccessResult {
@ -77,7 +66,7 @@ interface AuthErrorResult {
} }
type DeleteGoogleDriveFileResult = type DeleteGoogleDriveFileResult =
| InterruptResult | InterruptResult<DriveTrashFileContext>
| SuccessResult | SuccessResult
| WarningResult | WarningResult
| ErrorResult | ErrorResult
@ -85,15 +74,6 @@ type DeleteGoogleDriveFileResult =
| InsufficientPermissionsResult | InsufficientPermissionsResult
| AuthErrorResult; | 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 { function isErrorResult(result: unknown): result is ErrorResult {
return ( return (
typeof result === "object" && typeof result === "object" &&
@ -151,12 +131,8 @@ function ApprovalCard({
interruptData, interruptData,
onDecision, onDecision,
}: { }: {
interruptData: InterruptResult; interruptData: InterruptResult<DriveTrashFileContext>;
onDecision: (decision: { onDecision: (decision: HitlDecision) => void;
type: "approve" | "reject";
message?: string;
edited_action?: { name: string; args: Record<string, unknown> };
}) => void;
}) { }) {
const { phase, setProcessing, setRejected } = useHitlPhase(interruptData); const { phase, setProcessing, setRejected } = useHitlPhase(interruptData);
const [deleteFromKb, setDeleteFromKb] = useState(false); const [deleteFromKb, setDeleteFromKb] = useState(false);
@ -416,18 +392,14 @@ export const DeleteGoogleDriveFileToolUI = ({
{ file_name: string; delete_from_kb?: boolean }, { file_name: string; delete_from_kb?: boolean },
DeleteGoogleDriveFileResult DeleteGoogleDriveFileResult
>) => { >) => {
const { dispatch } = useHitlDecision();
if (!result) return null; if (!result) return null;
if (isInterruptResult(result)) { if (isInterruptResult(result)) {
return ( return (
<ApprovalCard <ApprovalCard
interruptData={result} interruptData={result as InterruptResult<DriveTrashFileContext>}
onDecision={(decision) => { onDecision={(decision) => dispatch([decision])}
const event = new CustomEvent("hitl-decision", {
detail: { decisions: [decision] },
});
window.dispatchEvent(event);
}}
/> />
); );
} }

View file

@ -15,6 +15,8 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
import type { InterruptResult, HitlDecision } from "@/lib/hitl";
import { useHitlPhase } from "@/hooks/use-hitl-phase"; import { useHitlPhase } from "@/hooks/use-hitl-phase";
interface JiraAccount { interface JiraAccount {
@ -40,26 +42,12 @@ interface JiraPriority {
name: string; name: string;
} }
interface InterruptResult { type CreateJiraIssueInterruptContext = {
__interrupt__: true;
__decided__?: "approve" | "reject" | "edit";
__completed__?: boolean;
action_requests: Array<{
name: string;
args: Record<string, unknown>;
}>;
review_configs: Array<{
action_name: string;
allowed_decisions: Array<"approve" | "edit" | "reject">;
}>;
interrupt_type?: string;
context?: {
accounts?: JiraAccount[]; accounts?: JiraAccount[];
projects?: JiraProject[]; projects?: JiraProject[];
issue_types?: JiraIssueType[]; issue_types?: JiraIssueType[];
priorities?: JiraPriority[]; priorities?: JiraPriority[];
error?: string; error?: string;
};
} }
interface SuccessResult { interface SuccessResult {
@ -88,21 +76,12 @@ interface InsufficientPermissionsResult {
} }
type CreateJiraIssueResult = type CreateJiraIssueResult =
| InterruptResult | InterruptResult<CreateJiraIssueInterruptContext>
| SuccessResult | SuccessResult
| ErrorResult | ErrorResult
| AuthErrorResult | AuthErrorResult
| InsufficientPermissionsResult; | InsufficientPermissionsResult;
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 { function isErrorResult(result: unknown): result is ErrorResult {
return ( return (
typeof result === "object" && typeof result === "object" &&
@ -142,12 +121,8 @@ function ApprovalCard({
description?: string; description?: string;
priority?: string; priority?: string;
}; };
interruptData: InterruptResult; interruptData: InterruptResult<CreateJiraIssueInterruptContext>;
onDecision: (decision: { onDecision: (decision: HitlDecision) => void;
type: "approve" | "reject" | "edit";
message?: string;
edited_action?: { name: string; args: Record<string, unknown> };
}) => void;
}) { }) {
const { phase, setProcessing, setRejected } = useHitlPhase(interruptData); const { phase, setProcessing, setRejected } = useHitlPhase(interruptData);
const [isPanelOpen, setIsPanelOpen] = useState(false); const [isPanelOpen, setIsPanelOpen] = useState(false);
@ -549,18 +524,16 @@ export const CreateJiraIssueToolUI = ({
}, },
CreateJiraIssueResult CreateJiraIssueResult
>) => { >) => {
const { dispatch } = useHitlDecision();
if (!result) return null; if (!result) return null;
if (isInterruptResult(result)) { if (isInterruptResult(result)) {
return ( return (
<ApprovalCard <ApprovalCard
args={args} args={args}
interruptData={result} interruptData={result as InterruptResult<CreateJiraIssueInterruptContext>}
onDecision={(decision) => { onDecision={(decision) => dispatch([decision])}
window.dispatchEvent(
new CustomEvent("hitl-decision", { detail: { decisions: [decision] } })
);
}}
/> />
); );
} }

View file

@ -6,6 +6,8 @@ import { useCallback, useEffect, useState } from "react";
import { TextShimmerLoader } from "@/components/prompt-kit/loader"; import { TextShimmerLoader } from "@/components/prompt-kit/loader";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox"; import { Checkbox } from "@/components/ui/checkbox";
import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
import type { InterruptResult, HitlDecision } from "@/lib/hitl";
import { useHitlPhase } from "@/hooks/use-hitl-phase"; import { useHitlPhase } from "@/hooks/use-hitl-phase";
interface JiraAccount { interface JiraAccount {
@ -23,24 +25,10 @@ interface JiraIssue {
document_id?: number; document_id?: number;
} }
interface InterruptResult { type DeleteJiraIssueInterruptContext = {
__interrupt__: true;
__decided__?: "approve" | "reject";
__completed__?: boolean;
action_requests: Array<{
name: string;
args: Record<string, unknown>;
}>;
review_configs: Array<{
action_name: string;
allowed_decisions: Array<"approve" | "reject">;
}>;
interrupt_type?: string;
context?: {
account?: JiraAccount; account?: JiraAccount;
issue?: JiraIssue; issue?: JiraIssue;
error?: string; error?: string;
};
} }
interface SuccessResult { interface SuccessResult {
@ -79,7 +67,7 @@ interface InsufficientPermissionsResult {
} }
type DeleteJiraIssueResult = type DeleteJiraIssueResult =
| InterruptResult | InterruptResult<DeleteJiraIssueInterruptContext>
| SuccessResult | SuccessResult
| ErrorResult | ErrorResult
| NotFoundResult | NotFoundResult
@ -87,15 +75,6 @@ type DeleteJiraIssueResult =
| AuthErrorResult | AuthErrorResult
| InsufficientPermissionsResult; | InsufficientPermissionsResult;
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 { function isErrorResult(result: unknown): result is ErrorResult {
return ( return (
typeof result === "object" && typeof result === "object" &&
@ -147,12 +126,8 @@ function ApprovalCard({
interruptData, interruptData,
onDecision, onDecision,
}: { }: {
interruptData: InterruptResult; interruptData: InterruptResult<DeleteJiraIssueInterruptContext>;
onDecision: (decision: { onDecision: (decision: HitlDecision) => void;
type: "approve" | "reject";
message?: string;
edited_action?: { name: string; args: Record<string, unknown> };
}) => void;
}) { }) {
const { phase, setProcessing, setRejected } = useHitlPhase(interruptData); const { phase, setProcessing, setRejected } = useHitlPhase(interruptData);
const [deleteFromKb, setDeleteFromKb] = useState(false); const [deleteFromKb, setDeleteFromKb] = useState(false);
@ -399,18 +374,15 @@ export const DeleteJiraIssueToolUI = ({
{ issue_title_or_key: string; delete_from_kb?: boolean }, { issue_title_or_key: string; delete_from_kb?: boolean },
DeleteJiraIssueResult DeleteJiraIssueResult
>) => { >) => {
const { dispatch } = useHitlDecision();
if (!result) return null; if (!result) return null;
if (isInterruptResult(result)) { if (isInterruptResult(result)) {
return ( return (
<ApprovalCard <ApprovalCard
interruptData={result} interruptData={result as InterruptResult<DeleteJiraIssueInterruptContext>}
onDecision={(decision) => { onDecision={(decision) => dispatch([decision])}
const event = new CustomEvent("hitl-decision", {
detail: { decisions: [decision] },
});
window.dispatchEvent(event);
}}
/> />
); );
} }

View file

@ -16,6 +16,8 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
import type { InterruptResult, HitlDecision } from "@/lib/hitl";
import { useHitlPhase } from "@/hooks/use-hitl-phase"; import { useHitlPhase } from "@/hooks/use-hitl-phase";
interface JiraIssue { interface JiraIssue {
@ -43,25 +45,11 @@ interface JiraPriority {
name: string; name: string;
} }
interface InterruptResult { type UpdateJiraIssueInterruptContext = {
__interrupt__: true;
__decided__?: "approve" | "reject" | "edit";
__completed__?: boolean;
action_requests: Array<{
name: string;
args: Record<string, unknown>;
}>;
review_configs: Array<{
action_name: string;
allowed_decisions: Array<"approve" | "edit" | "reject">;
}>;
interrupt_type?: string;
context?: {
account?: JiraAccount; account?: JiraAccount;
issue?: JiraIssue; issue?: JiraIssue;
priorities?: JiraPriority[]; priorities?: JiraPriority[];
error?: string; error?: string;
};
} }
interface SuccessResult { interface SuccessResult {
@ -95,22 +83,13 @@ interface InsufficientPermissionsResult {
} }
type UpdateJiraIssueResult = type UpdateJiraIssueResult =
| InterruptResult | InterruptResult<UpdateJiraIssueInterruptContext>
| SuccessResult | SuccessResult
| ErrorResult | ErrorResult
| NotFoundResult | NotFoundResult
| AuthErrorResult | AuthErrorResult
| InsufficientPermissionsResult; | InsufficientPermissionsResult;
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 { function isErrorResult(result: unknown): result is ErrorResult {
return ( return (
typeof result === "object" && typeof result === "object" &&
@ -158,12 +137,8 @@ function ApprovalCard({
new_description?: string; new_description?: string;
new_priority?: string; new_priority?: string;
}; };
interruptData: InterruptResult; interruptData: InterruptResult<UpdateJiraIssueInterruptContext>;
onDecision: (decision: { onDecision: (decision: HitlDecision) => void;
type: "approve" | "reject" | "edit";
message?: string;
edited_action?: { name: string; args: Record<string, unknown> };
}) => void;
}) { }) {
const { phase, setProcessing, setRejected } = useHitlPhase(interruptData); const { phase, setProcessing, setRejected } = useHitlPhase(interruptData);
@ -563,18 +538,16 @@ export const UpdateJiraIssueToolUI = ({
}, },
UpdateJiraIssueResult UpdateJiraIssueResult
>) => { >) => {
const { dispatch } = useHitlDecision();
if (!result) return null; if (!result) return null;
if (isInterruptResult(result)) { if (isInterruptResult(result)) {
return ( return (
<ApprovalCard <ApprovalCard
args={args} args={args}
interruptData={result} interruptData={result as InterruptResult<UpdateJiraIssueInterruptContext>}
onDecision={(decision) => { onDecision={(decision) => dispatch([decision])}
window.dispatchEvent(
new CustomEvent("hitl-decision", { detail: { decisions: [decision] } })
);
}}
/> />
); );
} }

View file

@ -18,6 +18,8 @@ import {
} from "@/components/ui/select"; } from "@/components/ui/select";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { useHitlPhase } from "@/hooks/use-hitl-phase"; import { useHitlPhase } from "@/hooks/use-hitl-phase";
import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
import type { InterruptResult, HitlDecision } from "@/lib/hitl";
interface LinearLabel { interface LinearLabel {
id: string; id: string;
@ -64,23 +66,9 @@ interface LinearWorkspace {
auth_expired?: boolean; auth_expired?: boolean;
} }
interface InterruptResult { type LinearCreateIssueContext = {
__interrupt__: true;
__decided__?: "approve" | "reject" | "edit";
__completed__?: boolean;
action_requests: Array<{
name: string;
args: Record<string, unknown>;
}>;
review_configs: Array<{
action_name: string;
allowed_decisions: Array<"approve" | "edit" | "reject">;
}>;
interrupt_type?: string;
context?: {
workspaces?: LinearWorkspace[]; workspaces?: LinearWorkspace[];
error?: string; error?: string;
};
} }
interface SuccessResult { interface SuccessResult {
@ -103,16 +91,7 @@ interface AuthErrorResult {
connector_type: string; connector_type: string;
} }
type CreateLinearIssueResult = InterruptResult | SuccessResult | ErrorResult | AuthErrorResult; type CreateLinearIssueResult = InterruptResult<LinearCreateIssueContext> | 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 { function isErrorResult(result: unknown): result is ErrorResult {
return ( return (
@ -138,12 +117,8 @@ function ApprovalCard({
onDecision, onDecision,
}: { }: {
args: { title: string; description?: string }; args: { title: string; description?: string };
interruptData: InterruptResult; interruptData: InterruptResult<LinearCreateIssueContext>;
onDecision: (decision: { onDecision: (decision: HitlDecision) => void;
type: "approve" | "reject" | "edit";
message?: string;
edited_action?: { name: string; args: Record<string, unknown> };
}) => void;
}) { }) {
const { phase, setProcessing, setRejected } = useHitlPhase(interruptData); const { phase, setProcessing, setRejected } = useHitlPhase(interruptData);
const [isPanelOpen, setIsPanelOpen] = useState(false); const [isPanelOpen, setIsPanelOpen] = useState(false);
@ -609,18 +584,16 @@ export const CreateLinearIssueToolUI = ({
args, args,
result, result,
}: ToolCallMessagePartProps<{ title: string; description?: string }, CreateLinearIssueResult>) => { }: ToolCallMessagePartProps<{ title: string; description?: string }, CreateLinearIssueResult>) => {
const { dispatch } = useHitlDecision();
if (!result) return null; if (!result) return null;
if (isInterruptResult(result)) { if (isInterruptResult(result)) {
return ( return (
<ApprovalCard <ApprovalCard
args={args} args={args}
interruptData={result} interruptData={result as InterruptResult<LinearCreateIssueContext>}
onDecision={(decision) => { onDecision={(decision) => dispatch([decision])}
window.dispatchEvent(
new CustomEvent("hitl-decision", { detail: { decisions: [decision] } })
);
}}
/> />
); );
} }

View file

@ -7,21 +7,10 @@ import { TextShimmerLoader } from "@/components/prompt-kit/loader";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox"; import { Checkbox } from "@/components/ui/checkbox";
import { useHitlPhase } from "@/hooks/use-hitl-phase"; import { useHitlPhase } from "@/hooks/use-hitl-phase";
import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
import type { InterruptResult, HitlDecision } from "@/lib/hitl";
interface InterruptResult { type LinearDeleteIssueContext = {
__interrupt__: true;
__decided__?: "approve" | "reject";
__completed__?: boolean;
action_requests: Array<{
name: string;
args: Record<string, unknown>;
}>;
review_configs: Array<{
action_name: string;
allowed_decisions: Array<"approve" | "reject">;
}>;
interrupt_type?: string;
context?: {
workspace?: { id: number; organization_name: string }; workspace?: { id: number; organization_name: string };
issue?: { issue?: {
id: string; id: string;
@ -32,7 +21,6 @@ interface InterruptResult {
indexed_at?: string; indexed_at?: string;
}; };
error?: string; error?: string;
};
} }
interface SuccessResult { interface SuccessResult {
@ -65,22 +53,13 @@ interface AuthErrorResult {
} }
type DeleteLinearIssueResult = type DeleteLinearIssueResult =
| InterruptResult | InterruptResult<LinearDeleteIssueContext>
| SuccessResult | SuccessResult
| ErrorResult | ErrorResult
| NotFoundResult | NotFoundResult
| WarningResult | WarningResult
| AuthErrorResult; | 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 { function isErrorResult(result: unknown): result is ErrorResult {
return ( return (
typeof result === "object" && typeof result === "object" &&
@ -123,12 +102,8 @@ function ApprovalCard({
interruptData, interruptData,
onDecision, onDecision,
}: { }: {
interruptData: InterruptResult; interruptData: InterruptResult<LinearDeleteIssueContext>;
onDecision: (decision: { onDecision: (decision: HitlDecision) => void;
type: "approve" | "reject";
message?: string;
edited_action?: { name: string; args: Record<string, unknown> };
}) => void;
}) { }) {
const { phase, setProcessing, setRejected } = useHitlPhase(interruptData); const { phase, setProcessing, setRejected } = useHitlPhase(interruptData);
const [deleteFromKb, setDeleteFromKb] = useState(false); const [deleteFromKb, setDeleteFromKb] = useState(false);
@ -366,18 +341,15 @@ export const DeleteLinearIssueToolUI = ({
{ issue_ref: string; delete_from_kb?: boolean }, { issue_ref: string; delete_from_kb?: boolean },
DeleteLinearIssueResult DeleteLinearIssueResult
>) => { >) => {
const { dispatch } = useHitlDecision();
if (!result) return null; if (!result) return null;
if (isInterruptResult(result)) { if (isInterruptResult(result)) {
return ( return (
<ApprovalCard <ApprovalCard
interruptData={result} interruptData={result as InterruptResult<LinearDeleteIssueContext>}
onDecision={(decision) => { onDecision={(decision) => dispatch([decision])}
const event = new CustomEvent("hitl-decision", {
detail: { decisions: [decision] },
});
window.dispatchEvent(event);
}}
/> />
); );
} }

View file

@ -18,6 +18,8 @@ import {
} from "@/components/ui/select"; } from "@/components/ui/select";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { useHitlPhase } from "@/hooks/use-hitl-phase"; import { useHitlPhase } from "@/hooks/use-hitl-phase";
import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
import type { InterruptResult, HitlDecision } from "@/lib/hitl";
interface LinearLabel { interface LinearLabel {
id: string; id: string;
@ -45,20 +47,7 @@ interface LinearPriority {
label: string; label: string;
} }
interface InterruptResult { type LinearUpdateIssueContext = {
__interrupt__: true;
__decided__?: "approve" | "reject" | "edit";
__completed__?: boolean;
action_requests: Array<{
name: string;
args: Record<string, unknown>;
}>;
review_configs: Array<{
action_name: string;
allowed_decisions: Array<"approve" | "edit" | "reject">;
}>;
interrupt_type?: string;
context?: {
workspace?: { id: number; organization_name: string }; workspace?: { id: number; organization_name: string };
priorities?: LinearPriority[]; priorities?: LinearPriority[];
issue?: { issue?: {
@ -83,7 +72,6 @@ interface InterruptResult {
labels: LinearLabel[]; labels: LinearLabel[];
}; };
error?: string; error?: string;
};
} }
interface SuccessResult { interface SuccessResult {
@ -111,21 +99,12 @@ interface AuthErrorResult {
} }
type UpdateLinearIssueResult = type UpdateLinearIssueResult =
| InterruptResult | InterruptResult<LinearUpdateIssueContext>
| SuccessResult | SuccessResult
| ErrorResult | ErrorResult
| NotFoundResult | NotFoundResult
| AuthErrorResult; | 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 { function isErrorResult(result: unknown): result is ErrorResult {
return ( return (
typeof result === "object" && typeof result === "object" &&
@ -167,12 +146,8 @@ function ApprovalCard({
new_priority?: number; new_priority?: number;
new_label_names?: string[]; new_label_names?: string[];
}; };
interruptData: InterruptResult; interruptData: InterruptResult<LinearUpdateIssueContext>;
onDecision: (decision: { onDecision: (decision: HitlDecision) => void;
type: "approve" | "reject" | "edit";
message?: string;
edited_action?: { name: string; args: Record<string, unknown> };
}) => void;
}) { }) {
const { phase, setProcessing, setRejected } = useHitlPhase(interruptData); const { phase, setProcessing, setRejected } = useHitlPhase(interruptData);
@ -752,18 +727,16 @@ export const UpdateLinearIssueToolUI = ({
}, },
UpdateLinearIssueResult UpdateLinearIssueResult
>) => { >) => {
const { dispatch } = useHitlDecision();
if (!result) return null; if (!result) return null;
if (isInterruptResult(result)) { if (isInterruptResult(result)) {
return ( return (
<ApprovalCard <ApprovalCard
args={args} args={args}
interruptData={result} interruptData={result as InterruptResult<LinearUpdateIssueContext>}
onDecision={(decision) => { onDecision={(decision) => dispatch([decision])}
window.dispatchEvent(
new CustomEvent("hitl-decision", { detail: { decisions: [decision] } })
);
}}
/> />
); );
} }

View file

@ -16,23 +16,10 @@ import {
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { useHitlPhase } from "@/hooks/use-hitl-phase"; import { useHitlPhase } from "@/hooks/use-hitl-phase";
import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
import type { InterruptResult, HitlDecision } from "@/lib/hitl";
interface InterruptResult { type NotionCreatePageContext = {
__interrupt__: true;
__decided__?: "approve" | "reject" | "edit";
__completed__?: boolean;
action_requests: Array<{
name: string;
args: Record<string, unknown>;
description?: string;
}>;
review_configs: Array<{
action_name: string;
allowed_decisions: Array<"approve" | "edit" | "reject">;
}>;
interrupt_type?: string;
message?: string;
context?: {
accounts?: Array<{ accounts?: Array<{
id: number; id: number;
name: string; name: string;
@ -50,7 +37,6 @@ interface InterruptResult {
}> }>
>; >;
error?: string; error?: string;
};
} }
interface SuccessResult { interface SuccessResult {
@ -75,16 +61,7 @@ interface AuthErrorResult {
connector_type: string; connector_type: string;
} }
type CreateNotionPageResult = InterruptResult | SuccessResult | ErrorResult | AuthErrorResult; type CreateNotionPageResult = InterruptResult<NotionCreatePageContext> | 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 isAuthErrorResult(result: unknown): result is AuthErrorResult { function isAuthErrorResult(result: unknown): result is AuthErrorResult {
return ( return (
@ -110,12 +87,8 @@ function ApprovalCard({
onDecision, onDecision,
}: { }: {
args: Record<string, unknown>; args: Record<string, unknown>;
interruptData: InterruptResult; interruptData: InterruptResult<NotionCreatePageContext>;
onDecision: (decision: { onDecision: (decision: HitlDecision) => void;
type: "approve" | "reject" | "edit";
message?: string;
edited_action?: { name: string; args: Record<string, unknown> };
}) => void;
}) { }) {
const { phase, setProcessing, setRejected } = useHitlPhase(interruptData); const { phase, setProcessing, setRejected } = useHitlPhase(interruptData);
const [isPanelOpen, setIsPanelOpen] = useState(false); const [isPanelOpen, setIsPanelOpen] = useState(false);
@ -449,19 +422,16 @@ export const CreateNotionPageToolUI = ({
args, args,
result, result,
}: ToolCallMessagePartProps<{ title: string; content: string }, CreateNotionPageResult>) => { }: ToolCallMessagePartProps<{ title: string; content: string }, CreateNotionPageResult>) => {
const { dispatch } = useHitlDecision();
if (!result) return null; if (!result) return null;
if (isInterruptResult(result)) { if (isInterruptResult(result)) {
return ( return (
<ApprovalCard <ApprovalCard
args={args} args={args}
interruptData={result} interruptData={result as InterruptResult<NotionCreatePageContext>}
onDecision={(decision) => { onDecision={(decision) => dispatch([decision])}
const event = new CustomEvent("hitl-decision", {
detail: { decisions: [decision] },
});
window.dispatchEvent(event);
}}
/> />
); );
} }

View file

@ -7,23 +7,10 @@ import { TextShimmerLoader } from "@/components/prompt-kit/loader";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox"; import { Checkbox } from "@/components/ui/checkbox";
import { useHitlPhase } from "@/hooks/use-hitl-phase"; import { useHitlPhase } from "@/hooks/use-hitl-phase";
import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
import type { InterruptResult, HitlDecision } from "@/lib/hitl";
interface InterruptResult { type NotionDeletePageContext = {
__interrupt__: true;
__decided__?: "approve" | "reject";
__completed__?: boolean;
action_requests: Array<{
name: string;
args: Record<string, unknown>;
description?: string;
}>;
review_configs: Array<{
action_name: string;
allowed_decisions: Array<"approve" | "reject">;
}>;
interrupt_type?: string;
message?: string;
context?: {
account?: { account?: {
id: number; id: number;
name: string; name: string;
@ -36,7 +23,6 @@ interface InterruptResult {
document_id?: number; document_id?: number;
indexed_at?: string; indexed_at?: string;
error?: string; error?: string;
};
} }
interface SuccessResult { interface SuccessResult {
@ -73,22 +59,13 @@ interface AuthErrorResult {
} }
type DeleteNotionPageResult = type DeleteNotionPageResult =
| InterruptResult | InterruptResult<NotionDeletePageContext>
| SuccessResult | SuccessResult
| ErrorResult | ErrorResult
| InfoResult | InfoResult
| WarningResult | WarningResult
| AuthErrorResult; | 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 { function isErrorResult(result: unknown): result is ErrorResult {
return ( return (
typeof result === "object" && typeof result === "object" &&
@ -131,12 +108,8 @@ function ApprovalCard({
interruptData, interruptData,
onDecision, onDecision,
}: { }: {
interruptData: InterruptResult; interruptData: InterruptResult<NotionDeletePageContext>;
onDecision: (decision: { onDecision: (decision: HitlDecision) => void;
type: "approve" | "reject";
message?: string;
edited_action?: { name: string; args: Record<string, unknown> };
}) => void;
}) { }) {
const { phase, setProcessing, setRejected } = useHitlPhase(interruptData); const { phase, setProcessing, setRejected } = useHitlPhase(interruptData);
const [deleteFromKb, setDeleteFromKb] = useState(false); const [deleteFromKb, setDeleteFromKb] = useState(false);
@ -378,18 +351,15 @@ export const DeleteNotionPageToolUI = ({
{ page_title: string; delete_from_kb?: boolean }, { page_title: string; delete_from_kb?: boolean },
DeleteNotionPageResult DeleteNotionPageResult
>) => { >) => {
const { dispatch } = useHitlDecision();
if (!result) return null; if (!result) return null;
if (isInterruptResult(result)) { if (isInterruptResult(result)) {
return ( return (
<ApprovalCard <ApprovalCard
interruptData={result} interruptData={result as InterruptResult<NotionDeletePageContext>}
onDecision={(decision) => { onDecision={(decision) => dispatch([decision])}
const event = new CustomEvent("hitl-decision", {
detail: { decisions: [decision] },
});
window.dispatchEvent(event);
}}
/> />
); );
} }

View file

@ -9,23 +9,10 @@ import { PlateEditor } from "@/components/editor/plate-editor";
import { TextShimmerLoader } from "@/components/prompt-kit/loader"; import { TextShimmerLoader } from "@/components/prompt-kit/loader";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { useHitlPhase } from "@/hooks/use-hitl-phase"; import { useHitlPhase } from "@/hooks/use-hitl-phase";
import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
import type { InterruptResult, HitlDecision } from "@/lib/hitl";
interface InterruptResult { type NotionUpdatePageContext = {
__interrupt__: true;
__decided__?: "approve" | "reject" | "edit";
__completed__?: boolean;
action_requests: Array<{
name: string;
args: Record<string, unknown>;
description?: string;
}>;
review_configs: Array<{
action_name: string;
allowed_decisions: Array<"approve" | "edit" | "reject">;
}>;
interrupt_type?: string;
message?: string;
context?: {
account?: { account?: {
id: number; id: number;
name: string; name: string;
@ -38,7 +25,6 @@ interface InterruptResult {
document_id?: number; document_id?: number;
indexed_at?: string; indexed_at?: string;
error?: string; error?: string;
};
} }
interface SuccessResult { interface SuccessResult {
@ -69,21 +55,12 @@ interface AuthErrorResult {
} }
type UpdateNotionPageResult = type UpdateNotionPageResult =
| InterruptResult | InterruptResult<NotionUpdatePageContext>
| SuccessResult | SuccessResult
| ErrorResult | ErrorResult
| InfoResult | InfoResult
| AuthErrorResult; | 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 { function isErrorResult(result: unknown): result is ErrorResult {
return ( return (
typeof result === "object" && typeof result === "object" &&
@ -117,12 +94,8 @@ function ApprovalCard({
onDecision, onDecision,
}: { }: {
args: Record<string, unknown>; args: Record<string, unknown>;
interruptData: InterruptResult; interruptData: InterruptResult<NotionUpdatePageContext>;
onDecision: (decision: { onDecision: (decision: HitlDecision) => void;
type: "approve" | "reject" | "edit";
message?: string;
edited_action?: { name: string; args: Record<string, unknown> };
}) => void;
}) { }) {
const { phase, setProcessing, setRejected } = useHitlPhase(interruptData); const { phase, setProcessing, setRejected } = useHitlPhase(interruptData);
const [isPanelOpen, setIsPanelOpen] = useState(false); const [isPanelOpen, setIsPanelOpen] = useState(false);
@ -399,19 +372,16 @@ export const UpdateNotionPageToolUI = ({
args, args,
result, result,
}: ToolCallMessagePartProps<{ page_title: string; content: string }, UpdateNotionPageResult>) => { }: ToolCallMessagePartProps<{ page_title: string; content: string }, UpdateNotionPageResult>) => {
const { dispatch } = useHitlDecision();
if (!result) return null; if (!result) return null;
if (isInterruptResult(result)) { if (isInterruptResult(result)) {
return ( return (
<ApprovalCard <ApprovalCard
args={args} args={args}
interruptData={result} interruptData={result as InterruptResult<NotionUpdatePageContext>}
onDecision={(decision) => { onDecision={(decision) => dispatch([decision])}
const event = new CustomEvent("hitl-decision", {
detail: { decisions: [decision] },
});
window.dispatchEvent(event);
}}
/> />
); );
} }

View file

@ -16,6 +16,8 @@ import {
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { useHitlPhase } from "@/hooks/use-hitl-phase"; import { useHitlPhase } from "@/hooks/use-hitl-phase";
import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
import type { InterruptResult, HitlDecision } from "@/lib/hitl";
interface OneDriveAccount { interface OneDriveAccount {
id: number; id: number;
@ -24,20 +26,10 @@ interface OneDriveAccount {
auth_expired?: boolean; auth_expired?: boolean;
} }
interface InterruptResult { type OneDriveCreateFileContext = {
__interrupt__: true;
__decided__?: "approve" | "reject" | "edit";
__completed__?: boolean;
action_requests: Array<{ name: string; args: Record<string, unknown> }>;
review_configs: Array<{
action_name: string;
allowed_decisions: Array<"approve" | "edit" | "reject">;
}>;
context?: {
accounts?: OneDriveAccount[]; accounts?: OneDriveAccount[];
parent_folders?: Record<number, Array<{ folder_id: string; name: string }>>; parent_folders?: Record<number, Array<{ folder_id: string; name: string }>>;
error?: string; error?: string;
};
} }
interface SuccessResult { interface SuccessResult {
@ -59,16 +51,7 @@ interface AuthErrorResult {
connector_type?: string; connector_type?: string;
} }
type CreateOneDriveFileResult = InterruptResult | SuccessResult | ErrorResult | AuthErrorResult; type CreateOneDriveFileResult = InterruptResult<OneDriveCreateFileContext> | 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 { function isErrorResult(result: unknown): result is ErrorResult {
return ( return (
@ -94,12 +77,8 @@ function ApprovalCard({
onDecision, onDecision,
}: { }: {
args: { name: string; content?: string }; args: { name: string; content?: string };
interruptData: InterruptResult; interruptData: InterruptResult<OneDriveCreateFileContext>;
onDecision: (decision: { onDecision: (decision: HitlDecision) => void;
type: "approve" | "reject" | "edit";
message?: string;
edited_action?: { name: string; args: Record<string, unknown> };
}) => void;
}) { }) {
const { phase, setProcessing, setRejected } = useHitlPhase(interruptData); const { phase, setProcessing, setRejected } = useHitlPhase(interruptData);
const [isPanelOpen, setIsPanelOpen] = useState(false); const [isPanelOpen, setIsPanelOpen] = useState(false);
@ -434,17 +413,14 @@ export const CreateOneDriveFileToolUI = ({
args, args,
result, result,
}: ToolCallMessagePartProps<{ name: string; content?: string }, CreateOneDriveFileResult>) => { }: ToolCallMessagePartProps<{ name: string; content?: string }, CreateOneDriveFileResult>) => {
const { dispatch } = useHitlDecision();
if (!result) return null; if (!result) return null;
if (isInterruptResult(result)) { if (isInterruptResult(result)) {
return ( return (
<ApprovalCard <ApprovalCard
args={args} args={args}
interruptData={result} interruptData={result as InterruptResult<OneDriveCreateFileContext>}
onDecision={(decision) => { onDecision={(decision) => dispatch([decision])}
window.dispatchEvent(
new CustomEvent("hitl-decision", { detail: { decisions: [decision] } })
);
}}
/> />
); );
} }

View file

@ -7,6 +7,8 @@ import { TextShimmerLoader } from "@/components/prompt-kit/loader";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox"; import { Checkbox } from "@/components/ui/checkbox";
import { useHitlPhase } from "@/hooks/use-hitl-phase"; import { useHitlPhase } from "@/hooks/use-hitl-phase";
import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
import type { InterruptResult, HitlDecision } from "@/lib/hitl";
interface OneDriveAccount { interface OneDriveAccount {
id: number; id: number;
@ -22,13 +24,10 @@ interface OneDriveFile {
web_url?: string; web_url?: string;
} }
interface InterruptResult { type OneDriveTrashFileContext = {
__interrupt__: true; account?: OneDriveAccount;
__decided__?: "approve" | "reject"; file?: OneDriveFile;
__completed__?: boolean; error?: string;
action_requests: Array<{ name: string; args: Record<string, unknown> }>;
review_configs: Array<{ action_name: string; allowed_decisions: Array<"approve" | "reject"> }>;
context?: { account?: OneDriveAccount; file?: OneDriveFile; error?: string };
} }
interface SuccessResult { interface SuccessResult {
@ -52,20 +51,11 @@ interface AuthErrorResult {
} }
type DeleteOneDriveFileResult = type DeleteOneDriveFileResult =
| InterruptResult | InterruptResult<OneDriveTrashFileContext>
| SuccessResult | SuccessResult
| ErrorResult | ErrorResult
| NotFoundResult | NotFoundResult
| AuthErrorResult; | 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 { function isErrorResult(result: unknown): result is ErrorResult {
return ( return (
typeof result === "object" && typeof result === "object" &&
@ -95,12 +85,8 @@ function ApprovalCard({
interruptData, interruptData,
onDecision, onDecision,
}: { }: {
interruptData: InterruptResult; interruptData: InterruptResult<OneDriveTrashFileContext>;
onDecision: (decision: { onDecision: (decision: HitlDecision) => void;
type: "approve" | "reject";
message?: string;
edited_action?: { name: string; args: Record<string, unknown> };
}) => void;
}) { }) {
const { phase, setProcessing, setRejected } = useHitlPhase(interruptData); const { phase, setProcessing, setRejected } = useHitlPhase(interruptData);
const [deleteFromKb, setDeleteFromKb] = useState(false); const [deleteFromKb, setDeleteFromKb] = useState(false);
@ -311,16 +297,13 @@ export const DeleteOneDriveFileToolUI = ({
{ file_name: string; delete_from_kb?: boolean }, { file_name: string; delete_from_kb?: boolean },
DeleteOneDriveFileResult DeleteOneDriveFileResult
>) => { >) => {
const { dispatch } = useHitlDecision();
if (!result) return null; if (!result) return null;
if (isInterruptResult(result)) { if (isInterruptResult(result)) {
return ( return (
<ApprovalCard <ApprovalCard
interruptData={result} interruptData={result as InterruptResult<OneDriveTrashFileContext>}
onDecision={(decision) => { onDecision={(decision) => dispatch([decision])}
window.dispatchEvent(
new CustomEvent("hitl-decision", { detail: { decisions: [decision] } })
);
}}
/> />
); );
} }

View file

@ -404,6 +404,45 @@ class ConnectorsApiService {
listDiscordChannelsResponse listDiscordChannelsResponse
); );
}; };
// =============================================================================
// MCP Tool Trust (Allow-List) Methods
// =============================================================================
/**
* Add a tool to the MCP connector's "Always Allow" list.
* Subsequent calls to this tool will skip HITL approval.
*/
trustMCPTool = async (connectorId: number, toolName: string): Promise<void> => {
const backendUrl = process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL || "http://localhost:8000";
const token =
typeof window !== "undefined" ? document.cookie.match(/fapiToken=([^;]+)/)?.[1] : undefined;
await fetch(`${backendUrl}/api/v1/connectors/mcp/${connectorId}/trust-tool`, {
method: "POST",
headers: {
"Content-Type": "application/json",
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify({ tool_name: toolName }),
});
};
/**
* Remove a tool from the MCP connector's "Always Allow" list.
*/
untrustMCPTool = async (connectorId: number, toolName: string): Promise<void> => {
const backendUrl = process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL || "http://localhost:8000";
const token =
typeof window !== "undefined" ? document.cookie.match(/fapiToken=([^;]+)/)?.[1] : undefined;
await fetch(`${backendUrl}/api/v1/connectors/mcp/${connectorId}/untrust-tool`, {
method: "POST",
headers: {
"Content-Type": "application/json",
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify({ tool_name: toolName }),
});
};
} }
export type { SlackChannel, DiscordChannel }; export type { SlackChannel, DiscordChannel };

View file

@ -148,9 +148,10 @@ export function addToolCall(
toolsWithUI: Set<string>, toolsWithUI: Set<string>,
toolCallId: string, toolCallId: string,
toolName: string, toolName: string,
args: Record<string, unknown> args: Record<string, unknown>,
force = false,
): void { ): void {
if (toolsWithUI.has(toolName)) { if (force || toolsWithUI.has(toolName)) {
state.contentParts.push({ state.contentParts.push({
type: "tool-call", type: "tool-call",
toolCallId, toolCallId,
@ -175,13 +176,20 @@ export function updateToolCall(
} }
} }
function _hasInterruptResult(part: ContentPart): boolean {
if (part.type !== "tool-call") return false;
const r = (part as { result?: unknown }).result;
return typeof r === "object" && r !== null && "__interrupt__" in r;
}
export function buildContentForUI( export function buildContentForUI(
state: ContentPartsState, state: ContentPartsState,
toolsWithUI: Set<string> toolsWithUI: Set<string>
): ThreadMessageLike["content"] { ): ThreadMessageLike["content"] {
const filtered = state.contentParts.filter((part) => { const filtered = state.contentParts.filter((part) => {
if (part.type === "text") return part.text.length > 0; if (part.type === "text") return part.text.length > 0;
if (part.type === "tool-call") return toolsWithUI.has(part.toolName); if (part.type === "tool-call")
return toolsWithUI.has(part.toolName) || _hasInterruptResult(part);
if (part.type === "data-thinking-steps") return true; if (part.type === "data-thinking-steps") return true;
return false; return false;
}); });
@ -199,7 +207,10 @@ export function buildContentForPersistence(
for (const part of state.contentParts) { for (const part of state.contentParts) {
if (part.type === "text" && part.text.length > 0) { if (part.type === "text" && part.text.length > 0) {
parts.push(part); parts.push(part);
} else if (part.type === "tool-call" && toolsWithUI.has(part.toolName)) { } else if (
part.type === "tool-call" &&
(toolsWithUI.has(part.toolName) || _hasInterruptResult(part))
) {
parts.push(part); parts.push(part);
} else if (part.type === "data-thinking-steps") { } else if (part.type === "data-thinking-steps") {
parts.push(part); parts.push(part);

View file

@ -0,0 +1,8 @@
export { isInterruptResult } from "./types";
export type {
HitlDecision,
InterruptActionRequest,
InterruptResult,
InterruptReviewConfig,
} from "./types";
export { useHitlDecision } from "./use-hitl-decision";

View file

@ -0,0 +1,45 @@
/**
* Shared types for Human-in-the-Loop (HITL) approval across all tools.
*
* Every tool-ui component that handles interrupts should import from here
* instead of defining its own `InterruptResult` / `isInterruptResult`.
*/
export interface InterruptActionRequest {
name: string;
args: Record<string, unknown>;
}
export interface InterruptReviewConfig {
action_name: string;
allowed_decisions: Array<"approve" | "edit" | "reject">;
}
export interface InterruptResult<C extends Record<string, unknown> = Record<string, unknown>> {
__interrupt__: true;
__decided__?: "approve" | "reject" | "edit";
__completed__?: boolean;
action_requests: InterruptActionRequest[];
review_configs: InterruptReviewConfig[];
interrupt_type?: string;
context?: C;
message?: string;
}
export function isInterruptResult(result: unknown): result is InterruptResult {
return (
typeof result === "object" &&
result !== null &&
"__interrupt__" in result &&
(result as InterruptResult).__interrupt__ === true
);
}
export interface HitlDecision {
type: "approve" | "reject" | "edit";
message?: string;
edited_action?: {
name: string;
args: Record<string, unknown>;
};
}

View file

@ -0,0 +1,19 @@
/**
* Shared hook for dispatching HITL decisions.
*
* All tool-ui components that handle approve/reject/edit should use this
* instead of manually constructing `CustomEvent("hitl-decision", ...)`.
*/
import { useCallback } from "react";
import type { HitlDecision } from "./types";
export function useHitlDecision() {
const dispatch = useCallback((decisions: HitlDecision[]) => {
window.dispatchEvent(
new CustomEvent("hitl-decision", { detail: { decisions } }),
);
}, []);
return { dispatch };
}

View file

@ -737,8 +737,8 @@
"back_to_app": "Back to app", "back_to_app": "Back to app",
"nav_general": "General", "nav_general": "General",
"nav_general_desc": "Name, description & basic info", "nav_general_desc": "Name, description & basic info",
"nav_agent_configs": "Agent Configs", "nav_agent_models": "Agent Models",
"nav_agent_configs_desc": "Models with prompts & citations", "nav_agent_models_desc": "Models with prompts & citations",
"nav_role_assignments": "Role Assignments", "nav_role_assignments": "Role Assignments",
"nav_role_assignments_desc": "Assign configs to agent roles", "nav_role_assignments_desc": "Assign configs to agent roles",
"nav_image_models": "Image Models", "nav_image_models": "Image Models",

View file

@ -737,8 +737,8 @@
"back_to_app": "Volver a la app", "back_to_app": "Volver a la app",
"nav_general": "General", "nav_general": "General",
"nav_general_desc": "Nombre, descripción e información básica", "nav_general_desc": "Nombre, descripción e información básica",
"nav_agent_configs": "Configuraciones de agente", "nav_agent_models": "Modelos de agente",
"nav_agent_configs_desc": "Modelos LLM con prompts y citas", "nav_agent_models_desc": "Modelos LLM con prompts y citas",
"nav_role_assignments": "Asignaciones de roles", "nav_role_assignments": "Asignaciones de roles",
"nav_role_assignments_desc": "Asignar configuraciones a roles de agente", "nav_role_assignments_desc": "Asignar configuraciones a roles de agente",
"nav_image_models": "Modelos de imagen", "nav_image_models": "Modelos de imagen",

View file

@ -737,8 +737,8 @@
"back_to_app": "ऐप पर वापस जाएं", "back_to_app": "ऐप पर वापस जाएं",
"nav_general": "सामान्य", "nav_general": "सामान्य",
"nav_general_desc": "नाम, विवरण और बुनियादी जानकारी", "nav_general_desc": "नाम, विवरण और बुनियादी जानकारी",
"nav_agent_configs": "एजेंट कॉन्फ़िगरेशन", "nav_agent_models": "एजेंट मॉडल",
"nav_agent_configs_desc": "प्रॉम्प्ट और उद्धरण के साथ LLM मॉडल", "nav_agent_models_desc": "प्रॉम्प्ट और उद्धरण के साथ LLM मॉडल",
"nav_role_assignments": "भूमिका असाइनमेंट", "nav_role_assignments": "भूमिका असाइनमेंट",
"nav_role_assignments_desc": "एजेंट भूमिकाओं को कॉन्फ़िगरेशन असाइन करें", "nav_role_assignments_desc": "एजेंट भूमिकाओं को कॉन्फ़िगरेशन असाइन करें",
"nav_image_models": "इमेज मॉडल", "nav_image_models": "इमेज मॉडल",

View file

@ -737,8 +737,8 @@
"back_to_app": "Voltar ao app", "back_to_app": "Voltar ao app",
"nav_general": "Geral", "nav_general": "Geral",
"nav_general_desc": "Nome, descrição e informações básicas", "nav_general_desc": "Nome, descrição e informações básicas",
"nav_agent_configs": "Configurações do agente", "nav_agent_models": "Modelos do agente",
"nav_agent_configs_desc": "Modelos LLM com prompts e citações", "nav_agent_models_desc": "Modelos LLM com prompts e citações",
"nav_role_assignments": "Atribuições de funções", "nav_role_assignments": "Atribuições de funções",
"nav_role_assignments_desc": "Atribuir configurações a funções do agente", "nav_role_assignments_desc": "Atribuir configurações a funções do agente",
"nav_image_models": "Modelos de imagem", "nav_image_models": "Modelos de imagem",

View file

@ -721,8 +721,8 @@
"back_to_app": "返回应用", "back_to_app": "返回应用",
"nav_general": "常规", "nav_general": "常规",
"nav_general_desc": "名称、描述和基本信息", "nav_general_desc": "名称、描述和基本信息",
"nav_agent_configs": "代理配置", "nav_agent_models": "代理模型",
"nav_agent_configs_desc": "LLM 模型配置提示词和引用", "nav_agent_models_desc": "LLM 模型配置提示词和引用",
"nav_role_assignments": "角色分配", "nav_role_assignments": "角色分配",
"nav_role_assignments_desc": "为代理角色分配配置", "nav_role_assignments_desc": "为代理角色分配配置",
"nav_image_models": "图像模型", "nav_image_models": "图像模型",