Merge pull request #825 from CREDO23/sur-169-feat-implement-human-in-the-loop-for-linear-sensitive

[Feat] Add human in the loop for linear sensitive actions
This commit is contained in:
Rohan Verma 2026-02-19 19:09:50 -08:00 committed by GitHub
commit 9aef655566
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
30 changed files with 3789 additions and 126 deletions

View file

@ -256,6 +256,18 @@ async def create_surfsense_deep_agent(
] ]
modified_disabled_tools.extend(notion_tools) modified_disabled_tools.extend(notion_tools)
# Disable Linear action tools if no Linear connector is configured
has_linear_connector = (
available_connectors is not None and "LINEAR_CONNECTOR" in available_connectors
)
if not has_linear_connector:
linear_tools = [
"create_linear_issue",
"update_linear_issue",
"delete_linear_issue",
]
modified_disabled_tools.extend(linear_tools)
# Build tools using the async registry (includes MCP tools) # Build tools using the async registry (includes MCP tools)
tools = await build_tools_async( tools = await build_tools_async(
dependencies=dependencies, dependencies=dependencies,

View file

@ -0,0 +1,11 @@
"""Linear tools for creating, updating, and deleting issues."""
from .create_issue import create_create_linear_issue_tool
from .delete_issue import create_delete_linear_issue_tool
from .update_issue import create_update_linear_issue_tool
__all__ = [
"create_create_linear_issue_tool",
"create_delete_linear_issue_tool",
"create_update_linear_issue_tool",
]

View file

@ -0,0 +1,239 @@
import logging
from typing import Any
from langchain_core.tools import tool
from langgraph.types import interrupt
from sqlalchemy.ext.asyncio import AsyncSession
from app.connectors.linear_connector import LinearAPIError, LinearConnector
from app.services.linear import LinearToolMetadataService
logger = logging.getLogger(__name__)
def create_create_linear_issue_tool(
db_session: AsyncSession | None = None,
search_space_id: int | None = None,
user_id: str | None = None,
connector_id: int | None = None,
):
"""
Factory function to create the create_linear_issue tool.
Args:
db_session: Database session for accessing the Linear connector
search_space_id: Search space ID to find the Linear connector
user_id: User ID for fetching user-specific context
connector_id: Optional specific connector ID (if known)
Returns:
Configured create_linear_issue tool
"""
@tool
async def create_linear_issue(
title: str,
description: str | None = None,
) -> dict[str, Any]:
"""Create a new issue in Linear.
Use this tool when the user explicitly asks to create, add, or file
a new issue / ticket / task in Linear.
Args:
title: Short, descriptive issue title.
description: Optional markdown body for the issue.
Returns:
Dictionary with:
- status: "success", "rejected", or "error"
- issue_id: Linear issue UUID (if success)
- identifier: Human-readable ID like "ENG-42" (if success)
- url: URL to the created issue (if success)
- message: Result message
IMPORTANT: If status is "rejected", the user explicitly declined the action.
Respond with a brief acknowledgment (e.g., "Understood, I won't create the issue.")
and move on. Do NOT retry, troubleshoot, or suggest alternatives.
Examples:
- "Create a Linear issue titled 'Fix login bug'"
- "Add a ticket for the payment timeout problem"
- "File an issue about the broken search feature"
"""
logger.info(f"create_linear_issue called: title='{title}'")
if db_session is None or search_space_id is None or user_id is None:
logger.error(
"Linear tool not properly configured - missing required parameters"
)
return {
"status": "error",
"message": "Linear tool not properly configured. Please contact support.",
}
try:
metadata_service = LinearToolMetadataService(db_session)
context = await metadata_service.get_creation_context(
search_space_id, user_id
)
if "error" in context:
logger.error(f"Failed to fetch creation context: {context['error']}")
return {"status": "error", "message": context["error"]}
logger.info(f"Requesting approval for creating Linear issue: '{title}'")
approval = interrupt(
{
"type": "linear_issue_creation",
"action": {
"tool": "create_linear_issue",
"params": {
"title": title,
"description": description,
"team_id": None,
"state_id": None,
"assignee_id": None,
"priority": None,
"label_ids": [],
"connector_id": connector_id,
},
},
"context": context,
}
)
decisions_raw = (
approval.get("decisions", []) if isinstance(approval, dict) else []
)
decisions = (
decisions_raw if isinstance(decisions_raw, list) else [decisions_raw]
)
decisions = [d for d in decisions if isinstance(d, dict)]
if not decisions:
logger.warning("No approval decision received")
return {"status": "error", "message": "No approval decision received"}
decision = decisions[0]
decision_type = decision.get("type") or decision.get("decision_type")
logger.info(f"User decision: {decision_type}")
if decision_type == "reject":
logger.info("Linear issue creation rejected by user")
return {
"status": "rejected",
"message": "User declined. The issue was not created. Do not ask again or suggest alternatives.",
}
final_params: dict[str, Any] = {}
edited_action = decision.get("edited_action")
if isinstance(edited_action, dict):
edited_args = edited_action.get("args")
if isinstance(edited_args, dict):
final_params = edited_args
elif isinstance(decision.get("args"), dict):
final_params = decision["args"]
final_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():
logger.error("Title is empty or contains only whitespace")
return {"status": "error", "message": "Issue title cannot be empty."}
if not final_team_id:
return {
"status": "error",
"message": "A team must be selected to create an issue.",
}
from sqlalchemy.future import select
from app.db import SearchSourceConnector, SearchSourceConnectorType
actual_connector_id = final_connector_id
if actual_connector_id is None:
result = await db_session.execute(
select(SearchSourceConnector).filter(
SearchSourceConnector.search_space_id == search_space_id,
SearchSourceConnector.user_id == user_id,
SearchSourceConnector.connector_type
== SearchSourceConnectorType.LINEAR_CONNECTOR,
)
)
connector = result.scalars().first()
if not connector:
return {
"status": "error",
"message": "No Linear connector found. Please connect Linear in your workspace settings.",
}
actual_connector_id = connector.id
logger.info(f"Found Linear connector: id={actual_connector_id}")
else:
result = await db_session.execute(
select(SearchSourceConnector).filter(
SearchSourceConnector.id == actual_connector_id,
SearchSourceConnector.search_space_id == search_space_id,
SearchSourceConnector.user_id == user_id,
SearchSourceConnector.connector_type
== SearchSourceConnectorType.LINEAR_CONNECTOR,
)
)
connector = result.scalars().first()
if not connector:
return {
"status": "error",
"message": "Selected Linear connector is invalid or has been disconnected.",
}
logger.info(f"Validated Linear connector: id={actual_connector_id}")
logger.info(
f"Creating Linear issue with final params: title='{final_title}'"
)
linear_client = LinearConnector(
session=db_session, connector_id=actual_connector_id
)
result = await linear_client.create_issue(
team_id=final_team_id,
title=final_title,
description=final_description,
state_id=final_state_id,
assignee_id=final_assignee_id,
priority=final_priority,
label_ids=final_label_ids if final_label_ids else None,
)
if result.get("status") == "error":
logger.error(f"Failed to create Linear issue: {result.get('message')}")
return {"status": "error", "message": result.get("message")}
logger.info(
f"Linear issue created: {result.get('identifier')} - {result.get('title')}"
)
return {
"status": "success",
"issue_id": result.get("id"),
"identifier": result.get("identifier"),
"url": result.get("url"),
"message": result.get("message"),
}
except Exception as e:
from langgraph.errors import GraphInterrupt
if isinstance(e, GraphInterrupt):
raise
logger.error(f"Error creating Linear issue: {e}", exc_info=True)
if isinstance(e, (ValueError, LinearAPIError)):
message = str(e)
else:
message = "Something went wrong while creating the issue. Please try again."
return {"status": "error", "message": message}
return create_linear_issue

View file

@ -0,0 +1,262 @@
import logging
from typing import Any
from langchain_core.tools import tool
from langgraph.types import interrupt
from sqlalchemy.ext.asyncio import AsyncSession
from app.connectors.linear_connector import LinearAPIError, LinearConnector
from app.services.linear import LinearToolMetadataService
logger = logging.getLogger(__name__)
def create_delete_linear_issue_tool(
db_session: AsyncSession | None = None,
search_space_id: int | None = None,
user_id: str | None = None,
connector_id: int | None = None,
):
"""
Factory function to create the delete_linear_issue tool.
Args:
db_session: Database session for accessing the Linear connector
search_space_id: Search space ID to find the Linear connector
user_id: User ID for finding the correct Linear connector
connector_id: Optional specific connector ID (if known)
Returns:
Configured delete_linear_issue tool
"""
@tool
async def delete_linear_issue(
issue_ref: str,
delete_from_kb: bool = False,
) -> dict[str, Any]:
"""Archive (delete) a Linear issue.
Use this tool when the user asks to delete, remove, or archive a Linear issue.
Note that Linear archives issues rather than permanently deleting them
(they can be restored from the archive).
Args:
issue_ref: The issue to delete. Can be the issue title (e.g. "Fix login bug"),
the identifier (e.g. "ENG-42"), or the full document title
(e.g. "ENG-42: Fix login bug").
delete_from_kb: Whether to also remove the issue from the knowledge base.
Default is False. Set to True to remove from both Linear
and the knowledge base.
Returns:
Dictionary with:
- status: "success", "rejected", "not_found", or "error"
- identifier: Human-readable ID like "ENG-42" (if success)
- message: Success or error message
- deleted_from_kb: Whether the issue was also removed from the knowledge base (if success)
IMPORTANT:
- If status is "rejected", the user explicitly declined the action.
Respond with a brief acknowledgment (e.g., "Understood, I won't delete the issue.")
and move on. Do NOT ask for alternatives or troubleshoot.
- If status is "not_found", inform the user conversationally using the exact message
provided. Do NOT treat this as an error. Simply relay the message and ask the user
to verify the issue title or identifier, or check if it has been indexed.
Examples:
- "Delete the 'Fix login bug' Linear issue"
- "Archive ENG-42"
- "Remove the 'Old payment flow' issue from Linear"
"""
logger.info(
f"delete_linear_issue called: issue_ref='{issue_ref}', delete_from_kb={delete_from_kb}"
)
if db_session is None or search_space_id is None or user_id is None:
logger.error(
"Linear tool not properly configured - missing required parameters"
)
return {
"status": "error",
"message": "Linear tool not properly configured. Please contact support.",
}
try:
metadata_service = LinearToolMetadataService(db_session)
context = await metadata_service.get_delete_context(
search_space_id, user_id, issue_ref
)
if "error" in context:
error_msg = context["error"]
if "not found" in error_msg.lower():
logger.warning(f"Issue not found: {error_msg}")
return {"status": "not_found", "message": error_msg}
else:
logger.error(f"Failed to fetch delete context: {error_msg}")
return {"status": "error", "message": error_msg}
issue_id = context["issue"]["id"]
issue_identifier = context["issue"].get("identifier", "")
document_id = context["issue"]["document_id"]
connector_id_from_context = context.get("workspace", {}).get("id")
logger.info(
f"Requesting approval for deleting Linear issue: '{issue_ref}' "
f"(id={issue_id}, delete_from_kb={delete_from_kb})"
)
approval = interrupt(
{
"type": "linear_issue_deletion",
"action": {
"tool": "delete_linear_issue",
"params": {
"issue_id": issue_id,
"connector_id": connector_id_from_context,
"delete_from_kb": delete_from_kb,
},
},
"context": context,
}
)
decisions_raw = (
approval.get("decisions", []) if isinstance(approval, dict) else []
)
decisions = (
decisions_raw if isinstance(decisions_raw, list) else [decisions_raw]
)
decisions = [d for d in decisions if isinstance(d, dict)]
if not decisions:
logger.warning("No approval decision received")
return {"status": "error", "message": "No approval decision received"}
decision = decisions[0]
decision_type = decision.get("type") or decision.get("decision_type")
logger.info(f"User decision: {decision_type}")
if decision_type == "reject":
logger.info("Linear issue deletion rejected by user")
return {
"status": "rejected",
"message": "User declined. The issue was not deleted. Do not ask again or suggest alternatives.",
}
edited_action = decision.get("edited_action")
final_params: dict[str, Any] = {}
if isinstance(edited_action, dict):
edited_args = edited_action.get("args")
if isinstance(edited_args, dict):
final_params = edited_args
elif isinstance(decision.get("args"), dict):
final_params = decision["args"]
final_issue_id = final_params.get("issue_id", issue_id)
final_connector_id = final_params.get(
"connector_id", connector_id_from_context
)
final_delete_from_kb = final_params.get("delete_from_kb", delete_from_kb)
logger.info(
f"Deleting Linear issue with final params: issue_id={final_issue_id}, "
f"connector_id={final_connector_id}, delete_from_kb={final_delete_from_kb}"
)
from sqlalchemy.future import select
from app.db import SearchSourceConnector, SearchSourceConnectorType
if final_connector_id:
result = await db_session.execute(
select(SearchSourceConnector).filter(
SearchSourceConnector.id == final_connector_id,
SearchSourceConnector.search_space_id == search_space_id,
SearchSourceConnector.user_id == user_id,
SearchSourceConnector.connector_type
== SearchSourceConnectorType.LINEAR_CONNECTOR,
)
)
connector = result.scalars().first()
if not connector:
logger.error(
f"Invalid connector_id={final_connector_id} for search_space_id={search_space_id}"
)
return {
"status": "error",
"message": "Selected Linear connector is invalid or has been disconnected.",
}
actual_connector_id = connector.id
logger.info(f"Validated Linear connector: id={actual_connector_id}")
else:
logger.error("No connector found for this issue")
return {
"status": "error",
"message": "No connector found for this issue.",
}
linear_client = LinearConnector(
session=db_session, connector_id=actual_connector_id
)
result = await linear_client.archive_issue(issue_id=final_issue_id)
logger.info(
f"archive_issue result: {result.get('status')} - {result.get('message', '')}"
)
deleted_from_kb = False
if (
result.get("status") == "success"
and final_delete_from_kb
and document_id
):
try:
from app.db import Document
doc_result = await db_session.execute(
select(Document).filter(Document.id == document_id)
)
document = doc_result.scalars().first()
if document:
await db_session.delete(document)
await db_session.commit()
deleted_from_kb = True
logger.info(
f"Deleted document {document_id} from knowledge base"
)
else:
logger.warning(f"Document {document_id} not found in KB")
except Exception as e:
logger.error(f"Failed to delete document from KB: {e}")
await db_session.rollback()
result["warning"] = (
f"Issue archived in Linear, but failed to remove from knowledge base: {e!s}"
)
if result.get("status") == "success":
result["deleted_from_kb"] = deleted_from_kb
if issue_identifier:
result["message"] = f"Issue {issue_identifier} archived successfully."
if deleted_from_kb:
result["message"] = (
f"{result.get('message', '')} Also removed from the knowledge base."
)
return result
except Exception as e:
from langgraph.errors import GraphInterrupt
if isinstance(e, GraphInterrupt):
raise
logger.error(f"Error deleting Linear issue: {e}", exc_info=True)
if isinstance(e, (ValueError, LinearAPIError)):
message = str(e)
else:
message = "Something went wrong while deleting the issue. Please try again."
return {"status": "error", "message": message}
return delete_linear_issue

View file

@ -0,0 +1,332 @@
import logging
from typing import Any
from langchain_core.tools import tool
from langgraph.types import interrupt
from sqlalchemy.ext.asyncio import AsyncSession
from app.connectors.linear_connector import LinearAPIError, LinearConnector
from app.services.linear import LinearKBSyncService, LinearToolMetadataService
logger = logging.getLogger(__name__)
def create_update_linear_issue_tool(
db_session: AsyncSession | None = None,
search_space_id: int | None = None,
user_id: str | None = None,
connector_id: int | None = None,
):
"""
Factory function to create the update_linear_issue tool.
Args:
db_session: Database session for accessing the Linear connector
search_space_id: Search space ID to find the Linear connector
user_id: User ID for fetching user-specific context
connector_id: Optional specific connector ID (if known)
Returns:
Configured update_linear_issue tool
"""
@tool
async def update_linear_issue(
issue_ref: str,
new_title: str | None = None,
new_description: str | None = None,
new_state_name: str | None = None,
new_assignee_email: str | None = None,
new_priority: int | None = None,
new_label_names: list[str] | None = None,
) -> dict[str, Any]:
"""Update an existing Linear issue that has been indexed in the knowledge base.
Use this tool when the user asks to modify, change, or update a Linear issue
for example, changing its status, reassigning it, updating its title or description,
adjusting its priority, or changing its labels.
Only issues already indexed in the knowledge base can be updated.
Args:
issue_ref: The issue to update. Can be the issue title (e.g. "Fix login bug"),
the identifier (e.g. "ENG-42"), or the full document title
(e.g. "ENG-42: Fix login bug"). Matched case-insensitively.
new_title: New title for the issue (optional).
new_description: New markdown body for the issue (optional).
new_state_name: New workflow state name (e.g. "In Progress", "Done").
Matched case-insensitively against the team's states.
new_assignee_email: Email address of the new assignee.
Matched case-insensitively against the team's members.
new_priority: New priority (0 = No Priority, 1 = Urgent, 2 = High,
3 = Medium, 4 = Low).
new_label_names: New set of label names to apply.
Matched case-insensitively against the team's labels.
Unrecognised names are silently skipped.
Returns:
Dictionary with:
- status: "success", "rejected", "not_found", or "error"
- identifier: Human-readable ID like "ENG-42" (if success)
- url: URL to the updated issue (if success)
- message: Result message
IMPORTANT:
- If status is "rejected", the user explicitly declined the action.
Respond with a brief acknowledgment (e.g., "Understood, I didn't update the issue.")
and move on. Do NOT ask for alternatives or troubleshoot.
- If status is "not_found", inform the user conversationally using the exact message
provided. Do NOT treat this as an error. Simply relay the message and ask the user
to verify the issue title or identifier, or check if it has been indexed.
Examples:
- "Mark the 'Fix login bug' issue as done"
- "Assign ENG-42 to john@company.com"
- "Change the priority of 'Payment timeout' to urgent"
"""
logger.info(f"update_linear_issue called: issue_ref='{issue_ref}'")
if db_session is None or search_space_id is None or user_id is None:
logger.error(
"Linear tool not properly configured - missing required parameters"
)
return {
"status": "error",
"message": "Linear tool not properly configured. Please contact support.",
}
try:
metadata_service = LinearToolMetadataService(db_session)
context = await metadata_service.get_update_context(
search_space_id, user_id, issue_ref
)
if "error" in context:
error_msg = context["error"]
if "not found" in error_msg.lower():
logger.warning(f"Issue not found: {error_msg}")
return {"status": "not_found", "message": error_msg}
else:
logger.error(f"Failed to fetch update context: {error_msg}")
return {"status": "error", "message": error_msg}
issue_id = context["issue"]["id"]
document_id = context["issue"]["document_id"]
connector_id_from_context = context.get("workspace", {}).get("id")
team = context.get("team", {})
new_state_id = _resolve_state(team, new_state_name)
new_assignee_id = _resolve_assignee(team, new_assignee_email)
new_label_ids = _resolve_labels(team, new_label_names)
logger.info(
f"Requesting approval for updating Linear issue: '{issue_ref}' (id={issue_id})"
)
approval = interrupt(
{
"type": "linear_issue_update",
"action": {
"tool": "update_linear_issue",
"params": {
"issue_id": issue_id,
"document_id": document_id,
"new_title": new_title,
"new_description": new_description,
"new_state_id": new_state_id,
"new_assignee_id": new_assignee_id,
"new_priority": new_priority,
"new_label_ids": new_label_ids,
"connector_id": connector_id_from_context,
},
},
"context": context,
}
)
decisions_raw = (
approval.get("decisions", []) if isinstance(approval, dict) else []
)
decisions = (
decisions_raw if isinstance(decisions_raw, list) else [decisions_raw]
)
decisions = [d for d in decisions if isinstance(d, dict)]
if not decisions:
logger.warning("No approval decision received")
return {"status": "error", "message": "No approval decision received"}
decision = decisions[0]
decision_type = decision.get("type") or decision.get("decision_type")
logger.info(f"User decision: {decision_type}")
if decision_type == "reject":
logger.info("Linear issue update rejected by user")
return {
"status": "rejected",
"message": "User declined. The issue was not updated. Do not ask again or suggest alternatives.",
}
edited_action = decision.get("edited_action")
final_params: dict[str, Any] = {}
if isinstance(edited_action, dict):
edited_args = edited_action.get("args")
if isinstance(edited_args, dict):
final_params = edited_args
elif isinstance(decision.get("args"), dict):
final_params = decision["args"]
final_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
)
final_connector_id = final_params.get(
"connector_id", connector_id_from_context
)
if not final_connector_id:
logger.error("No connector found for this issue")
return {
"status": "error",
"message": "No connector found for this issue.",
}
from sqlalchemy.future import select
from app.db import SearchSourceConnector, SearchSourceConnectorType
result = await db_session.execute(
select(SearchSourceConnector).filter(
SearchSourceConnector.id == final_connector_id,
SearchSourceConnector.search_space_id == search_space_id,
SearchSourceConnector.user_id == user_id,
SearchSourceConnector.connector_type
== SearchSourceConnectorType.LINEAR_CONNECTOR,
)
)
connector = result.scalars().first()
if not connector:
logger.error(
f"Invalid connector_id={final_connector_id} for search_space_id={search_space_id}"
)
return {
"status": "error",
"message": "Selected Linear connector is invalid or has been disconnected.",
}
logger.info(f"Validated Linear connector: id={final_connector_id}")
logger.info(
f"Updating Linear issue with final params: issue_id={final_issue_id}"
)
linear_client = LinearConnector(
session=db_session, connector_id=final_connector_id
)
updated_issue = await linear_client.update_issue(
issue_id=final_issue_id,
title=final_new_title,
description=final_new_description,
state_id=final_new_state_id,
assignee_id=final_new_assignee_id,
priority=final_new_priority,
label_ids=final_new_label_ids,
)
if updated_issue.get("status") == "error":
logger.error(
f"Failed to update Linear issue: {updated_issue.get('message')}"
)
return {
"status": "error",
"message": updated_issue.get("message"),
}
logger.info(
f"update_issue result: {updated_issue.get('identifier')} - {updated_issue.get('title')}"
)
if final_document_id is not None:
logger.info(
f"Updating knowledge base for document {final_document_id}..."
)
kb_service = LinearKBSyncService(db_session)
kb_result = await kb_service.sync_after_update(
document_id=final_document_id,
issue_id=final_issue_id,
user_id=user_id,
search_space_id=search_space_id,
)
if kb_result["status"] == "success":
logger.info(
f"Knowledge base successfully updated for issue {final_issue_id}"
)
kb_message = " Your knowledge base has also been updated."
elif kb_result["status"] == "not_indexed":
kb_message = " This issue will be added to your knowledge base in the next scheduled sync."
else:
logger.warning(
f"KB update failed for issue {final_issue_id}: {kb_result.get('message')}"
)
kb_message = " Your knowledge base will be updated in the next scheduled sync."
else:
kb_message = ""
identifier = updated_issue.get("identifier")
default_msg = f"Issue {identifier} updated successfully."
return {
"status": "success",
"identifier": identifier,
"url": updated_issue.get("url"),
"message": f"{updated_issue.get('message', default_msg)}{kb_message}",
}
except Exception as e:
from langgraph.errors import GraphInterrupt
if isinstance(e, GraphInterrupt):
raise
logger.error(f"Error updating Linear issue: {e}", exc_info=True)
if isinstance(e, (ValueError, LinearAPIError)):
message = str(e)
else:
message = "Something went wrong while updating the issue. Please try again."
return {"status": "error", "message": message}
return update_linear_issue
def _resolve_state(team: dict, state_name: str | None) -> str | None:
if not state_name:
return None
name_lower = state_name.lower()
for state in team.get("states", []):
if state.get("name", "").lower() == name_lower:
return state["id"]
return None
def _resolve_assignee(team: dict, assignee_email: str | None) -> str | None:
if not assignee_email:
return None
email_lower = assignee_email.lower()
for member in team.get("members", []):
if member.get("email", "").lower() == email_lower:
return member["id"]
return None
def _resolve_labels(team: dict, label_names: list[str] | None) -> list[str] | None:
if label_names is None:
return None
if not label_names:
return []
name_set = {n.lower() for n in label_names}
return [
label["id"]
for label in team.get("labels", [])
if label.get("name", "").lower() in name_set
]

View file

@ -5,7 +5,7 @@ from langchain_core.tools import tool
from langgraph.types import interrupt from langgraph.types import interrupt
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.connectors.notion_history import NotionHistoryConnector from app.connectors.notion_history import NotionAPIError, NotionHistoryConnector
from app.services.notion import NotionToolMetadataService from app.services.notion import NotionToolMetadataService
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -34,7 +34,6 @@ def create_create_notion_page_tool(
async def create_notion_page( async def create_notion_page(
title: str, title: str,
content: str, content: str,
parent_page_id: str | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Create a new page in Notion with the given title and content. """Create a new page in Notion with the given title and content.
@ -45,8 +44,6 @@ def create_create_notion_page_tool(
Args: Args:
title: The title of the Notion page. title: The title of the Notion page.
content: The markdown content for the page body (supports headings, lists, paragraphs). content: The markdown content for the page body (supports headings, lists, paragraphs).
parent_page_id: Optional parent page ID to create as a subpage.
If not provided, will ask for one.
Returns: Returns:
Dictionary with: Dictionary with:
@ -58,15 +55,13 @@ def create_create_notion_page_tool(
IMPORTANT: If status is "rejected", the user explicitly declined the action. IMPORTANT: If status is "rejected", the user explicitly declined the action.
Respond with a brief acknowledgment (e.g., "Understood, I didn't create the page.") Respond with a brief acknowledgment (e.g., "Understood, I didn't create the page.")
and move on. Do NOT ask for parent page IDs, troubleshoot, or suggest alternatives. and move on. Do NOT troubleshoot or suggest alternatives.
Examples: Examples:
- "Create a Notion page titled 'Meeting Notes' with content 'Discussed project timeline'" - "Create a Notion page titled 'Meeting Notes' with content 'Discussed project timeline'"
- "Save this to Notion with title 'Research Summary'" - "Save this to Notion with title 'Research Summary'"
""" """
logger.info( logger.info(f"create_notion_page called: title='{title}'")
f"create_notion_page called: title='{title}', parent_page_id={parent_page_id}"
)
if db_session is None or search_space_id is None or user_id is None: if db_session is None or search_space_id is None or user_id is None:
logger.error( logger.error(
@ -99,7 +94,7 @@ def create_create_notion_page_tool(
"params": { "params": {
"title": title, "title": title,
"content": content, "content": content,
"parent_page_id": parent_page_id, "parent_page_id": None,
"connector_id": connector_id, "connector_id": connector_id,
}, },
}, },
@ -144,7 +139,7 @@ def create_create_notion_page_tool(
final_title = final_params.get("title", title) final_title = final_params.get("title", title)
final_content = final_params.get("content", content) final_content = final_params.get("content", content)
final_parent_page_id = final_params.get("parent_page_id", parent_page_id) final_parent_page_id = final_params.get("parent_page_id")
final_connector_id = final_params.get("connector_id", connector_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():
@ -229,11 +224,10 @@ def create_create_notion_page_tool(
raise raise
logger.error(f"Error creating Notion page: {e}", exc_info=True) logger.error(f"Error creating Notion page: {e}", exc_info=True)
return { if isinstance(e, (ValueError, NotionAPIError)):
"status": "error", message = str(e)
"message": str(e) else:
if isinstance(e, ValueError) message = "Something went wrong while creating the page. Please try again."
else f"Unexpected error: {e!s}", return {"status": "error", "message": message}
}
return create_notion_page return create_notion_page

View file

@ -5,7 +5,7 @@ from langchain_core.tools import tool
from langgraph.types import interrupt from langgraph.types import interrupt
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.connectors.notion_history import NotionHistoryConnector from app.connectors.notion_history import NotionAPIError, NotionHistoryConnector
from app.services.notion.tool_metadata_service import NotionToolMetadataService from app.services.notion.tool_metadata_service import NotionToolMetadataService
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -33,7 +33,7 @@ def create_delete_notion_page_tool(
@tool @tool
async def delete_notion_page( async def delete_notion_page(
page_title: str, page_title: str,
delete_from_db: bool = False, delete_from_kb: bool = False,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Delete (archive) a Notion page. """Delete (archive) a Notion page.
@ -43,8 +43,8 @@ def create_delete_notion_page_tool(
Args: Args:
page_title: The title of the Notion page to delete. page_title: The title of the Notion page to delete.
delete_from_db: Whether to also remove the page from the knowledge base. delete_from_kb: Whether to also remove the page from the knowledge base.
Default is False (in Notion). Default is False.
Set to True to permanently remove from both Notion and knowledge base. Set to True to permanently remove from both Notion and knowledge base.
Returns: Returns:
@ -52,7 +52,7 @@ def create_delete_notion_page_tool(
- status: "success", "rejected", "not_found", or "error" - status: "success", "rejected", "not_found", or "error"
- page_id: Deleted page ID (if success) - page_id: Deleted page ID (if success)
- message: Success or error message - message: Success or error message
- deleted_from_db: Whether the page was also removed from knowledge base (if success) - deleted_from_kb: Whether the page was also removed from knowledge base (if success)
Examples: Examples:
- "Delete the 'Meeting Notes' Notion page" - "Delete the 'Meeting Notes' Notion page"
@ -60,7 +60,7 @@ def create_delete_notion_page_tool(
- "Archive the 'Draft Ideas' Notion page" - "Archive the 'Draft Ideas' Notion page"
""" """
logger.info( logger.info(
f"delete_notion_page called: page_title='{page_title}', delete_from_db={delete_from_db}" f"delete_notion_page called: page_title='{page_title}', delete_from_kb={delete_from_kb}"
) )
if db_session is None or search_space_id is None or user_id is None: if db_session is None or search_space_id is None or user_id is None:
@ -100,7 +100,7 @@ def create_delete_notion_page_tool(
document_id = context.get("document_id") document_id = context.get("document_id")
logger.info( logger.info(
f"Requesting approval for deleting Notion page: '{page_title}' (page_id={page_id}, delete_from_db={delete_from_db})" f"Requesting approval for deleting Notion page: '{page_title}' (page_id={page_id}, delete_from_kb={delete_from_kb})"
) )
# Request approval before deleting # Request approval before deleting
@ -112,7 +112,7 @@ def create_delete_notion_page_tool(
"params": { "params": {
"page_id": page_id, "page_id": page_id,
"connector_id": connector_id_from_context, "connector_id": connector_id_from_context,
"delete_from_db": delete_from_db, "delete_from_kb": delete_from_kb,
}, },
}, },
"context": context, "context": context,
@ -159,10 +159,10 @@ def create_delete_notion_page_tool(
final_connector_id = final_params.get( final_connector_id = final_params.get(
"connector_id", connector_id_from_context "connector_id", connector_id_from_context
) )
final_delete_from_db = final_params.get("delete_from_db", delete_from_db) final_delete_from_kb = final_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_db={final_delete_from_db}" f"Deleting Notion page with final params: page_id={final_page_id}, connector_id={final_connector_id}, delete_from_kb={final_delete_from_kb}"
) )
from sqlalchemy.future import select from sqlalchemy.future import select
@ -211,11 +211,11 @@ def create_delete_notion_page_tool(
f"delete_page result: {result.get('status')} - {result.get('message', '')}" f"delete_page result: {result.get('status')} - {result.get('message', '')}"
) )
# If deletion was successful and user wants to delete from DB # If deletion was successful and user wants to delete from KB
deleted_from_db = False deleted_from_kb = False
if ( if (
result.get("status") == "success" result.get("status") == "success"
and final_delete_from_db and final_delete_from_kb
and document_id and document_id
): ):
try: try:
@ -232,24 +232,23 @@ def create_delete_notion_page_tool(
if document: if document:
await db_session.delete(document) await db_session.delete(document)
await db_session.commit() await db_session.commit()
deleted_from_db = True deleted_from_kb = True
logger.info( logger.info(
f"Deleted document {document_id} from knowledge base" f"Deleted document {document_id} from knowledge base"
) )
else: else:
logger.warning(f"Document {document_id} not found in DB") logger.warning(f"Document {document_id} not found in KB")
except Exception as e: except Exception as e:
logger.error(f"Failed to delete document from DB: {e}") logger.error(f"Failed to delete document from KB: {e}")
# Don't fail the whole operation if DB deletion fails await db_session.rollback()
# The page is already deleted from Notion, so inform the user
result["warning"] = ( result["warning"] = (
f"Page deleted from Notion, but failed to remove from knowledge base: {e!s}" f"Page deleted from Notion, but failed to remove from knowledge base: {e!s}"
) )
# Update result with DB deletion status # Update result with KB deletion status
if result.get("status") == "success": if result.get("status") == "success":
result["deleted_from_db"] = deleted_from_db result["deleted_from_kb"] = deleted_from_kb
if deleted_from_db: if deleted_from_kb:
result["message"] = ( result["message"] = (
f"{result.get('message', '')} (also removed from knowledge base)" f"{result.get('message', '')} (also removed from knowledge base)"
) )
@ -263,11 +262,10 @@ def create_delete_notion_page_tool(
raise raise
logger.error(f"Error deleting Notion page: {e}", exc_info=True) logger.error(f"Error deleting Notion page: {e}", exc_info=True)
return { if isinstance(e, (ValueError, NotionAPIError)):
"status": "error", message = str(e)
"message": str(e) else:
if isinstance(e, ValueError) message = "Something went wrong while deleting the page. Please try again."
else f"Unexpected error: {e!s}", return {"status": "error", "message": message}
}
return delete_notion_page return delete_notion_page

View file

@ -5,7 +5,7 @@ from langchain_core.tools import tool
from langgraph.types import interrupt from langgraph.types import interrupt
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.connectors.notion_history import NotionHistoryConnector from app.connectors.notion_history import NotionAPIError, NotionHistoryConnector
from app.services.notion import NotionToolMetadataService from app.services.notion import NotionToolMetadataService
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -108,6 +108,7 @@ def create_update_notion_page_tool(
} }
page_id = context.get("page_id") page_id = context.get("page_id")
document_id = context.get("document_id")
connector_id_from_context = context.get("account", {}).get("id") connector_id_from_context = context.get("account", {}).get("id")
logger.info( logger.info(
@ -218,6 +219,39 @@ def create_update_notion_page_tool(
logger.info( logger.info(
f"update_page result: {result.get('status')} - {result.get('message', '')}" f"update_page result: {result.get('status')} - {result.get('message', '')}"
) )
if result.get("status") == "success" and document_id is not None:
from app.services.notion import NotionKBSyncService
logger.info(f"Updating knowledge base for document {document_id}...")
kb_service = NotionKBSyncService(db_session)
kb_result = await kb_service.sync_after_update(
document_id=document_id,
appended_content=final_content,
user_id=user_id,
search_space_id=search_space_id,
appended_block_ids=result.get("appended_block_ids"),
)
if kb_result["status"] == "success":
result["message"] = (
f"{result['message']}. Your knowledge base has also been updated."
)
logger.info(
f"Knowledge base successfully updated for page {final_page_id}"
)
elif kb_result["status"] == "not_indexed":
result["message"] = (
f"{result['message']}. This page will be added to your knowledge base in the next scheduled sync."
)
else:
result["message"] = (
f"{result['message']}. Your knowledge base will be updated in the next scheduled sync."
)
logger.warning(
f"KB update failed for page {final_page_id}: {kb_result['message']}"
)
return result return result
except Exception as e: except Exception as e:
@ -227,11 +261,10 @@ def create_update_notion_page_tool(
raise raise
logger.error(f"Error updating Notion page: {e}", exc_info=True) logger.error(f"Error updating Notion page: {e}", exc_info=True)
return { if isinstance(e, (ValueError, NotionAPIError)):
"status": "error", message = str(e)
"message": str(e) else:
if isinstance(e, ValueError) message = "Something went wrong while updating the page. Please try again."
else f"Unexpected error: {e!s}", return {"status": "error", "message": message}
}
return update_notion_page return update_notion_page

View file

@ -48,6 +48,11 @@ from app.db import ChatVisibility
from .display_image import create_display_image_tool from .display_image import create_display_image_tool
from .generate_image import create_generate_image_tool from .generate_image import create_generate_image_tool
from .knowledge_base import create_search_knowledge_base_tool from .knowledge_base import create_search_knowledge_base_tool
from .linear import (
create_create_linear_issue_tool,
create_delete_linear_issue_tool,
create_update_linear_issue_tool,
)
from .link_preview import create_link_preview_tool from .link_preview import create_link_preview_tool
from .mcp_tool import load_mcp_tools from .mcp_tool import load_mcp_tools
from .notion import ( from .notion import (
@ -216,6 +221,39 @@ BUILTIN_TOOLS: list[ToolDefinition] = [
requires=["user_id", "search_space_id", "db_session", "thread_visibility"], requires=["user_id", "search_space_id", "db_session", "thread_visibility"],
), ),
# ========================================================================= # =========================================================================
# LINEAR TOOLS - create, update, delete issues
# =========================================================================
ToolDefinition(
name="create_linear_issue",
description="Create a new issue in the user's Linear workspace",
factory=lambda deps: create_create_linear_issue_tool(
db_session=deps["db_session"],
search_space_id=deps["search_space_id"],
user_id=deps["user_id"],
),
requires=["db_session", "search_space_id", "user_id"],
),
ToolDefinition(
name="update_linear_issue",
description="Update an existing indexed Linear issue",
factory=lambda deps: create_update_linear_issue_tool(
db_session=deps["db_session"],
search_space_id=deps["search_space_id"],
user_id=deps["user_id"],
),
requires=["db_session", "search_space_id", "user_id"],
),
ToolDefinition(
name="delete_linear_issue",
description="Archive (delete) an existing indexed Linear issue",
factory=lambda deps: create_delete_linear_issue_tool(
db_session=deps["db_session"],
search_space_id=deps["search_space_id"],
user_id=deps["user_id"],
),
requires=["db_session", "search_space_id", "user_id"],
),
# =========================================================================
# NOTION TOOLS - create, update, delete pages # NOTION TOOLS - create, update, delete pages
# ========================================================================= # =========================================================================
ToolDefinition( ToolDefinition(

View file

@ -10,7 +10,6 @@ from datetime import datetime
from typing import Any from typing import Any
import httpx import httpx
import requests
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select from sqlalchemy.future import select
@ -23,6 +22,14 @@ logger = logging.getLogger(__name__)
LINEAR_GRAPHQL_URL = "https://api.linear.app/graphql" LINEAR_GRAPHQL_URL = "https://api.linear.app/graphql"
class LinearAPIError(Exception):
"""Raised when the Linear API returns a non-200 response.
The message is always user-presentable; callers should surface it directly
without any additional prefix or wrapping.
"""
ORGANIZATION_QUERY = """ ORGANIZATION_QUERY = """
query { query {
organization { organization {
@ -244,6 +251,37 @@ class LinearConnector:
"Authorization": f"Bearer {self._credentials.access_token}", "Authorization": f"Bearer {self._credentials.access_token}",
} }
@staticmethod
def _raise_api_error(status_code: int, body: str) -> None:
"""Parse a non-200 Linear API response and raise a clean exception.
Translates known Linear error codes into user-readable messages so that
raw GraphQL payloads never reach the end user.
"""
import json as _json
friendly = None
try:
payload = _json.loads(body)
errors = payload.get("errors", [])
if errors:
ext = errors[0].get("extensions", {})
code = ext.get("code", "")
if code == "INPUT_ERROR" and "too complex" in errors[0].get("message", "").lower():
friendly = (
"Linear rejected the request because the workspace is too large "
"to fetch in one query. Please try again — if the problem persists, "
"contact support."
)
elif ext.get("userPresentableMessage"):
friendly = ext["userPresentableMessage"]
elif errors[0].get("message"):
friendly = errors[0]["message"]
except Exception:
pass
raise LinearAPIError(friendly or f"Linear API error (HTTP {status_code})")
async def execute_graphql_query( async def execute_graphql_query(
self, query: str, variables: dict[str, Any] | None = None self, query: str, variables: dict[str, Any] | None = None
) -> dict[str, Any]: ) -> dict[str, Any]:
@ -274,14 +312,15 @@ class LinearConnector:
if variables: if variables:
payload["variables"] = variables payload["variables"] = variables
response = requests.post(self.api_url, headers=headers, json=payload) async with httpx.AsyncClient() as client:
response = await client.post(
self.api_url, headers=headers, json=payload, timeout=30.0
)
if response.status_code == 200: if response.status_code == 200:
return response.json() return response.json()
else: else:
raise Exception( self._raise_api_error(response.status_code, response.text)
f"Query failed with status code {response.status_code}: {response.text}"
)
async def get_all_issues( async def get_all_issues(
self, include_comments: bool = True self, include_comments: bool = True
@ -588,6 +627,148 @@ class LinearConnector:
return formatted return formatted
async def create_issue(
self,
team_id: str,
title: str,
description: str | None = None,
state_id: str | None = None,
assignee_id: str | None = None,
priority: int | None = None,
label_ids: list[str] | None = None,
) -> dict[str, Any]:
try:
mutation = """
mutation IssueCreate($input: IssueCreateInput!) {
issueCreate(input: $input) {
success
issue { id identifier title url }
}
}
"""
input_data: dict[str, Any] = {"teamId": team_id, "title": title}
if description is not None:
input_data["description"] = description
if state_id is not None:
input_data["stateId"] = state_id
if assignee_id is not None:
input_data["assigneeId"] = assignee_id
if priority is not None:
input_data["priority"] = priority
if label_ids:
input_data["labelIds"] = label_ids
result = await self.execute_graphql_query(mutation, {"input": input_data})
payload = result.get("data", {}).get("issueCreate", {})
if not payload.get("success"):
errors = result.get("errors", [])
msg = (
errors[0].get("message", "Unknown error")
if errors
else "Unknown error"
)
return {"status": "error", "message": f"issueCreate failed: {msg}"}
issue = payload.get("issue", {})
return {
"status": "success",
"id": issue.get("id"),
"identifier": issue.get("identifier"),
"title": issue.get("title"),
"url": issue.get("url"),
"message": f"Issue {issue.get('identifier')} created successfully.",
}
except Exception as e:
logger.error(f"Error creating Linear issue: {e}")
return {"status": "error", "message": str(e)}
async def update_issue(
self,
issue_id: str,
title: str | None = None,
description: str | None = None,
state_id: str | None = None,
assignee_id: str | None = None,
priority: int | None = None,
label_ids: list[str] | None = None,
) -> dict[str, Any]:
try:
mutation = """
mutation IssueUpdate($id: String!, $input: IssueUpdateInput!) {
issueUpdate(id: $id, input: $input) {
success
issue { id identifier title url }
}
}
"""
input_data: dict[str, Any] = {}
if title is not None:
input_data["title"] = title
if description is not None:
input_data["description"] = description
if state_id is not None:
input_data["stateId"] = state_id
if assignee_id is not None:
input_data["assigneeId"] = assignee_id
if priority is not None:
input_data["priority"] = priority
if label_ids is not None:
input_data["labelIds"] = label_ids
if not input_data:
return {
"status": "error",
"message": "No fields provided for update. Please specify at least one field to change.",
}
result = await self.execute_graphql_query(
mutation, {"id": issue_id, "input": input_data}
)
payload = result.get("data", {}).get("issueUpdate", {})
if not payload.get("success"):
errors = result.get("errors", [])
msg = (
errors[0].get("message", "Unknown error")
if errors
else "Unknown error"
)
return {"status": "error", "message": f"issueUpdate failed: {msg}"}
issue = payload.get("issue", {})
return {
"status": "success",
"id": issue.get("id"),
"identifier": issue.get("identifier"),
"title": issue.get("title"),
"url": issue.get("url"),
"message": f"Issue {issue.get('identifier')} updated successfully.",
}
except Exception as e:
logger.error(f"Error updating Linear issue: {e}")
return {"status": "error", "message": str(e)}
async def archive_issue(self, issue_id: str) -> dict[str, Any]:
try:
mutation = """
mutation IssueArchive($id: String!) {
issueArchive(id: $id) {
success
}
}
"""
result = await self.execute_graphql_query(mutation, {"id": issue_id})
payload = result.get("data", {}).get("issueArchive", {})
if not payload.get("success"):
errors = result.get("errors", [])
msg = (
errors[0].get("message", "Unknown error")
if errors
else "Unknown error"
)
return {"status": "error", "message": f"issueArchive failed: {msg}"}
return {"status": "success", "message": "Issue archived successfully."}
except Exception as e:
logger.error(f"Error archiving Linear issue: {e}")
return {"status": "error", "message": str(e)}
def format_issue_to_markdown(self, issue: dict[str, Any]) -> str: def format_issue_to_markdown(self, issue: dict[str, Any]) -> str:
""" """
Convert an issue to markdown format. Convert an issue to markdown format.

View file

@ -17,6 +17,15 @@ from app.utils.oauth_security import TokenEncryption
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class NotionAPIError(Exception):
"""Raised when the Notion API returns a non-200 response.
The message is always user-presentable; callers should surface it directly
without any additional prefix or wrapping.
"""
# Type variable for generic return type # Type variable for generic return type
T = TypeVar("T") T = TypeVar("T")
@ -250,8 +259,9 @@ class NotionHistoryConnector:
logger.error( logger.error(
f"Failed to refresh Notion token for connector {self._connector_id}: {e!s}" f"Failed to refresh Notion token for connector {self._connector_id}: {e!s}"
) )
raise Exception( raise NotionAPIError(
f"Failed to refresh Notion OAuth credentials: {e!s}" "Failed to refresh your Notion connection. "
"Please try again or reconnect your Notion account."
) from e ) from e
return self._credentials.access_token return self._credentials.access_token
@ -1041,7 +1051,7 @@ class NotionHistoryConnector:
try: try:
notion = await self._get_client() notion = await self._get_client()
# Append content if provided appended_block_ids = []
if content: if content:
# Convert new content to blocks # Convert new content to blocks
try: try:
@ -1065,14 +1075,23 @@ class NotionHistoryConnector:
try: try:
for i in range(0, len(children), 100): for i in range(0, len(children), 100):
batch = children[i : i + 100] batch = children[i : i + 100]
await self._api_call_with_retry( response = await self._api_call_with_retry(
notion.blocks.children.append, notion.blocks.children.append,
block_id=page_id, block_id=page_id,
children=batch, children=batch,
) )
batch_block_ids = [
block["id"] for block in response.get("results", [])
]
appended_block_ids.extend(batch_block_ids)
logger.info( logger.info(
f"Successfully appended {len(children)} new blocks to page {page_id}" f"Successfully appended {len(children)} new blocks to page {page_id}"
) )
logger.debug(
f"Appended block IDs: {appended_block_ids[:5]}..."
if len(appended_block_ids) > 5
else f"Appended block IDs: {appended_block_ids}"
)
except Exception as e: except Exception as e:
logger.error(f"Failed to append content blocks: {e}") logger.error(f"Failed to append content blocks: {e}")
return { return {
@ -1092,6 +1111,7 @@ class NotionHistoryConnector:
"page_id": page_id, "page_id": page_id,
"url": page_url, "url": page_url,
"title": page_title, "title": page_title,
"appended_block_ids": appended_block_ids,
"message": f"Updated Notion page '{page_title}' (content appended)", "message": f"Updated Notion page '{page_title}' (content appended)",
} }

View file

@ -0,0 +1,13 @@
from app.services.linear.kb_sync_service import LinearKBSyncService
from app.services.linear.tool_metadata_service import (
LinearIssue,
LinearToolMetadataService,
LinearWorkspace,
)
__all__ = [
"LinearIssue",
"LinearKBSyncService",
"LinearToolMetadataService",
"LinearWorkspace",
]

View file

@ -0,0 +1,182 @@
import logging
from datetime import datetime
from sqlalchemy import delete
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import config
from app.connectors.linear_connector import LinearConnector
from app.db import Chunk, Document
from app.services.llm_service import get_user_long_context_llm
from app.utils.document_converters import (
create_document_chunks,
generate_content_hash,
generate_document_summary,
)
logger = logging.getLogger(__name__)
class LinearKBSyncService:
"""Re-indexes a single Linear issue document after a successful update.
Mirrors the indexer's Phase-2 logic exactly: fetch fresh issue content,
run generate_document_summary, create_document_chunks, then update the
document row in the knowledge base.
"""
def __init__(self, db_session: AsyncSession):
self.db_session = db_session
async def sync_after_update(
self,
document_id: int,
issue_id: str,
user_id: str,
search_space_id: int,
) -> dict:
"""Re-index a Linear issue document after it has been updated via the API.
Args:
document_id: The KB document ID to update.
issue_id: The Linear issue UUID to fetch fresh content from.
user_id: Used to select the correct LLM configuration.
search_space_id: Used to select the correct LLM configuration.
Returns:
dict with 'status': 'success' | 'not_indexed' | 'error'.
"""
from app.tasks.connector_indexers.base import (
get_current_timestamp,
safe_set_chunks,
)
try:
document = await self.db_session.get(Document, document_id)
if not document:
logger.warning(f"Document {document_id} not found in KB")
return {"status": "not_indexed"}
connector_id = document.connector_id
if not connector_id:
return {"status": "error", "message": "Document has no connector_id"}
linear_client = LinearConnector(
session=self.db_session, connector_id=connector_id
)
issue_raw = await self._fetch_issue(linear_client, issue_id)
if not issue_raw:
return {"status": "error", "message": "Issue not found in Linear API"}
formatted_issue = linear_client.format_issue(issue_raw)
issue_content = linear_client.format_issue_to_markdown(formatted_issue)
if not issue_content:
return {"status": "error", "message": "Issue produced empty content"}
issue_identifier = formatted_issue.get("identifier", "")
issue_title = formatted_issue.get("title", "")
state = formatted_issue.get("state", "Unknown")
priority = issue_raw.get("priorityLabel", "Unknown")
comment_count = len(formatted_issue.get("comments", []))
description = formatted_issue.get("description", "")
user_llm = await get_user_long_context_llm(
self.db_session, user_id, search_space_id, disable_streaming=True
)
if user_llm:
document_metadata_for_summary = {
"issue_id": issue_identifier,
"issue_title": issue_title,
"state": state,
"priority": priority,
"comment_count": comment_count,
"document_type": "Linear Issue",
"connector_type": "Linear",
}
summary_content, summary_embedding = await generate_document_summary(
issue_content, user_llm, document_metadata_for_summary
)
else:
if description and len(description) > 1000:
description = description[:997] + "..."
summary_content = (
f"Linear Issue {issue_identifier}: {issue_title}\n\n"
f"Status: {state}\n\n"
)
if description:
summary_content += f"Description: {description}\n\n"
summary_content += f"Comments: {comment_count}"
summary_embedding = config.embedding_model_instance.embed(
summary_content
)
await self.db_session.execute(
delete(Chunk).where(Chunk.document_id == document.id)
)
chunks = await create_document_chunks(issue_content)
document.title = f"{issue_identifier}: {issue_title}"
document.content = summary_content
document.content_hash = generate_content_hash(
issue_content, search_space_id
)
document.embedding = summary_embedding
from sqlalchemy.orm.attributes import flag_modified
document.document_metadata = {
**(document.document_metadata or {}),
"issue_id": issue_id,
"issue_identifier": issue_identifier,
"issue_title": issue_title,
"state": state,
"priority": priority,
"comment_count": comment_count,
"indexed_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
"connector_id": connector_id,
}
flag_modified(document, "document_metadata")
safe_set_chunks(document, chunks)
document.updated_at = get_current_timestamp()
await self.db_session.commit()
logger.info(
f"KB sync successful for document {document_id} "
f"({issue_identifier}: {issue_title})"
)
return {"status": "success"}
except Exception as e:
logger.error(
f"KB sync failed for document {document_id}: {e}", exc_info=True
)
await self.db_session.rollback()
return {"status": "error", "message": str(e)}
@staticmethod
async def _fetch_issue(client: LinearConnector, issue_id: str) -> dict | None:
"""Fetch a full issue from Linear, matching the fields used by format_issue."""
query = """
query LinearIssueSync($id: String!) {
issue(id: $id) {
id identifier title description priority priorityLabel
createdAt updatedAt url
state { id name type color }
creator { id name email }
assignee { id name email }
comments {
nodes {
id body createdAt updatedAt
user { id name email }
}
}
team { id name key }
}
}
"""
result = await client.execute_graphql_query(query, {"id": issue_id})
return (result.get("data") or {}).get("issue")

View file

@ -0,0 +1,360 @@
from dataclasses import dataclass
from sqlalchemy import and_, func, or_
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select
from app.connectors.linear_connector import LinearConnector
from app.db import (
Document,
DocumentType,
SearchSourceConnector,
SearchSourceConnectorType,
)
@dataclass
class LinearWorkspace:
"""Represents a Linear connector as a workspace for tool context."""
id: int
name: str
organization_name: str
@classmethod
def from_connector(cls, connector: SearchSourceConnector) -> "LinearWorkspace":
return cls(
id=connector.id,
name=connector.name,
organization_name=connector.config.get(
"organization_name", "Linear Workspace"
),
)
def to_dict(self) -> dict:
return {
"id": self.id,
"name": self.name,
"organization_name": self.organization_name,
}
@dataclass
class LinearIssue:
"""Represents an indexed Linear issue resolved from the knowledge base."""
id: str
identifier: str
title: str
state: str
connector_id: int
document_id: int
indexed_at: str | None
@classmethod
def from_document(cls, document: Document) -> "LinearIssue":
meta = document.document_metadata or {}
return cls(
id=meta.get("issue_id", ""),
identifier=meta.get("issue_identifier", ""),
title=meta.get("issue_title", document.title),
state=meta.get("state", ""),
connector_id=document.connector_id,
document_id=document.id,
indexed_at=meta.get("indexed_at"),
)
def to_dict(self) -> dict:
return {
"id": self.id,
"identifier": self.identifier,
"title": self.title,
"state": self.state,
"connector_id": self.connector_id,
"document_id": self.document_id,
"indexed_at": self.indexed_at,
}
class LinearToolMetadataService:
"""Builds interrupt context for Linear HITL tools.
All context queries (GraphQL reads) live here.
Write mutations live in LinearConnector.
"""
def __init__(self, db_session: AsyncSession):
self._db_session = db_session
async def get_creation_context(self, search_space_id: int, user_id: str) -> dict:
"""Return context needed to create a new Linear issue.
Fetches all connected Linear workspaces, and for each one fetches
its teams with states, members, and labels from the Linear API.
Returns a dict with key: workspaces (each entry has id, name, organization_name, teams, priorities).
Returns a dict with key 'error' on failure.
"""
connectors = await self._get_all_linear_connectors(search_space_id, user_id)
if not connectors:
return {"error": "No Linear account connected"}
workspaces = []
for connector in connectors:
workspace = LinearWorkspace.from_connector(connector)
linear_client = LinearConnector(
session=self._db_session, connector_id=connector.id
)
try:
priorities = await self._fetch_priority_values(linear_client)
teams = await self._fetch_teams_context(linear_client)
except Exception as e:
return {"error": f"Failed to fetch Linear context: {e!s}"}
workspaces.append({
"id": workspace.id,
"name": workspace.name,
"organization_name": workspace.organization_name,
"teams": teams,
"priorities": priorities,
})
return {"workspaces": workspaces}
async def get_update_context(
self, search_space_id: int, user_id: str, issue_ref: str
) -> dict:
"""Return context needed to update an indexed Linear issue.
Resolves the issue from the KB (title identifier full title),
then fetches its current state, assignee, labels, and team context
from the Linear API.
Returns a dict with keys: workspace, priorities, issue, team.
Returns a dict with key 'error' if the issue is not found or API fails.
"""
document = await self._resolve_issue(search_space_id, user_id, issue_ref)
if not document:
return {
"error": f"Issue '{issue_ref}' not found in your indexed Linear issues. "
"This could mean: (1) the issue doesn't exist, (2) it hasn't been indexed yet, "
"or (3) the title or identifier is different."
}
connector = await self._get_connector_for_document(document, user_id)
if not connector:
return {"error": "Connector not found or access denied"}
workspace = LinearWorkspace.from_connector(connector)
issue = LinearIssue.from_document(document)
linear_client = LinearConnector(
session=self._db_session, connector_id=connector.id
)
try:
priorities = await self._fetch_priority_values(linear_client)
issue_api = await self._fetch_issue_context(linear_client, issue.id)
except Exception as e:
return {"error": f"Failed to fetch Linear issue context: {e!s}"}
if not issue_api:
return {
"error": f"Issue '{issue_ref}' could not be fetched from Linear API"
}
team_raw = issue_api.get("team") or {}
labels_raw = issue_api.get("labels") or {}
states_raw = team_raw.get("states") or {}
members_raw = team_raw.get("members") or {}
team_labels_raw = team_raw.get("labels") or {}
return {
"workspace": workspace.to_dict(),
"priorities": priorities,
"issue": {
"id": issue_api.get("id"),
"identifier": issue_api.get("identifier"),
"title": issue_api.get("title"),
"description": issue_api.get("description"),
"priority": issue_api.get("priority"),
"url": issue_api.get("url"),
"current_state": issue_api.get("state"),
"current_assignee": issue_api.get("assignee"),
"current_labels": labels_raw.get("nodes", []),
"team_id": team_raw.get("id"),
"document_id": issue.document_id,
"indexed_at": issue.indexed_at,
},
"team": {
"id": team_raw.get("id"),
"name": team_raw.get("name"),
"key": team_raw.get("key"),
"states": states_raw.get("nodes", []),
"members": members_raw.get("nodes", []),
"labels": team_labels_raw.get("nodes", []),
},
}
async def get_delete_context(
self, search_space_id: int, user_id: str, issue_ref: str
) -> dict:
"""Return context needed to archive an indexed Linear issue.
Resolves the issue from the KB only no Linear API call required.
Returns a dict with keys: workspace, issue.
Returns a dict with key 'error' if the issue is not found.
"""
document = await self._resolve_issue(search_space_id, user_id, issue_ref)
if not document:
return {
"error": f"Issue '{issue_ref}' not found in your indexed Linear issues. "
"This could mean: (1) the issue doesn't exist, (2) it hasn't been indexed yet, "
"or (3) the title or identifier is different."
}
connector = await self._get_connector_for_document(document, user_id)
if not connector:
return {"error": "Connector not found or access denied"}
workspace = LinearWorkspace.from_connector(connector)
issue = LinearIssue.from_document(document)
return {
"workspace": workspace.to_dict(),
"issue": issue.to_dict(),
}
@staticmethod
async def _fetch_priority_values(client: LinearConnector) -> list[dict]:
"""Fetch Linear priority values (0-4) with their display labels."""
query = "{ issuePriorityValues { priority label } }"
result = await client.execute_graphql_query(query)
return result.get("data", {}).get("issuePriorityValues", [])
@staticmethod
async def _fetch_teams_context(client: LinearConnector) -> list[dict]:
"""Fetch all teams with their states, members, and labels."""
query = """
query {
teams(first: 25) {
nodes {
id name key
states { nodes { id name type color position } }
members { nodes { id name displayName email avatarUrl active } }
labels { nodes { id name color } }
}
}
}
"""
result = await client.execute_graphql_query(query)
raw_teams = result.get("data", {}).get("teams", {}).get("nodes", [])
return [
{
"id": t.get("id"),
"name": t.get("name"),
"key": t.get("key"),
"states": (t.get("states") or {}).get("nodes", []),
"members": (t.get("members") or {}).get("nodes", []),
"labels": (t.get("labels") or {}).get("nodes", []),
}
for t in raw_teams
]
@staticmethod
async def _fetch_issue_context(
client: LinearConnector, issue_id: str
) -> dict | None:
"""Fetch a single issue with its current state, assignee, labels, and team context."""
query = """
query LinearIssueContext($id: String!) {
issue(id: $id) {
id identifier title description priority url
state { id name type color }
assignee { id name displayName email }
labels { nodes { id name color } }
team {
id name key
states { nodes { id name type color position } }
members { nodes { id name displayName email avatarUrl active } }
labels { nodes { id name color } }
}
}
}
"""
result = await client.execute_graphql_query(query, {"id": issue_id})
return result.get("data", {}).get("issue")
async def _resolve_issue(
self, search_space_id: int, user_id: str, issue_ref: str
) -> Document | None:
"""Resolve an issue from the KB using a 3-step fallback.
Order: issue_title (most natural) issue_identifier (e.g. ENG-42) document.title.
All comparisons are case-insensitive.
"""
ref_lower = issue_ref.lower()
result = await self._db_session.execute(
select(Document)
.join(
SearchSourceConnector, Document.connector_id == SearchSourceConnector.id
)
.filter(
and_(
Document.search_space_id == search_space_id,
Document.document_type == DocumentType.LINEAR_CONNECTOR,
SearchSourceConnector.user_id == user_id,
or_(
func.lower(
Document.document_metadata.op("->>")(
"issue_title"
)
)
== ref_lower,
func.lower(
Document.document_metadata.op("->>")(
"issue_identifier"
)
)
== ref_lower,
func.lower(Document.title) == ref_lower,
),
)
)
.limit(1)
)
return result.scalars().first()
async def _get_all_linear_connectors(
self, search_space_id: int, user_id: str
) -> list[SearchSourceConnector]:
"""Fetch all Linear connectors for the given search space and user."""
result = await self._db_session.execute(
select(SearchSourceConnector).filter(
and_(
SearchSourceConnector.search_space_id == search_space_id,
SearchSourceConnector.user_id == user_id,
SearchSourceConnector.connector_type
== SearchSourceConnectorType.LINEAR_CONNECTOR,
)
)
)
return result.scalars().all()
async def _get_connector_for_document(
self, document: Document, user_id: str
) -> SearchSourceConnector | None:
"""Fetch the connector associated with a document, scoped to the user."""
if not document.connector_id:
return None
result = await self._db_session.execute(
select(SearchSourceConnector).filter(
and_(
SearchSourceConnector.id == document.connector_id,
SearchSourceConnector.user_id == user_id,
)
)
)
return result.scalars().first()

View file

@ -162,7 +162,7 @@ async def validate_llm_config(
async def get_search_space_llm_instance( async def get_search_space_llm_instance(
session: AsyncSession, search_space_id: int, role: str session: AsyncSession, search_space_id: int, role: str, disable_streaming: bool = False
) -> ChatLiteLLM | ChatLiteLLMRouter | None: ) -> ChatLiteLLM | ChatLiteLLMRouter | None:
""" """
Get a ChatLiteLLM instance for a specific search space and role. Get a ChatLiteLLM instance for a specific search space and role.
@ -218,7 +218,7 @@ async def get_search_space_llm_instance(
logger.debug( logger.debug(
f"Using Auto mode (LLM Router) for search space {search_space_id}, role {role}" f"Using Auto mode (LLM Router) for search space {search_space_id}, role {role}"
) )
return ChatLiteLLMRouter() return ChatLiteLLMRouter(disable_streaming=disable_streaming)
except Exception as e: except Exception as e:
logger.error(f"Failed to create ChatLiteLLMRouter: {e}") logger.error(f"Failed to create ChatLiteLLMRouter: {e}")
return None return None
@ -284,6 +284,9 @@ async def get_search_space_llm_instance(
if global_config.get("litellm_params"): if global_config.get("litellm_params"):
litellm_kwargs.update(global_config["litellm_params"]) litellm_kwargs.update(global_config["litellm_params"])
if disable_streaming:
litellm_kwargs["disable_streaming"] = True
return ChatLiteLLM(**litellm_kwargs) return ChatLiteLLM(**litellm_kwargs)
# Get the LLM configuration from database (NewLLMConfig) # Get the LLM configuration from database (NewLLMConfig)
@ -357,6 +360,9 @@ async def get_search_space_llm_instance(
if llm_config.litellm_params: if llm_config.litellm_params:
litellm_kwargs.update(llm_config.litellm_params) litellm_kwargs.update(llm_config.litellm_params)
if disable_streaming:
litellm_kwargs["disable_streaming"] = True
return ChatLiteLLM(**litellm_kwargs) return ChatLiteLLM(**litellm_kwargs)
except Exception as e: except Exception as e:
@ -374,20 +380,20 @@ async def get_agent_llm(
async def get_document_summary_llm( async def get_document_summary_llm(
session: AsyncSession, search_space_id: int session: AsyncSession, search_space_id: int, disable_streaming: bool = False
) -> ChatLiteLLM | ChatLiteLLMRouter | None: ) -> ChatLiteLLM | ChatLiteLLMRouter | None:
"""Get the search space's document summary LLM instance.""" """Get the search space's document summary LLM instance."""
return await get_search_space_llm_instance( return await get_search_space_llm_instance(
session, search_space_id, LLMRole.DOCUMENT_SUMMARY session, search_space_id, LLMRole.DOCUMENT_SUMMARY, disable_streaming=disable_streaming
) )
# Backward-compatible alias (LLM preferences are now per-search-space, not per-user) # Backward-compatible alias (LLM preferences are now per-search-space, not per-user)
async def get_user_long_context_llm( async def get_user_long_context_llm(
session: AsyncSession, user_id: str, search_space_id: int session: AsyncSession, user_id: str, search_space_id: int, disable_streaming: bool = False
) -> ChatLiteLLM | ChatLiteLLMRouter | None: ) -> ChatLiteLLM | ChatLiteLLMRouter | None:
""" """
Deprecated: Use get_document_summary_llm instead. Deprecated: Use get_document_summary_llm instead.
The user_id parameter is ignored as LLM preferences are now per-search-space. The user_id parameter is ignored as LLM preferences are now per-search-space.
""" """
return await get_document_summary_llm(session, search_space_id) return await get_document_summary_llm(session, search_space_id, disable_streaming=disable_streaming)

View file

@ -1,3 +1,4 @@
from app.services.notion.kb_sync_service import NotionKBSyncService
from app.services.notion.tool_metadata_service import ( from app.services.notion.tool_metadata_service import (
NotionAccount, NotionAccount,
NotionPage, NotionPage,
@ -6,6 +7,7 @@ from app.services.notion.tool_metadata_service import (
__all__ = [ __all__ = [
"NotionAccount", "NotionAccount",
"NotionKBSyncService",
"NotionPage", "NotionPage",
"NotionToolMetadataService", "NotionToolMetadataService",
] ]

View file

@ -0,0 +1,163 @@
import logging
from datetime import datetime
from sqlalchemy import delete
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import config
from app.db import Chunk, Document
from app.services.llm_service import get_user_long_context_llm
from app.utils.document_converters import (
create_document_chunks,
generate_content_hash,
generate_document_summary,
)
logger = logging.getLogger(__name__)
class NotionKBSyncService:
def __init__(self, db_session: AsyncSession):
self.db_session = db_session
async def sync_after_update(
self,
document_id: int,
appended_content: str,
user_id: str,
search_space_id: int,
appended_block_ids: list[str] | None = None,
) -> dict:
from app.tasks.connector_indexers.base import (
get_current_timestamp,
safe_set_chunks,
)
try:
logger.debug(f"Starting KB sync for document {document_id}")
document = await self.db_session.get(Document, document_id)
if not document:
logger.warning(f"Document {document_id} not found in KB")
return {"status": "not_indexed"}
page_id = document.document_metadata.get("page_id")
if not page_id:
logger.error(f"Document {document_id} missing page_id in metadata")
return {"status": "error", "message": "Missing page_id in metadata"}
logger.debug(
f"Document found: id={document_id}, page_id={page_id}, connector_id={document.connector_id}"
)
from app.connectors.notion_history import NotionHistoryConnector
notion_connector = NotionHistoryConnector(
session=self.db_session, connector_id=document.connector_id
)
logger.debug(f"Fetching page content from Notion for page {page_id}")
blocks, _ = await notion_connector.get_page_content(page_id, page_title=None)
from app.utils.notion_utils import extract_all_block_ids, process_blocks
fetched_content = process_blocks(blocks)
logger.debug(f"Fetched content length: {len(fetched_content)} chars")
if not fetched_content or not fetched_content.strip():
logger.warning(
f"Fetched empty content for page {page_id} - document will have minimal searchable text"
)
content_verified = False
if appended_block_ids:
fetched_block_ids = set(extract_all_block_ids(blocks))
found_blocks = [
bid for bid in appended_block_ids if bid in fetched_block_ids
]
logger.debug(
f"Block verification: {len(found_blocks)}/{len(appended_block_ids)} blocks found"
)
logger.debug(
f"Appended IDs (first 3): {appended_block_ids[:3]}, Fetched IDs count: {len(fetched_block_ids)}"
)
if len(found_blocks) >= len(appended_block_ids) * 0.8: # 80% threshold
logger.info(
f"Content verified fresh: found {len(found_blocks)}/{len(appended_block_ids)} appended blocks"
)
full_content = fetched_content
content_verified = True
else:
logger.warning(
"No appended blocks found in fetched content - appending manually"
)
full_content = fetched_content + "\n\n" + appended_content
content_verified = False
else:
logger.warning("No block IDs provided - using fetched content as-is")
full_content = fetched_content
content_verified = False
logger.debug(f"Final content length: {len(full_content)} chars, verified={content_verified}")
logger.debug("Generating summary and embeddings")
user_llm = await get_user_long_context_llm(
self.db_session, user_id, search_space_id, disable_streaming=True # disable streaming to avoid leaking into the chat
)
if user_llm:
document_metadata_for_summary = {
"page_title": document.document_metadata.get("page_title"),
"page_id": document.document_metadata.get("page_id"),
"document_type": "Notion Page",
"connector_type": "Notion",
}
summary_content, summary_embedding = await generate_document_summary(
full_content, user_llm, document_metadata_for_summary
)
logger.debug(f"Generated summary length: {len(summary_content)} chars")
else:
logger.warning("No LLM configured - using fallback summary")
summary_content = f"Notion Page: {document.document_metadata.get('page_title')}\n\n{full_content[:500]}..."
summary_embedding = config.embedding_model_instance.embed(
summary_content
)
logger.debug(f"Deleting old chunks for document {document_id}")
await self.db_session.execute(
delete(Chunk).where(Chunk.document_id == document.id)
)
logger.debug("Creating new chunks")
chunks = await create_document_chunks(full_content)
logger.debug(f"Created {len(chunks)} chunks")
logger.debug("Updating document fields")
document.content = summary_content
document.content_hash = generate_content_hash(full_content, search_space_id)
document.embedding = summary_embedding
document.document_metadata = {
**document.document_metadata,
"indexed_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
}
safe_set_chunks(document, chunks)
document.updated_at = get_current_timestamp()
logger.debug("Committing changes to database")
await self.db_session.commit()
logger.info(
f"Successfully synced KB for document {document_id}: "
f"summary={len(summary_content)} chars, chunks={len(chunks)}, "
f"content_verified={content_verified}"
)
return {"status": "success"}
except Exception as e:
logger.error(
f"Failed to sync KB for document {document_id}: {e}", exc_info=True
)
await self.db_session.rollback()
return {"status": "error", "message": str(e)}

View file

@ -796,6 +796,9 @@ async def _stream_agent_events(
"create_notion_page", "create_notion_page",
"update_notion_page", "update_notion_page",
"delete_notion_page", "delete_notion_page",
"create_linear_issue",
"update_linear_issue",
"delete_linear_issue",
): ):
yield streaming_service.format_tool_output_available( yield streaming_service.format_tool_output_available(
tool_call_id, tool_call_id,

View file

@ -24,6 +24,7 @@ from app.utils.document_converters import (
generate_document_summary, generate_document_summary,
generate_unique_identifier_hash, generate_unique_identifier_hash,
) )
from app.utils.notion_utils import process_blocks
from .base import ( from .base import (
build_document_metadata_string, build_document_metadata_string,
@ -280,53 +281,6 @@ async def index_notion_pages(
pages_to_process = [] # List of dicts with document and page data pages_to_process = [] # List of dicts with document and page data
new_documents_created = False new_documents_created = False
# Helper function to convert page content to markdown
def process_blocks(blocks, level=0):
result = ""
for block in blocks:
block_type = block.get("type")
block_content = block.get("content", "")
children = block.get("children", [])
# Add indentation based on level
indent = " " * level
# Format based on block type
if block_type in ["paragraph", "text"]:
result += f"{indent}{block_content}\n\n"
elif block_type in ["heading_1", "header"]:
result += f"{indent}# {block_content}\n\n"
elif block_type == "heading_2":
result += f"{indent}## {block_content}\n\n"
elif block_type == "heading_3":
result += f"{indent}### {block_content}\n\n"
elif block_type == "bulleted_list_item":
result += f"{indent}* {block_content}\n"
elif block_type == "numbered_list_item":
result += f"{indent}1. {block_content}\n"
elif block_type == "to_do":
result += f"{indent}- [ ] {block_content}\n"
elif block_type == "toggle":
result += f"{indent}> {block_content}\n"
elif block_type == "code":
result += f"{indent}```\n{block_content}\n```\n\n"
elif block_type == "quote":
result += f"{indent}> {block_content}\n\n"
elif block_type == "callout":
result += f"{indent}> **Note:** {block_content}\n\n"
elif block_type == "image":
result += f"{indent}![Image]({block_content})\n\n"
else:
# Default for other block types
if block_content:
result += f"{indent}{block_content}\n\n"
# Process children recursively
if children:
result += process_blocks(children, level + 1)
return result
for page in pages: for page in pages:
try: try:
page_id = page.get("page_id") page_id = page.get("page_id")

View file

@ -0,0 +1,58 @@
"""Utility functions for processing Notion blocks and content."""
def extract_all_block_ids(blocks_list):
ids = []
for block in blocks_list:
if isinstance(block, dict) and "id" in block:
ids.append(block["id"])
if isinstance(block, dict) and block.get("children"):
ids.extend(extract_all_block_ids(block["children"]))
return ids
def process_blocks(blocks, level=0):
result = ""
for block in blocks:
block_type = block.get("type")
block_content = block.get("content", "")
children = block.get("children", [])
# Add indentation based on level
indent = " " * level
# Format based on block type
if block_type in ["paragraph", "text"]:
result += f"{indent}{block_content}\n\n"
elif block_type in ["heading_1", "header"]:
result += f"{indent}# {block_content}\n\n"
elif block_type == "heading_2":
result += f"{indent}## {block_content}\n\n"
elif block_type == "heading_3":
result += f"{indent}### {block_content}\n\n"
elif block_type == "bulleted_list_item":
result += f"{indent}* {block_content}\n"
elif block_type == "numbered_list_item":
result += f"{indent}1. {block_content}\n"
elif block_type == "to_do":
result += f"{indent}- [ ] {block_content}\n"
elif block_type == "toggle":
result += f"{indent}> {block_content}\n"
elif block_type == "code":
result += f"{indent}```\n{block_content}\n```\n\n"
elif block_type == "quote":
result += f"{indent}> {block_content}\n\n"
elif block_type == "callout":
result += f"{indent}> **Note:** {block_content}\n\n"
elif block_type == "image":
result += f"{indent}![Image]({block_content})\n\n"
else:
# Default for other block types
if block_content:
result += f"{indent}{block_content}\n\n"
# Process children recursively
if children:
result += process_blocks(children, level + 1)
return result

View file

@ -34,15 +34,22 @@ import { currentUserAtom } from "@/atoms/user/user-query.atoms";
import { Thread } from "@/components/assistant-ui/thread"; import { Thread } from "@/components/assistant-ui/thread";
import { ChatHeader } from "@/components/new-chat/chat-header"; import { ChatHeader } from "@/components/new-chat/chat-header";
import { ReportPanel } from "@/components/report-panel/report-panel"; import { ReportPanel } from "@/components/report-panel/report-panel";
import { CreateNotionPageToolUI } from "@/components/tool-ui/create-notion-page";
import type { ThinkingStep } from "@/components/tool-ui/deepagent-thinking"; import type { ThinkingStep } from "@/components/tool-ui/deepagent-thinking";
import { DeleteNotionPageToolUI } from "@/components/tool-ui/delete-notion-page";
import { DisplayImageToolUI } from "@/components/tool-ui/display-image"; import { DisplayImageToolUI } from "@/components/tool-ui/display-image";
import { GeneratePodcastToolUI } from "@/components/tool-ui/generate-podcast"; import { GeneratePodcastToolUI } from "@/components/tool-ui/generate-podcast";
import { GenerateReportToolUI } from "@/components/tool-ui/generate-report"; import { GenerateReportToolUI } from "@/components/tool-ui/generate-report";
import {
CreateLinearIssueToolUI,
DeleteLinearIssueToolUI,
UpdateLinearIssueToolUI,
} from "@/components/tool-ui/linear";
import { LinkPreviewToolUI } from "@/components/tool-ui/link-preview"; import { LinkPreviewToolUI } from "@/components/tool-ui/link-preview";
import {
CreateNotionPageToolUI,
DeleteNotionPageToolUI,
UpdateNotionPageToolUI,
} from "@/components/tool-ui/notion";
import { ScrapeWebpageToolUI } from "@/components/tool-ui/scrape-webpage"; import { ScrapeWebpageToolUI } from "@/components/tool-ui/scrape-webpage";
import { UpdateNotionPageToolUI } from "@/components/tool-ui/update-notion-page";
import { RecallMemoryToolUI, SaveMemoryToolUI } from "@/components/tool-ui/user-memory"; import { RecallMemoryToolUI, SaveMemoryToolUI } from "@/components/tool-ui/user-memory";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { useChatSessionStateSync } from "@/hooks/use-chat-session-state"; import { useChatSessionStateSync } from "@/hooks/use-chat-session-state";
@ -141,6 +148,9 @@ const TOOLS_WITH_UI = new Set([
"scrape_webpage", "scrape_webpage",
"create_notion_page", "create_notion_page",
"update_notion_page", "update_notion_page",
"create_linear_issue",
"update_linear_issue",
"delete_linear_issue",
// "write_todos", // Disabled for now // "write_todos", // Disabled for now
]); ]);
@ -1651,6 +1661,9 @@ export default function NewChatPage() {
<CreateNotionPageToolUI /> <CreateNotionPageToolUI />
<UpdateNotionPageToolUI /> <UpdateNotionPageToolUI />
<DeleteNotionPageToolUI /> <DeleteNotionPageToolUI />
<CreateLinearIssueToolUI />
<UpdateLinearIssueToolUI />
<DeleteLinearIssueToolUI />
{/* <WriteTodosToolUI /> Disabled for now */} {/* <WriteTodosToolUI /> Disabled for now */}
<div className="flex h-[calc(100dvh-64px)] overflow-hidden"> <div className="flex h-[calc(100dvh-64px)] overflow-hidden">
<div className="flex-1 flex flex-col min-w-0 overflow-hidden"> <div className="flex-1 flex flex-col min-w-0 overflow-hidden">

View file

@ -16,7 +16,6 @@ export {
type SerializableArticle, type SerializableArticle,
} from "./article"; } from "./article";
export { Audio } from "./audio"; export { Audio } from "./audio";
export { CreateNotionPageToolUI } from "./create-notion-page";
export { export {
type DeepAgentThinkingArgs, type DeepAgentThinkingArgs,
type DeepAgentThinkingResult, type DeepAgentThinkingResult,
@ -42,6 +41,11 @@ export {
parseSerializableImage, parseSerializableImage,
type SerializableImage, type SerializableImage,
} from "./image"; } from "./image";
export {
CreateLinearIssueToolUI,
DeleteLinearIssueToolUI,
UpdateLinearIssueToolUI,
} from "./linear";
export { export {
type LinkPreviewArgs, type LinkPreviewArgs,
LinkPreviewArgsSchema, LinkPreviewArgsSchema,
@ -63,6 +67,7 @@ export {
parseSerializableMediaCard, parseSerializableMediaCard,
type SerializableMediaCard, type SerializableMediaCard,
} from "./media-card"; } from "./media-card";
export { CreateNotionPageToolUI, DeleteNotionPageToolUI, UpdateNotionPageToolUI } from "./notion";
export { export {
Plan, Plan,
PlanErrorBoundary, PlanErrorBoundary,
@ -79,7 +84,6 @@ export {
ScrapeWebpageResultSchema, ScrapeWebpageResultSchema,
ScrapeWebpageToolUI, ScrapeWebpageToolUI,
} from "./scrape-webpage"; } from "./scrape-webpage";
export { UpdateNotionPageToolUI } from "./update-notion-page";
export { export {
type MemoryItem, type MemoryItem,
type RecallMemoryArgs, type RecallMemoryArgs,

View file

@ -0,0 +1,619 @@
"use client";
import { makeAssistantToolUI } from "@assistant-ui/react";
import { AlertTriangleIcon, CheckIcon, Loader2Icon, PencilIcon, XIcon } from "lucide-react";
import { useMemo, useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
interface LinearLabel {
id: string;
name: string;
color: string;
}
interface LinearState {
id: string;
name: string;
type: string;
color: string;
position: number;
}
interface LinearMember {
id: string;
name: string;
displayName: string;
email: string;
active: boolean;
}
interface LinearTeam {
id: string;
name: string;
key: string;
states: LinearState[];
members: LinearMember[];
labels: LinearLabel[];
}
interface LinearPriority {
priority: number;
label: string;
}
interface LinearWorkspace {
id: number;
name: string;
organization_name: string;
teams: LinearTeam[];
priorities: LinearPriority[];
}
interface InterruptResult {
__interrupt__: true;
__decided__?: "approve" | "reject" | "edit";
action_requests: Array<{
name: string;
args: Record<string, unknown>;
}>;
review_configs: Array<{
action_name: string;
allowed_decisions: Array<"approve" | "edit" | "reject">;
}>;
interrupt_type?: string;
context?: {
workspaces?: LinearWorkspace[];
error?: string;
};
}
interface SuccessResult {
status: "success";
issue_id: string;
identifier: string;
url: string;
message?: string;
}
interface ErrorResult {
status: "error";
message: string;
}
type CreateLinearIssueResult = InterruptResult | SuccessResult | ErrorResult;
function isInterruptResult(result: unknown): result is InterruptResult {
return (
typeof result === "object" &&
result !== null &&
"__interrupt__" in result &&
(result as InterruptResult).__interrupt__ === true
);
}
function isErrorResult(result: unknown): result is ErrorResult {
return (
typeof result === "object" &&
result !== null &&
"status" in result &&
(result as ErrorResult).status === "error"
);
}
function ApprovalCard({
args,
interruptData,
onDecision,
}: {
args: { title: string; description?: string };
interruptData: InterruptResult;
onDecision: (decision: {
type: "approve" | "reject" | "edit";
message?: string;
edited_action?: { name: string; args: Record<string, unknown> };
}) => void;
}) {
const [decided, setDecided] = useState<"approve" | "reject" | "edit" | null>(
interruptData.__decided__ ?? null
);
const [isEditing, setIsEditing] = useState(false);
const [editedTitle, setEditedTitle] = useState(args.title ?? "");
const [editedDescription, setEditedDescription] = useState(args.description ?? "");
const [selectedWorkspaceId, setSelectedWorkspaceId] = useState("");
const [selectedTeamId, setSelectedTeamId] = useState("");
const [selectedStateId, setSelectedStateId] = useState("__none__");
const [selectedAssigneeId, setSelectedAssigneeId] = useState("__none__");
const [selectedPriority, setSelectedPriority] = useState("0");
const [selectedLabelIds, setSelectedLabelIds] = useState<string[]>([]);
const workspaces = interruptData.context?.workspaces ?? [];
const selectedWorkspace = useMemo(
() => workspaces.find((w) => String(w.id) === selectedWorkspaceId) ?? null,
[workspaces, selectedWorkspaceId]
);
const selectedTeam = useMemo(
() => selectedWorkspace?.teams.find((t) => t.id === selectedTeamId) ?? null,
[selectedWorkspace, selectedTeamId]
);
const isTitleValid = editedTitle.trim().length > 0;
const canApprove = !!selectedWorkspaceId && !!selectedTeamId && isTitleValid;
const reviewConfig = interruptData.review_configs[0];
const allowedDecisions = reviewConfig?.allowed_decisions ?? ["approve", "reject"];
const canEdit = allowedDecisions.includes("edit");
function buildFinalArgs() {
return {
title: editedTitle,
description: editedDescription || null,
connector_id: selectedWorkspaceId ? Number(selectedWorkspaceId) : null,
team_id: selectedTeamId || null,
state_id: selectedStateId === "__none__" ? null : selectedStateId,
assignee_id: selectedAssigneeId === "__none__" ? null : selectedAssigneeId,
priority: Number(selectedPriority),
label_ids: selectedLabelIds,
};
}
return (
<div
className={`my-4 max-w-full overflow-hidden rounded-xl transition-all duration-300 ${
decided
? "border border-border bg-card shadow-sm"
: "border-2 border-foreground/20 bg-muted/30 dark:bg-muted/10 shadow-lg animate-pulse-subtle"
}`}
>
{/* Header */}
<div
className={`flex items-center gap-3 border-b ${
decided ? "border-border bg-card" : "border-foreground/15 bg-muted/40 dark:bg-muted/20"
} px-4 py-3`}
>
<div
className={`flex size-9 shrink-0 items-center justify-center rounded-lg ${
decided ? "bg-muted" : "bg-muted animate-pulse"
}`}
>
<AlertTriangleIcon
className={`size-4 ${decided ? "text-muted-foreground" : "text-foreground"}`}
/>
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-foreground">Create Linear Issue</p>
<p className="truncate text-xs text-muted-foreground">
{isEditing ? "You can edit the arguments below" : "Requires your approval to proceed"}
</p>
</div>
</div>
{/* Context section */}
{!decided && (
<div className="border-b border-border px-4 py-3 bg-muted/30 space-y-3">
{interruptData.context?.error ? (
<p className="text-sm text-destructive">{interruptData.context.error}</p>
) : (
<>
{workspaces.length > 0 && (
<div className="space-y-1.5">
<div className="text-xs font-medium text-muted-foreground">
Linear Account <span className="text-destructive">*</span>
</div>
<Select
value={selectedWorkspaceId}
onValueChange={(v) => {
setSelectedWorkspaceId(v);
setSelectedTeamId("");
setSelectedStateId("__none__");
setSelectedAssigneeId("__none__");
setSelectedPriority("0");
setSelectedLabelIds([]);
}}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select an account" />
</SelectTrigger>
<SelectContent>
{workspaces.map((w) => (
<SelectItem key={w.id} value={String(w.id)}>
{w.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{selectedWorkspace && (
<>
<div className="space-y-1.5">
<div className="text-xs font-medium text-muted-foreground">
Team <span className="text-destructive">*</span>
</div>
<Select
value={selectedTeamId}
onValueChange={(v) => {
setSelectedTeamId(v);
const newTeam = selectedWorkspace.teams.find((t) => t.id === v);
setSelectedStateId(newTeam?.states?.[0]?.id ?? "__none__");
setSelectedAssigneeId("__none__");
setSelectedLabelIds([]);
}}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select a team" />
</SelectTrigger>
<SelectContent>
{selectedWorkspace.teams.map((t) => (
<SelectItem key={t.id} value={t.id}>
{t.name} ({t.key})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{selectedTeam && (
<>
<div className="space-y-1.5">
<div className="text-xs font-medium text-muted-foreground">State</div>
<Select value={selectedStateId} onValueChange={setSelectedStateId}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Default" />
</SelectTrigger>
<SelectContent>
{selectedTeam.states.map((s) => (
<SelectItem key={s.id} value={s.id}>
{s.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<div className="text-xs font-medium text-muted-foreground">Assignee</div>
<Select value={selectedAssigneeId} onValueChange={setSelectedAssigneeId}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Unassigned" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__">Unassigned</SelectItem>
{selectedTeam.members
.filter((m) => m.active)
.map((m) => (
<SelectItem key={m.id} value={m.id}>
{m.name} ({m.email})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<div className="text-xs font-medium text-muted-foreground">Priority</div>
<Select value={selectedPriority} onValueChange={setSelectedPriority}>
<SelectTrigger className="w-full">
<SelectValue placeholder="No priority" />
</SelectTrigger>
<SelectContent>
{selectedWorkspace.priorities.map((p) => (
<SelectItem key={p.priority} value={String(p.priority)}>
{p.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{selectedTeam.labels.length > 0 && (
<div className="space-y-1.5">
<div className="text-xs font-medium text-muted-foreground">Labels</div>
<div className="flex flex-wrap gap-1.5">
{selectedTeam.labels.map((label) => {
const isSelected = selectedLabelIds.includes(label.id);
return (
<button
key={label.id}
type="button"
onClick={() =>
setSelectedLabelIds((prev) =>
isSelected
? prev.filter((id) => id !== label.id)
: [...prev, label.id]
)
}
className={`inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-xs font-medium transition-opacity ${
isSelected
? "opacity-100 ring-2 ring-foreground/30"
: "opacity-50 hover:opacity-80"
}`}
style={{
backgroundColor: `${label.color}33`,
color: label.color,
}}
>
<span
className="size-1.5 rounded-full"
style={{ backgroundColor: label.color }}
/>
{label.name}
</button>
);
})}
</div>
</div>
)}
</>
)}
</>
)}
</>
)}
</div>
)}
{/* Display mode */}
{!isEditing && (
<div className="space-y-2 px-4 py-3 bg-card">
<div>
<p className="text-xs font-medium text-muted-foreground">Title</p>
<p className="text-sm text-foreground">{args.title}</p>
</div>
{args.description && (
<div>
<p className="text-xs font-medium text-muted-foreground">Description</p>
<p className="line-clamp-4 text-sm whitespace-pre-wrap text-foreground">
{args.description}
</p>
</div>
)}
</div>
)}
{/* Edit mode */}
{isEditing && !decided && (
<div className="space-y-3 px-4 py-3 bg-card">
<div>
<label
htmlFor="linear-title"
className="text-xs font-medium text-muted-foreground mb-1.5 block"
>
Title <span className="text-destructive">*</span>
</label>
<Input
id="linear-title"
value={editedTitle}
onChange={(e) => setEditedTitle(e.target.value)}
placeholder="Enter issue title"
className={!isTitleValid ? "border-destructive" : ""}
/>
{!isTitleValid && <p className="text-xs text-destructive mt-1">Title is required</p>}
</div>
<div>
<label
htmlFor="linear-description"
className="text-xs font-medium text-muted-foreground mb-1.5 block"
>
Description
</label>
<Textarea
id="linear-description"
value={editedDescription}
onChange={(e) => setEditedDescription(e.target.value)}
placeholder="Enter issue description (markdown supported)"
rows={5}
className="resize-none"
/>
</div>
</div>
)}
{/* Action buttons */}
<div
className={`flex items-center gap-2 border-t ${
decided ? "border-border bg-card" : "border-foreground/15 bg-muted/20 dark:bg-muted/10"
} px-4 py-3`}
>
{decided ? (
<p className="flex items-center gap-1.5 text-sm text-muted-foreground">
{decided === "approve" || decided === "edit" ? (
<>
<CheckIcon className="size-3.5 text-green-500" />
{decided === "edit" ? "Approved with Changes" : "Approved"}
</>
) : (
<>
<XIcon className="size-3.5 text-destructive" />
Rejected
</>
)}
</p>
) : isEditing ? (
<>
<Button
size="sm"
onClick={() => {
setDecided("edit");
setIsEditing(false);
onDecision({
type: "edit",
edited_action: {
name: interruptData.action_requests[0].name,
args: buildFinalArgs(),
},
});
}}
disabled={!canApprove}
>
<CheckIcon />
Approve with Changes
</Button>
<Button
size="sm"
variant="outline"
onClick={() => {
setIsEditing(false);
setEditedTitle(args.title ?? "");
setEditedDescription(args.description ?? "");
}}
>
Cancel
</Button>
</>
) : (
<>
{allowedDecisions.includes("approve") && (
<Button
size="sm"
onClick={() => {
setDecided("approve");
onDecision({
type: "approve",
edited_action: {
name: interruptData.action_requests[0].name,
args: buildFinalArgs(),
},
});
}}
disabled={!canApprove}
>
<CheckIcon />
Approve
</Button>
)}
{canEdit && (
<Button size="sm" variant="outline" onClick={() => setIsEditing(true)}>
<PencilIcon />
Edit
</Button>
)}
{allowedDecisions.includes("reject") && (
<Button
size="sm"
variant="outline"
onClick={() => {
setDecided("reject");
onDecision({ type: "reject", message: "User rejected the action." });
}}
>
<XIcon />
Reject
</Button>
)}
</>
)}
</div>
</div>
);
}
function ErrorCard({ result }: { result: ErrorResult }) {
return (
<div className="my-4 max-w-md overflow-hidden rounded-xl border border-destructive/50 bg-card">
<div className="flex items-center gap-3 border-b border-destructive/50 px-4 py-3">
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-destructive/10">
<XIcon className="size-4 text-destructive" />
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-destructive">Failed to create Linear issue</p>
</div>
</div>
<div className="px-4 py-3">
<p className="text-sm text-muted-foreground">{result.message}</p>
</div>
</div>
);
}
function SuccessCard({ result }: { result: SuccessResult }) {
return (
<div className="my-4 max-w-md overflow-hidden rounded-xl border border-border bg-card">
<div className="flex items-center gap-3 border-b border-border px-4 py-3">
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-green-500/10">
<CheckIcon className="size-4 text-green-500" />
</div>
<div className="min-w-0 flex-1">
<p className="text-[.8rem] text-muted-foreground">
{result.message || "Linear issue created successfully"}
</p>
</div>
</div>
<div className="space-y-2 px-4 py-3 text-xs">
<div>
<span className="font-medium text-muted-foreground">Identifier: </span>
<span>{result.identifier}</span>
</div>
{result.url && (
<div>
<a
href={result.url}
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
>
Open in Linear
</a>
</div>
)}
</div>
</div>
);
}
export const CreateLinearIssueToolUI = makeAssistantToolUI<
{ title: string; description?: string },
CreateLinearIssueResult
>({
toolName: "create_linear_issue",
render: function CreateLinearIssueUI({ args, result, status }) {
if (status.type === "running") {
return (
<div className="my-4 flex max-w-md items-center gap-3 rounded-xl border border-border bg-card px-4 py-3">
<Loader2Icon className="size-4 animate-spin text-muted-foreground" />
<p className="text-sm text-muted-foreground">Preparing Linear issue...</p>
</div>
);
}
if (!result) return null;
if (isInterruptResult(result)) {
return (
<ApprovalCard
args={args}
interruptData={result}
onDecision={(decision) => {
window.dispatchEvent(
new CustomEvent("hitl-decision", { detail: { decisions: [decision] } })
);
}}
/>
);
}
if (
typeof result === "object" &&
result !== null &&
"status" in result &&
(result as { status: string }).status === "rejected"
) {
return null;
}
if (isErrorResult(result)) return <ErrorCard result={result} />;
return <SuccessCard result={result as SuccessResult} />;
},
});

View file

@ -0,0 +1,395 @@
"use client";
import { makeAssistantToolUI } from "@assistant-ui/react";
import {
AlertTriangleIcon,
CheckIcon,
InfoIcon,
Loader2Icon,
TriangleAlertIcon,
XIcon,
} from "lucide-react";
import { useState } from "react";
import { Button } from "@/components/ui/button";
interface InterruptResult {
__interrupt__: true;
__decided__?: "approve" | "reject";
action_requests: Array<{
name: string;
args: Record<string, unknown>;
}>;
review_configs: Array<{
action_name: string;
allowed_decisions: Array<"approve" | "reject">;
}>;
interrupt_type?: string;
context?: {
workspace?: { id: number; organization_name: string };
issue?: {
id: string;
identifier: string;
title: string;
state?: string;
document_id?: number;
indexed_at?: string;
};
error?: string;
};
}
interface SuccessResult {
status: "success";
deleted_from_kb?: boolean;
message?: string;
}
interface ErrorResult {
status: "error";
message: string;
}
interface NotFoundResult {
status: "not_found";
message: string;
}
interface WarningResult {
status: "success";
warning: string;
message?: string;
}
type DeleteLinearIssueResult =
| InterruptResult
| SuccessResult
| ErrorResult
| NotFoundResult
| WarningResult;
function isInterruptResult(result: unknown): result is InterruptResult {
return (
typeof result === "object" &&
result !== null &&
"__interrupt__" in result &&
(result as InterruptResult).__interrupt__ === true
);
}
function isErrorResult(result: unknown): result is ErrorResult {
return (
typeof result === "object" &&
result !== null &&
"status" in result &&
(result as ErrorResult).status === "error"
);
}
function isNotFoundResult(result: unknown): result is NotFoundResult {
return (
typeof result === "object" &&
result !== null &&
"status" in result &&
(result as NotFoundResult).status === "not_found"
);
}
function isWarningResult(result: unknown): result is WarningResult {
return (
typeof result === "object" &&
result !== null &&
"status" in result &&
(result as WarningResult).status === "success" &&
"warning" in result &&
typeof (result as WarningResult).warning === "string"
);
}
function ApprovalCard({
interruptData,
onDecision,
}: {
interruptData: InterruptResult;
onDecision: (decision: {
type: "approve" | "reject";
message?: string;
edited_action?: { name: string; args: Record<string, unknown> };
}) => void;
}) {
const actionArgs = interruptData.action_requests[0]?.args ?? {};
const context = interruptData.context;
const issue = context?.issue;
const [decided, setDecided] = useState<"approve" | "reject" | null>(
interruptData.__decided__ ?? null
);
const [deleteFromKb, setDeleteFromKb] = useState(
typeof actionArgs.delete_from_kb === "boolean" ? actionArgs.delete_from_kb : false
);
return (
<div
className={`my-4 max-w-full overflow-hidden rounded-xl transition-all duration-300 ${
decided
? "border border-border bg-card shadow-sm"
: "border-2 border-foreground/20 bg-muted/30 dark:bg-muted/10 shadow-lg animate-pulse-subtle"
}`}
>
{/* Header */}
<div
className={`flex items-center gap-3 border-b ${
decided ? "border-border bg-card" : "border-foreground/15 bg-muted/40 dark:bg-muted/20"
} px-4 py-3`}
>
<div
className={`flex size-9 shrink-0 items-center justify-center rounded-lg ${
decided ? "bg-muted" : "bg-muted animate-pulse"
}`}
>
<AlertTriangleIcon
className={`size-4 ${decided ? "text-muted-foreground" : "text-foreground"}`}
/>
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-foreground">Delete Linear Issue</p>
<p className="truncate text-xs text-muted-foreground">
Requires your approval to proceed
</p>
</div>
</div>
{/* Context section — workspace + issue info (read-only) */}
{!decided && (
<div className="border-b border-border px-4 py-3 bg-muted/30 space-y-3">
{context?.error ? (
<p className="text-sm text-destructive">{context.error}</p>
) : (
<>
{context?.workspace && (
<div className="space-y-1">
<div className="text-xs font-medium text-muted-foreground">Linear Account</div>
<div className="w-full rounded-md border border-input bg-muted/50 px-3 py-2 text-sm">
{context.workspace.organization_name}
</div>
</div>
)}
{issue && (
<div className="space-y-1">
<div className="text-xs font-medium text-muted-foreground">Issue to Archive</div>
<div className="w-full rounded-md border border-input bg-muted/50 px-3 py-2 text-sm space-y-1">
<div className="font-medium">
{issue.identifier}: {issue.title}
</div>
{issue.state && (
<div className="text-xs text-muted-foreground">{issue.state}</div>
)}
</div>
</div>
)}
</>
)}
</div>
)}
{/* delete_from_kb toggle */}
{!decided && (
<div className="px-4 py-3 border-b border-border bg-muted/20">
<label className="flex items-start gap-2 cursor-pointer">
<input
type="checkbox"
checked={deleteFromKb}
onChange={(e) => setDeleteFromKb(e.target.checked)}
className="mt-0.5"
/>
<div className="flex-1">
<span className="text-sm text-foreground">Also remove from knowledge base</span>
<p className="text-xs text-muted-foreground mt-1">
This will permanently delete the issue from your knowledge base (cannot be undone)
</p>
</div>
</label>
</div>
)}
{/* Action buttons */}
<div
className={`flex items-center gap-2 border-t ${
decided ? "border-border bg-card" : "border-foreground/15 bg-muted/20 dark:bg-muted/10"
} px-4 py-3`}
>
{decided ? (
<p className="flex items-center gap-1.5 text-sm text-muted-foreground">
{decided === "approve" ? (
<>
<CheckIcon className="size-3.5 text-green-500" />
Approved
</>
) : (
<>
<XIcon className="size-3.5 text-destructive" />
Rejected
</>
)}
</p>
) : (
<>
<Button
size="sm"
onClick={() => {
setDecided("approve");
onDecision({
type: "approve",
edited_action: {
name: interruptData.action_requests[0].name,
args: {
issue_id: issue?.id,
connector_id: context?.workspace?.id,
delete_from_kb: deleteFromKb,
},
},
});
}}
>
<CheckIcon />
Approve
</Button>
<Button
size="sm"
variant="outline"
onClick={() => {
setDecided("reject");
onDecision({ type: "reject", message: "User rejected the action." });
}}
>
<XIcon />
Reject
</Button>
</>
)}
</div>
</div>
);
}
function ErrorCard({ result }: { result: ErrorResult }) {
return (
<div className="my-4 max-w-md overflow-hidden rounded-xl border border-destructive/50 bg-card">
<div className="flex items-center gap-3 border-b border-destructive/50 px-4 py-3">
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-destructive/10">
<XIcon className="size-4 text-destructive" />
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-destructive">Failed to delete Linear issue</p>
</div>
</div>
<div className="px-4 py-3">
<p className="text-sm text-muted-foreground">{result.message}</p>
</div>
</div>
);
}
function NotFoundCard({ result }: { result: NotFoundResult }) {
return (
<div className="my-4 max-w-md overflow-hidden rounded-xl border border-amber-500/50 bg-card">
<div className="flex items-start gap-3 px-4 py-3">
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-amber-500/10">
<InfoIcon className="size-4 text-amber-500" />
</div>
<div className="min-w-0 flex-1 pt-2">
<p className="text-sm text-muted-foreground">{result.message}</p>
</div>
</div>
</div>
);
}
function WarningCard({ result }: { result: WarningResult }) {
return (
<div className="my-4 max-w-md overflow-hidden rounded-xl border border-amber-500/50 bg-card">
<div className="flex items-center gap-3 border-b border-amber-500/50 px-4 py-3">
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-amber-500/10">
<TriangleAlertIcon className="size-4 text-amber-500" />
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-amber-600 dark:text-amber-500">Partial success</p>
</div>
</div>
<div className="px-4 py-3">
<p className="text-sm text-muted-foreground">{result.warning}</p>
</div>
</div>
);
}
function SuccessCard({ result }: { result: SuccessResult }) {
return (
<div className="my-4 max-w-md overflow-hidden rounded-xl border border-border bg-card">
<div className="flex items-center gap-3 border-b border-border px-4 py-3">
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-green-500/10">
<CheckIcon className="size-4 text-green-500" />
</div>
<div className="min-w-0 flex-1">
<p className="text-[.8rem] text-muted-foreground">
{result.message || "Linear issue archived successfully"}
</p>
</div>
</div>
{result.deleted_from_kb && (
<div className="px-4 py-3 text-xs">
<span className="text-green-600 dark:text-green-500">
Also removed from knowledge base
</span>
</div>
)}
</div>
);
}
export const DeleteLinearIssueToolUI = makeAssistantToolUI<
{ issue_ref: string; delete_from_kb?: boolean },
DeleteLinearIssueResult
>({
toolName: "delete_linear_issue",
render: function DeleteLinearIssueUI({ result, status }) {
if (status.type === "running") {
return (
<div className="my-4 flex max-w-md items-center gap-3 rounded-xl border border-border bg-card px-4 py-3">
<Loader2Icon className="size-4 animate-spin text-muted-foreground" />
<p className="text-sm text-muted-foreground">Preparing Linear issue deletion...</p>
</div>
);
}
if (!result) return null;
if (isInterruptResult(result)) {
return (
<ApprovalCard
interruptData={result}
onDecision={(decision) => {
window.dispatchEvent(
new CustomEvent("hitl-decision", { detail: { decisions: [decision] } })
);
}}
/>
);
}
if (
typeof result === "object" &&
result !== null &&
"status" in result &&
(result as { status: string }).status === "rejected"
) {
return null;
}
if (isNotFoundResult(result)) return <NotFoundCard result={result} />;
if (isWarningResult(result)) return <WarningCard result={result} />;
if (isErrorResult(result)) return <ErrorCard result={result} />;
return <SuccessCard result={result as SuccessResult} />;
},
});

View file

@ -0,0 +1,3 @@
export { CreateLinearIssueToolUI } from "./create-linear-issue";
export { DeleteLinearIssueToolUI } from "./delete-linear-issue";
export { UpdateLinearIssueToolUI } from "./update-linear-issue";

View file

@ -0,0 +1,765 @@
"use client";
import { makeAssistantToolUI } from "@assistant-ui/react";
import {
AlertTriangleIcon,
CheckIcon,
InfoIcon,
Loader2Icon,
PencilIcon,
XIcon,
} from "lucide-react";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
interface LinearLabel {
id: string;
name: string;
color: string;
}
interface LinearState {
id: string;
name: string;
type: string;
color: string;
}
interface LinearMember {
id: string;
name: string;
displayName: string;
email: string;
active: boolean;
}
interface LinearPriority {
priority: number;
label: string;
}
interface InterruptResult {
__interrupt__: true;
__decided__?: "approve" | "reject" | "edit";
action_requests: Array<{
name: string;
args: Record<string, unknown>;
}>;
review_configs: Array<{
action_name: string;
allowed_decisions: Array<"approve" | "edit" | "reject">;
}>;
interrupt_type?: string;
context?: {
workspace?: { id: number; organization_name: string };
priorities?: LinearPriority[];
issue?: {
id: string;
identifier: string;
title: string;
description?: string;
priority: number;
url: string;
current_state?: LinearState;
current_assignee?: { id: string; name: string; email: string } | null;
current_labels?: LinearLabel[];
team_id: string;
document_id: number;
};
team?: {
id: string;
name: string;
key: string;
states: LinearState[];
members: LinearMember[];
labels: LinearLabel[];
};
error?: string;
};
}
interface SuccessResult {
status: "success";
identifier: string;
url: string;
message?: string;
}
interface ErrorResult {
status: "error";
message: string;
}
interface NotFoundResult {
status: "not_found";
message: string;
}
type UpdateLinearIssueResult = InterruptResult | SuccessResult | ErrorResult | NotFoundResult;
function isInterruptResult(result: unknown): result is InterruptResult {
return (
typeof result === "object" &&
result !== null &&
"__interrupt__" in result &&
(result as InterruptResult).__interrupt__ === true
);
}
function isErrorResult(result: unknown): result is ErrorResult {
return (
typeof result === "object" &&
result !== null &&
"status" in result &&
(result as ErrorResult).status === "error"
);
}
function isNotFoundResult(result: unknown): result is NotFoundResult {
return (
typeof result === "object" &&
result !== null &&
"status" in result &&
(result as NotFoundResult).status === "not_found"
);
}
function ApprovalCard({
interruptData,
onDecision,
}: {
interruptData: InterruptResult;
onDecision: (decision: {
type: "approve" | "reject" | "edit";
message?: string;
edited_action?: { name: string; args: Record<string, unknown> };
}) => void;
}) {
const actionArgs = interruptData.action_requests[0]?.args ?? {};
const context = interruptData.context;
const team = context?.team;
const priorities = context?.priorities ?? [];
const issue = context?.issue;
const initialEditState = {
title: actionArgs.new_title ? String(actionArgs.new_title) : (issue?.title ?? ""),
description: actionArgs.new_description
? String(actionArgs.new_description)
: (issue?.description ?? ""),
stateId: actionArgs.new_state_id
? String(actionArgs.new_state_id)
: (issue?.current_state?.id ?? "__none__"),
assigneeId: actionArgs.new_assignee_id
? String(actionArgs.new_assignee_id)
: (issue?.current_assignee?.id ?? "__none__"),
priority:
actionArgs.new_priority != null
? String(actionArgs.new_priority)
: String(issue?.priority ?? 0),
labelIds: Array.isArray(actionArgs.new_label_ids)
? (actionArgs.new_label_ids as string[])
: (issue?.current_labels?.map((l) => l.id) ?? []),
};
const [decided, setDecided] = useState<"approve" | "reject" | "edit" | null>(
interruptData.__decided__ ?? null
);
const [isEditing, setIsEditing] = useState(false);
const [editedArgs, setEditedArgs] = useState(initialEditState);
const reviewConfig = interruptData.review_configs[0];
const allowedDecisions = reviewConfig?.allowed_decisions ?? ["approve", "reject"];
const canEdit = allowedDecisions.includes("edit");
function resolveStateName(stateId: string | null) {
if (!stateId || stateId === "__none__") return null;
return team?.states.find((s) => s.id === stateId)?.name ?? stateId;
}
function resolveAssigneeName(assigneeId: string | null) {
if (!assigneeId || assigneeId === "__none__") return null;
const m = team?.members.find((m) => m.id === assigneeId);
return m ? `${m.name} (${m.email})` : assigneeId;
}
function resolvePriorityLabel(p: string | null) {
if (!p || p === "__none__") return null;
return priorities.find((pr) => String(pr.priority) === p)?.label ?? p;
}
function resolveLabelNames(ids: string[]) {
return ids.map((id) => team?.labels.find((l) => l.id === id)).filter(Boolean) as LinearLabel[];
}
function buildFinalArgs() {
const labelsWereProposed = Array.isArray(actionArgs.new_label_ids);
return {
issue_id: issue?.id,
document_id: issue?.document_id,
connector_id: context?.workspace?.id,
new_title: editedArgs.title || null,
new_description: editedArgs.description || null,
new_state_id: editedArgs.stateId === "__none__" ? null : editedArgs.stateId,
new_assignee_id: editedArgs.assigneeId === "__none__" ? null : editedArgs.assigneeId,
new_priority: Number(editedArgs.priority),
new_label_ids:
labelsWereProposed || editedArgs.labelIds.length > 0 ? editedArgs.labelIds : null,
};
}
const proposedStateName = resolveStateName(
actionArgs.new_state_id ? String(actionArgs.new_state_id) : null
);
const proposedAssigneeName = resolveAssigneeName(
actionArgs.new_assignee_id ? String(actionArgs.new_assignee_id) : null
);
const proposedPriorityLabel = resolvePriorityLabel(
actionArgs.new_priority != null ? String(actionArgs.new_priority) : null
);
const proposedLabelObjects = resolveLabelNames(
Array.isArray(actionArgs.new_label_ids) ? (actionArgs.new_label_ids as string[]) : []
);
const hasProposedChanges =
actionArgs.new_title ||
actionArgs.new_description ||
proposedStateName ||
proposedAssigneeName ||
proposedPriorityLabel ||
proposedLabelObjects.length > 0;
return (
<div
className={`my-4 max-w-full overflow-hidden rounded-xl transition-all duration-300 ${
decided
? "border border-border bg-card shadow-sm"
: "border-2 border-foreground/20 bg-muted/30 dark:bg-muted/10 shadow-lg animate-pulse-subtle"
}`}
>
{/* Header */}
<div
className={`flex items-center gap-3 border-b ${
decided ? "border-border bg-card" : "border-foreground/15 bg-muted/40 dark:bg-muted/20"
} px-4 py-3`}
>
<div
className={`flex size-9 shrink-0 items-center justify-center rounded-lg ${
decided ? "bg-muted" : "bg-muted animate-pulse"
}`}
>
<AlertTriangleIcon
className={`size-4 ${decided ? "text-muted-foreground" : "text-foreground"}`}
/>
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-foreground">Update Linear Issue</p>
<p className="truncate text-xs text-muted-foreground">
{isEditing ? "You can edit the arguments below" : "Requires your approval to proceed"}
</p>
</div>
</div>
{/* Context section — workspace + current issue (read-only) */}
{!decided && (
<div className="border-b border-border px-4 py-3 bg-muted/30 space-y-3">
{context?.error ? (
<p className="text-sm text-destructive">{context.error}</p>
) : (
<>
{context?.workspace && (
<div className="space-y-1">
<div className="text-xs font-medium text-muted-foreground">Linear Account</div>
<div className="w-full rounded-md border border-input bg-muted/50 px-3 py-2 text-sm">
{context.workspace.organization_name}
</div>
</div>
)}
{issue && (
<div className="space-y-1">
<div className="text-xs font-medium text-muted-foreground">Current Issue</div>
<div className="w-full rounded-md border border-input bg-muted/50 px-3 py-2 text-sm space-y-1.5">
<div className="font-medium">
{issue.identifier}: {issue.title}
</div>
<div className="flex flex-wrap gap-2 text-xs text-muted-foreground">
{issue.current_state && (
<span
className="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium"
style={{
backgroundColor: `${issue.current_state.color}22`,
color: issue.current_state.color,
}}
>
{issue.current_state.name}
</span>
)}
{issue.current_assignee && <span>{issue.current_assignee.name}</span>}
{priorities.find((p) => p.priority === issue.priority) && (
<span>{priorities.find((p) => p.priority === issue.priority)?.label}</span>
)}
</div>
{issue.current_labels && issue.current_labels.length > 0 && (
<div className="flex flex-wrap gap-1">
{issue.current_labels.map((label) => (
<span
key={label.id}
className="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium"
style={{
backgroundColor: `${label.color}22`,
color: label.color,
}}
>
{label.name}
</span>
))}
</div>
)}
{issue.url && (
<a
href={issue.url}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-primary hover:underline"
>
Open in Linear
</a>
)}
</div>
</div>
)}
</>
)}
</div>
)}
{/* Display mode — proposed changes */}
{!isEditing && (
<div className="space-y-2 px-4 py-3 bg-card">
{hasProposedChanges ? (
<>
{actionArgs.new_title && (
<div>
<p className="text-xs font-medium text-muted-foreground">New Title</p>
<p className="text-sm text-foreground">{String(actionArgs.new_title)}</p>
</div>
)}
{actionArgs.new_description && (
<div>
<p className="text-xs font-medium text-muted-foreground">New Description</p>
<p className="line-clamp-4 text-sm whitespace-pre-wrap text-foreground">
{String(actionArgs.new_description)}
</p>
</div>
)}
{proposedStateName && (
<div>
<p className="text-xs font-medium text-muted-foreground">New State</p>
<p className="text-sm text-foreground">{proposedStateName}</p>
</div>
)}
{proposedAssigneeName && (
<div>
<p className="text-xs font-medium text-muted-foreground">New Assignee</p>
<p className="text-sm text-foreground">{proposedAssigneeName}</p>
</div>
)}
{proposedPriorityLabel && (
<div>
<p className="text-xs font-medium text-muted-foreground">New Priority</p>
<p className="text-sm text-foreground">{proposedPriorityLabel}</p>
</div>
)}
{proposedLabelObjects.length > 0 && (
<div>
<p className="text-xs font-medium text-muted-foreground">New Labels</p>
<div className="flex flex-wrap gap-1 mt-1">
{proposedLabelObjects.map((label) => (
<span
key={label.id}
className="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium"
style={{
backgroundColor: `${label.color}33`,
color: label.color,
}}
>
{label.name}
</span>
))}
</div>
</div>
)}
</>
) : (
<p className="text-sm text-muted-foreground italic">No changes proposed</p>
)}
</div>
)}
{/* Edit mode */}
{isEditing && !decided && (
<div className="space-y-3 px-4 py-3 bg-card">
<div>
<label
htmlFor="linear-update-title"
className="text-xs font-medium text-muted-foreground mb-1.5 block"
>
New Title
</label>
<Input
id="linear-update-title"
value={editedArgs.title}
onChange={(e) => setEditedArgs({ ...editedArgs, title: e.target.value })}
placeholder="Issue title"
/>
</div>
<div>
<label
htmlFor="linear-update-description"
className="text-xs font-medium text-muted-foreground mb-1.5 block"
>
Description
</label>
<Textarea
id="linear-update-description"
value={editedArgs.description}
onChange={(e) => setEditedArgs({ ...editedArgs, description: e.target.value })}
placeholder="Issue description"
rows={5}
className="resize-none"
/>
</div>
{team && (
<>
<div className="space-y-1.5">
<div className="text-xs font-medium text-muted-foreground">State</div>
<Select
value={editedArgs.stateId}
onValueChange={(v) => setEditedArgs({ ...editedArgs, stateId: v })}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select state" />
</SelectTrigger>
<SelectContent>
{team.states.map((s) => (
<SelectItem key={s.id} value={s.id}>
{s.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<div className="text-xs font-medium text-muted-foreground">Assignee</div>
<Select
value={editedArgs.assigneeId}
onValueChange={(v) => setEditedArgs({ ...editedArgs, assigneeId: v })}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select assignee" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__">Unassigned</SelectItem>
{team.members
.filter((m) => m.active)
.map((m) => (
<SelectItem key={m.id} value={m.id}>
{m.name} ({m.email})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<div className="text-xs font-medium text-muted-foreground">Priority</div>
<Select
value={editedArgs.priority}
onValueChange={(v) => setEditedArgs({ ...editedArgs, priority: v })}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select priority" />
</SelectTrigger>
<SelectContent>
{priorities.map((p) => (
<SelectItem key={p.priority} value={String(p.priority)}>
{p.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{team.labels.length > 0 && (
<div className="space-y-1.5">
<div className="text-xs font-medium text-muted-foreground">Labels</div>
<div className="flex flex-wrap gap-1.5">
{team.labels.map((label) => {
const isSelected = editedArgs.labelIds.includes(label.id);
return (
<button
key={label.id}
type="button"
onClick={() =>
setEditedArgs({
...editedArgs,
labelIds: isSelected
? editedArgs.labelIds.filter((id) => id !== label.id)
: [...editedArgs.labelIds, label.id],
})
}
className={`inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-xs font-medium transition-opacity ${
isSelected
? "opacity-100 ring-2 ring-foreground/30"
: "opacity-50 hover:opacity-80"
}`}
style={{
backgroundColor: `${label.color}33`,
color: label.color,
}}
>
<span
className="size-1.5 rounded-full"
style={{ backgroundColor: label.color }}
/>
{label.name}
</button>
);
})}
</div>
</div>
)}
</>
)}
</div>
)}
{/* Action buttons */}
<div
className={`flex items-center gap-2 border-t ${
decided ? "border-border bg-card" : "border-foreground/15 bg-muted/20 dark:bg-muted/10"
} px-4 py-3`}
>
{decided ? (
<p className="flex items-center gap-1.5 text-sm text-muted-foreground">
{decided === "approve" || decided === "edit" ? (
<>
<CheckIcon className="size-3.5 text-green-500" />
{decided === "edit" ? "Approved with Changes" : "Approved"}
</>
) : (
<>
<XIcon className="size-3.5 text-destructive" />
Rejected
</>
)}
</p>
) : isEditing ? (
<>
<Button
size="sm"
onClick={() => {
setDecided("edit");
setIsEditing(false);
onDecision({
type: "edit",
edited_action: {
name: interruptData.action_requests[0].name,
args: buildFinalArgs(),
},
});
}}
>
<CheckIcon />
Approve with Changes
</Button>
<Button
size="sm"
variant="outline"
onClick={() => {
setIsEditing(false);
setEditedArgs(initialEditState); // Reset to original args
}}
>
Cancel
</Button>
</>
) : (
<>
{allowedDecisions.includes("approve") && (
<Button
size="sm"
onClick={() => {
setDecided("approve");
onDecision({
type: "approve",
edited_action: {
name: interruptData.action_requests[0].name,
args: buildFinalArgs(),
},
});
}}
>
<CheckIcon />
Approve
</Button>
)}
{canEdit && (
<Button size="sm" variant="outline" onClick={() => setIsEditing(true)}>
<PencilIcon />
Edit
</Button>
)}
{allowedDecisions.includes("reject") && (
<Button
size="sm"
variant="outline"
onClick={() => {
setDecided("reject");
onDecision({ type: "reject", message: "User rejected the action." });
}}
>
<XIcon />
Reject
</Button>
)}
</>
)}
</div>
</div>
);
}
function ErrorCard({ result }: { result: ErrorResult }) {
return (
<div className="my-4 max-w-md overflow-hidden rounded-xl border border-destructive/50 bg-card">
<div className="flex items-center gap-3 border-b border-destructive/50 px-4 py-3">
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-destructive/10">
<XIcon className="size-4 text-destructive" />
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-destructive">Failed to update Linear issue</p>
</div>
</div>
<div className="px-4 py-3">
<p className="text-sm text-muted-foreground">{result.message}</p>
</div>
</div>
);
}
function NotFoundCard({ result }: { result: NotFoundResult }) {
return (
<div className="my-4 max-w-md overflow-hidden rounded-xl border border-amber-500/50 bg-card">
<div className="flex items-start gap-3 px-4 py-3">
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-amber-500/10">
<InfoIcon className="size-4 text-amber-500" />
</div>
<div className="min-w-0 flex-1 pt-2">
<p className="text-sm text-muted-foreground">{result.message}</p>
</div>
</div>
</div>
);
}
function SuccessCard({ result }: { result: SuccessResult }) {
return (
<div className="my-4 max-w-md overflow-hidden rounded-xl border border-border bg-card">
<div className="flex items-center gap-3 border-b border-border px-4 py-3">
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-green-500/10">
<CheckIcon className="size-4 text-green-500" />
</div>
<div className="min-w-0 flex-1">
<p className="text-[.8rem] text-muted-foreground">
{result.message || "Linear issue updated successfully"}
</p>
</div>
</div>
<div className="space-y-2 px-4 py-3 text-xs">
<div>
<span className="font-medium text-muted-foreground">Identifier: </span>
<span>{result.identifier}</span>
</div>
{result.url && (
<div>
<a
href={result.url}
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
>
Open in Linear
</a>
</div>
)}
</div>
</div>
);
}
export const UpdateLinearIssueToolUI = makeAssistantToolUI<
{
issue_ref: string;
new_title?: string;
new_description?: string;
new_state_name?: string;
new_assignee_email?: string;
new_priority?: number;
new_label_names?: string[];
},
UpdateLinearIssueResult
>({
toolName: "update_linear_issue",
render: function UpdateLinearIssueUI({ result, status }) {
if (status.type === "running") {
return (
<div className="my-4 flex max-w-md items-center gap-3 rounded-xl border border-border bg-card px-4 py-3">
<Loader2Icon className="size-4 animate-spin text-muted-foreground" />
<p className="text-sm text-muted-foreground">Preparing Linear issue update...</p>
</div>
);
}
if (!result) return null;
if (isInterruptResult(result)) {
return (
<ApprovalCard
interruptData={result}
onDecision={(decision) => {
window.dispatchEvent(
new CustomEvent("hitl-decision", { detail: { decisions: [decision] } })
);
}}
/>
);
}
if (
typeof result === "object" &&
result !== null &&
"status" in result &&
(result as { status: string }).status === "rejected"
) {
return null;
}
if (isNotFoundResult(result)) return <NotFoundCard result={result} />;
if (isErrorResult(result)) return <ErrorCard result={result} />;
return <SuccessCard result={result as SuccessResult} />;
},
});

View file

@ -47,7 +47,7 @@ interface SuccessResult {
page_id: string; page_id: string;
title?: string; title?: string;
message?: string; message?: string;
deleted_from_db?: boolean; deleted_from_kb?: boolean;
} }
interface ErrorResult { interface ErrorResult {
@ -129,7 +129,7 @@ function ApprovalCard({
const [decided, setDecided] = useState<"approve" | "reject" | null>( const [decided, setDecided] = useState<"approve" | "reject" | null>(
interruptData.__decided__ ?? null interruptData.__decided__ ?? null
); );
const [deleteFromDb, setDeleteFromDb] = useState(false); const [deleteFromKb, setDeleteFromKb] = useState(false);
const account = interruptData.context?.account; const account = interruptData.context?.account;
const currentTitle = interruptData.context?.current_title; const currentTitle = interruptData.context?.current_title;
@ -203,8 +203,8 @@ function ApprovalCard({
<label className="flex items-start gap-2 cursor-pointer"> <label className="flex items-start gap-2 cursor-pointer">
<input <input
type="checkbox" type="checkbox"
checked={deleteFromDb} checked={deleteFromKb}
onChange={(e) => setDeleteFromDb(e.target.checked)} onChange={(e) => setDeleteFromKb(e.target.checked)}
className="mt-0.5" className="mt-0.5"
/> />
<div className="flex-1"> <div className="flex-1">
@ -249,7 +249,7 @@ function ApprovalCard({
args: { args: {
page_id: interruptData.context?.page_id, page_id: interruptData.context?.page_id,
connector_id: account?.id, connector_id: account?.id,
delete_from_db: deleteFromDb, delete_from_kb: deleteFromKb,
}, },
}, },
}); });
@ -346,7 +346,7 @@ function SuccessCard({ result }: { result: SuccessResult }) {
</p> </p>
</div> </div>
</div> </div>
{(result.deleted_from_db || result.title) && ( {(result.deleted_from_kb || result.title) && (
<div className="space-y-2 px-4 py-3 text-xs"> <div className="space-y-2 px-4 py-3 text-xs">
{result.title && ( {result.title && (
<div> <div>
@ -354,7 +354,7 @@ function SuccessCard({ result }: { result: SuccessResult }) {
<span>{result.title}</span> <span>{result.title}</span>
</div> </div>
)} )}
{result.deleted_from_db && ( {result.deleted_from_kb && (
<div className="pt-1"> <div className="pt-1">
<span className="text-green-600 dark:text-green-500"> <span className="text-green-600 dark:text-green-500">
Also removed from knowledge base Also removed from knowledge base
@ -368,7 +368,7 @@ function SuccessCard({ result }: { result: SuccessResult }) {
} }
export const DeleteNotionPageToolUI = makeAssistantToolUI< export const DeleteNotionPageToolUI = makeAssistantToolUI<
{ page_title: string; delete_from_db?: boolean }, { page_title: string; delete_from_kb?: boolean },
DeleteNotionPageResult DeleteNotionPageResult
>({ >({
toolName: "delete_notion_page", toolName: "delete_notion_page",

View file

@ -0,0 +1,3 @@
export { CreateNotionPageToolUI } from "./create-notion-page";
export { DeleteNotionPageToolUI } from "./delete-notion-page";
export { UpdateNotionPageToolUI } from "./update-notion-page";

View file

@ -195,7 +195,7 @@ function ApprovalCard({
<div className="space-y-2"> <div className="space-y-2">
<div className="text-xs font-medium text-muted-foreground">Notion Account</div> <div className="text-xs font-medium text-muted-foreground">Notion Account</div>
<div className="w-full rounded-md border border-input bg-muted/50 px-3 py-2 text-sm"> <div className="w-full rounded-md border border-input bg-muted/50 px-3 py-2 text-sm">
{account.workspace_icon} {account.workspace_name} {account.workspace_name}
</div> </div>
</div> </div>
)} )}