mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-03 04:42:39 +02:00
Merge pull request #1219 from AnishSarkar22/fix/sensitive-actions
refactor: Unified sensitive actions using HITL & many UI/UX changes
This commit is contained in:
commit
e1e4bb4706
86 changed files with 2387 additions and 3144 deletions
|
|
@ -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"),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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."}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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."}
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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."}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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."}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
140
surfsense_backend/app/agents/new_chat/tools/hitl.py
Normal file
140
surfsense_backend/app/agents/new_chat/tools/hitl.py
Normal 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)
|
||||||
|
|
@ -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."}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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}, "
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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}"
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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."}
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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} />
|
||||||
|
|
|
||||||
|
|
@ -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} />;
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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]);
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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} />,
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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] } })
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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] } })
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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] } })
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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] } })
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
264
surfsense_web/components/tool-ui/generic-hitl-approval.tsx
Normal file
264
surfsense_web/components/tool-ui/generic-hitl-approval.tsx
Normal 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])}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -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] } })
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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] } })
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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] },
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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] } })
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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] } })
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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] } })
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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] } })
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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] } })
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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] } })
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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] } })
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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] } })
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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] } })
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
8
surfsense_web/lib/hitl/index.ts
Normal file
8
surfsense_web/lib/hitl/index.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
export { isInterruptResult } from "./types";
|
||||||
|
export type {
|
||||||
|
HitlDecision,
|
||||||
|
InterruptActionRequest,
|
||||||
|
InterruptResult,
|
||||||
|
InterruptReviewConfig,
|
||||||
|
} from "./types";
|
||||||
|
export { useHitlDecision } from "./use-hitl-decision";
|
||||||
45
surfsense_web/lib/hitl/types.ts
Normal file
45
surfsense_web/lib/hitl/types.ts
Normal 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>;
|
||||||
|
};
|
||||||
|
}
|
||||||
19
surfsense_web/lib/hitl/use-hitl-decision.ts
Normal file
19
surfsense_web/lib/hitl/use-hitl-decision.ts
Normal 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 };
|
||||||
|
}
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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": "इमेज मॉडल",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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": "图像模型",
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue