diff --git a/autogpt_platform/backend/backend/api/features/chat/tools/__init__.py b/autogpt_platform/backend/backend/api/features/chat/tools/__init__.py index dcbc35ef37..350776081a 100644 --- a/autogpt_platform/backend/backend/api/features/chat/tools/__init__.py +++ b/autogpt_platform/backend/backend/api/features/chat/tools/__init__.py @@ -12,6 +12,7 @@ from .base import BaseTool from .create_agent import CreateAgentTool from .customize_agent import CustomizeAgentTool from .edit_agent import EditAgentTool +from .feature_requests import CreateFeatureRequestTool, SearchFeatureRequestsTool from .find_agent import FindAgentTool from .find_block import FindBlockTool from .find_library_agent import FindLibraryAgentTool @@ -45,6 +46,9 @@ TOOL_REGISTRY: dict[str, BaseTool] = { "view_agent_output": AgentOutputTool(), "search_docs": SearchDocsTool(), "get_doc_page": GetDocPageTool(), + # Feature request tools + "search_feature_requests": SearchFeatureRequestsTool(), + "create_feature_request": CreateFeatureRequestTool(), # Workspace tools for CoPilot file operations "list_workspace_files": ListWorkspaceFilesTool(), "read_workspace_file": ReadWorkspaceFileTool(), diff --git a/autogpt_platform/backend/backend/api/features/chat/tools/feature_requests.py b/autogpt_platform/backend/backend/api/features/chat/tools/feature_requests.py new file mode 100644 index 0000000000..5e06d8b4b2 --- /dev/null +++ b/autogpt_platform/backend/backend/api/features/chat/tools/feature_requests.py @@ -0,0 +1,369 @@ +"""Feature request tools - search and create feature requests via Linear.""" + +import logging +from typing import Any + +from pydantic import SecretStr + +from backend.api.features.chat.model import ChatSession +from backend.api.features.chat.tools.base import BaseTool +from backend.api.features.chat.tools.models import ( + ErrorResponse, + FeatureRequestCreatedResponse, + FeatureRequestInfo, + FeatureRequestSearchResponse, + NoResultsResponse, + ToolResponseBase, +) +from backend.blocks.linear._api import LinearClient +from backend.data.model import APIKeyCredentials +from backend.util.settings import Settings + +logger = logging.getLogger(__name__) + +# Target project and team IDs in our Linear workspace +FEATURE_REQUEST_PROJECT_ID = "13f066f3-f639-4a67-aaa3-31483ebdf8cd" +TEAM_ID = "557fd3d5-087e-43a9-83e3-476c8313ce49" + +MAX_SEARCH_RESULTS = 10 + +# GraphQL queries/mutations +SEARCH_ISSUES_QUERY = """ +query SearchFeatureRequests($term: String!, $filter: IssueFilter, $first: Int) { + searchIssues(term: $term, filter: $filter, first: $first) { + nodes { + id + identifier + title + description + } + } +} +""" + +CUSTOMER_UPSERT_MUTATION = """ +mutation CustomerUpsert($input: CustomerUpsertInput!) { + customerUpsert(input: $input) { + success + customer { + id + name + externalIds + } + } +} +""" + +ISSUE_CREATE_MUTATION = """ +mutation IssueCreate($input: IssueCreateInput!) { + issueCreate(input: $input) { + success + issue { + id + identifier + title + url + } + } +} +""" + +CUSTOMER_NEED_CREATE_MUTATION = """ +mutation CustomerNeedCreate($input: CustomerNeedCreateInput!) { + customerNeedCreate(input: $input) { + success + need { + id + body + customer { + id + name + } + issue { + id + identifier + title + url + } + } + } +} +""" + + +_settings: Settings | None = None + + +def _get_settings() -> Settings: + global _settings + if _settings is None: + _settings = Settings() + return _settings + + +def _get_linear_client() -> LinearClient: + """Create a Linear client using the system API key from settings.""" + api_key = _get_settings().secrets.linear_api_key + if not api_key: + raise RuntimeError("LINEAR_API_KEY secret is not configured") + credentials = APIKeyCredentials( + id="system-linear", + provider="linear", + api_key=SecretStr(api_key), + title="System Linear API Key", + ) + return LinearClient(credentials=credentials) + + +class SearchFeatureRequestsTool(BaseTool): + """Tool for searching existing feature requests in Linear.""" + + @property + def name(self) -> str: + return "search_feature_requests" + + @property + def description(self) -> str: + return ( + "Search existing feature requests to check if a similar request " + "already exists before creating a new one. Returns matching feature " + "requests with their ID, title, and description." + ) + + @property + def parameters(self) -> dict[str, Any]: + return { + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "Search term to find matching feature requests.", + }, + }, + "required": ["query"], + } + + @property + def requires_auth(self) -> bool: + return True + + async def _execute( + self, + user_id: str | None, + session: ChatSession, + **kwargs, + ) -> ToolResponseBase: + query = kwargs.get("query", "").strip() + session_id = session.session_id if session else None + + if not query: + return ErrorResponse( + message="Please provide a search query.", + error="Missing query parameter", + session_id=session_id, + ) + + client = _get_linear_client() + data = await client.query( + SEARCH_ISSUES_QUERY, + { + "term": query, + "filter": { + "project": {"id": {"eq": FEATURE_REQUEST_PROJECT_ID}}, + }, + "first": MAX_SEARCH_RESULTS, + }, + ) + + nodes = data.get("searchIssues", {}).get("nodes", []) + + if not nodes: + return NoResultsResponse( + message=f"No feature requests found matching '{query}'.", + suggestions=[ + "Try different keywords", + "Use broader search terms", + "You can create a new feature request if none exists", + ], + session_id=session_id, + ) + + results = [ + FeatureRequestInfo( + id=node["id"], + identifier=node["identifier"], + title=node["title"], + description=node.get("description"), + ) + for node in nodes + ] + + return FeatureRequestSearchResponse( + message=f"Found {len(results)} feature request(s) matching '{query}'.", + results=results, + count=len(results), + query=query, + session_id=session_id, + ) + + +class CreateFeatureRequestTool(BaseTool): + """Tool for creating feature requests (or adding needs to existing ones).""" + + @property + def name(self) -> str: + return "create_feature_request" + + @property + def description(self) -> str: + return ( + "Create a new feature request or add a customer need to an existing one. " + "Always search first with search_feature_requests to avoid duplicates. " + "If a matching request exists, pass its ID as existing_issue_id to add " + "the user's need to it instead of creating a duplicate." + ) + + @property + def parameters(self) -> dict[str, Any]: + return { + "type": "object", + "properties": { + "title": { + "type": "string", + "description": "Title for the feature request.", + }, + "description": { + "type": "string", + "description": "Detailed description of what the user wants and why.", + }, + "existing_issue_id": { + "type": "string", + "description": ( + "If adding a need to an existing feature request, " + "provide its Linear issue ID (from search results). " + "Omit to create a new feature request." + ), + }, + }, + "required": ["title", "description"], + } + + @property + def requires_auth(self) -> bool: + return True + + async def _find_or_create_customer( + self, client: LinearClient, user_id: str + ) -> dict: + """Find existing customer by user_id or create a new one via upsert.""" + data = await client.mutate( + CUSTOMER_UPSERT_MUTATION, + { + "input": { + "name": user_id, + "externalId": user_id, + }, + }, + ) + result = data.get("customerUpsert", {}) + if not result.get("success"): + raise RuntimeError(f"Failed to upsert customer: {data}") + return result["customer"] + + async def _execute( + self, + user_id: str | None, + session: ChatSession, + **kwargs, + ) -> ToolResponseBase: + title = kwargs.get("title", "").strip() + description = kwargs.get("description", "").strip() + existing_issue_id = kwargs.get("existing_issue_id") + session_id = session.session_id if session else None + + if not title or not description: + return ErrorResponse( + message="Both title and description are required.", + error="Missing required parameters", + session_id=session_id, + ) + + if not user_id: + return ErrorResponse( + message="Authentication required to create feature requests.", + error="Missing user_id", + session_id=session_id, + ) + + client = _get_linear_client() + + # Step 1: Find or create customer for this user + customer = await self._find_or_create_customer(client, user_id) + customer_id = customer["id"] + customer_name = customer["name"] + + # Step 2: Create or reuse issue + if existing_issue_id: + # Add need to existing issue - we still need the issue details for response + is_new_issue = False + issue_id = existing_issue_id + else: + # Create new issue in the feature requests project + data = await client.mutate( + ISSUE_CREATE_MUTATION, + { + "input": { + "title": title, + "description": description, + "teamId": TEAM_ID, + "projectId": FEATURE_REQUEST_PROJECT_ID, + }, + }, + ) + result = data.get("issueCreate", {}) + if not result.get("success"): + return ErrorResponse( + message="Failed to create feature request issue.", + error=str(data), + session_id=session_id, + ) + issue = result["issue"] + issue_id = issue["id"] + is_new_issue = True + + # Step 3: Create customer need on the issue + data = await client.mutate( + CUSTOMER_NEED_CREATE_MUTATION, + { + "input": { + "customerId": customer_id, + "issueId": issue_id, + "body": description, + "priority": 0, + }, + }, + ) + need_result = data.get("customerNeedCreate", {}) + if not need_result.get("success"): + return ErrorResponse( + message="Failed to attach customer need to the feature request.", + error=str(data), + session_id=session_id, + ) + + need = need_result["need"] + issue_info = need["issue"] + + return FeatureRequestCreatedResponse( + message=( + f"{'Created new feature request' if is_new_issue else 'Added your request to existing feature request'} " + f"[{issue_info['identifier']}] {issue_info['title']}." + ), + issue_id=issue_info["id"], + issue_identifier=issue_info["identifier"], + issue_title=issue_info["title"], + issue_url=issue_info.get("url", ""), + is_new_issue=is_new_issue, + customer_name=customer_name, + session_id=session_id, + ) diff --git a/autogpt_platform/backend/backend/api/features/chat/tools/models.py b/autogpt_platform/backend/backend/api/features/chat/tools/models.py index 69c8c6c684..d420b289dc 100644 --- a/autogpt_platform/backend/backend/api/features/chat/tools/models.py +++ b/autogpt_platform/backend/backend/api/features/chat/tools/models.py @@ -40,6 +40,9 @@ class ResponseType(str, Enum): OPERATION_IN_PROGRESS = "operation_in_progress" # Input validation INPUT_VALIDATION_ERROR = "input_validation_error" + # Feature request types + FEATURE_REQUEST_SEARCH = "feature_request_search" + FEATURE_REQUEST_CREATED = "feature_request_created" # Base response model @@ -421,3 +424,34 @@ class AsyncProcessingResponse(ToolResponseBase): status: str = "accepted" # Must be "accepted" for detection operation_id: str | None = None task_id: str | None = None + + +# Feature request models +class FeatureRequestInfo(BaseModel): + """Information about a feature request issue.""" + + id: str + identifier: str + title: str + description: str | None = None + + +class FeatureRequestSearchResponse(ToolResponseBase): + """Response for search_feature_requests tool.""" + + type: ResponseType = ResponseType.FEATURE_REQUEST_SEARCH + results: list[FeatureRequestInfo] + count: int + query: str + + +class FeatureRequestCreatedResponse(ToolResponseBase): + """Response for create_feature_request tool.""" + + type: ResponseType = ResponseType.FEATURE_REQUEST_CREATED + issue_id: str + issue_identifier: str + issue_title: str + issue_url: str + is_new_issue: bool # False if added to existing + customer_name: str diff --git a/autogpt_platform/backend/backend/util/settings.py b/autogpt_platform/backend/backend/util/settings.py index 50b7428160..d539832fb0 100644 --- a/autogpt_platform/backend/backend/util/settings.py +++ b/autogpt_platform/backend/backend/util/settings.py @@ -658,6 +658,9 @@ class Secrets(UpdateTrackingModel["Secrets"], BaseSettings): mem0_api_key: str = Field(default="", description="Mem0 API key") elevenlabs_api_key: str = Field(default="", description="ElevenLabs API key") + linear_api_key: str = Field( + default="", description="Linear API key for system-level operations" + ) linear_client_id: str = Field(default="", description="Linear client ID") linear_client_secret: str = Field(default="", description="Linear client secret") diff --git a/autogpt_platform/backend/test_linear_customers.py b/autogpt_platform/backend/test_linear_customers.py new file mode 100644 index 0000000000..6e6f3e48fc --- /dev/null +++ b/autogpt_platform/backend/test_linear_customers.py @@ -0,0 +1,468 @@ +""" +Test script for Linear GraphQL API - Customer Requests operations. + +Tests the exact GraphQL calls needed for: +1. search_feature_requests - search issues in the Customer Feature Requests project +2. add_feature_request - upsert customer + create customer need on issue + +Requires LINEAR_API_KEY in backend/.env +Generate one at: https://linear.app/settings/api +""" + +import json +import os +import sys + +import httpx +from dotenv import load_dotenv + +load_dotenv() + +LINEAR_API_URL = "https://api.linear.app/graphql" +API_KEY = os.getenv("LINEAR_API_KEY") + +# Target project for feature requests +FEATURE_REQUEST_PROJECT_ID = "13f066f3-f639-4a67-aaa3-31483ebdf8cd" +# Team: Internal +TEAM_ID = "557fd3d5-087e-43a9-83e3-476c8313ce49" + +if not API_KEY: + print("ERROR: LINEAR_API_KEY not found in .env") + print("Generate a personal API key at: https://linear.app/settings/api") + print("Then add LINEAR_API_KEY=lin_api_... to backend/.env") + sys.exit(1) + +HEADERS = { + "Authorization": API_KEY, + "Content-Type": "application/json", +} + + +def graphql(query: str, variables: dict | None = None) -> dict: + """Execute a GraphQL query against Linear API.""" + payload = {"query": query} + if variables: + payload["variables"] = variables + + resp = httpx.post(LINEAR_API_URL, json=payload, headers=HEADERS, timeout=30) + if resp.status_code != 200: + print(f"HTTP {resp.status_code}: {resp.text[:500]}") + resp.raise_for_status() + data = resp.json() + + if "errors" in data: + print(f"GraphQL Errors: {json.dumps(data['errors'], indent=2)}") + + return data + + +# --------------------------------------------------------------------------- +# QUERIES +# --------------------------------------------------------------------------- + +# Search issues within the feature requests project by title/description +SEARCH_ISSUES_IN_PROJECT = """ +query SearchFeatureRequests($filter: IssueFilter!, $first: Int) { + issues(filter: $filter, first: $first) { + nodes { + id + identifier + title + description + url + state { + name + type + } + project { + id + name + } + labels { + nodes { + name + } + } + } + } +} +""" + +# Get issue with its customer needs +GET_ISSUE_WITH_NEEDS = """ +query GetIssueWithNeeds($id: String!) { + issue(id: $id) { + id + identifier + title + url + needs { + nodes { + id + body + priority + customer { + id + name + domains + externalIds + } + } + } + } +} +""" + +# Search customers +SEARCH_CUSTOMERS = """ +query SearchCustomers($filter: CustomerFilter, $first: Int) { + customers(filter: $filter, first: $first) { + nodes { + id + name + domains + externalIds + revenue + size + status { + name + } + tier { + name + } + } + } +} +""" + +# --------------------------------------------------------------------------- +# MUTATIONS +# --------------------------------------------------------------------------- + +CUSTOMER_UPSERT = """ +mutation CustomerUpsert($input: CustomerUpsertInput!) { + customerUpsert(input: $input) { + success + customer { + id + name + domains + externalIds + } + } +} +""" + +CUSTOMER_NEED_CREATE = """ +mutation CustomerNeedCreate($input: CustomerNeedCreateInput!) { + customerNeedCreate(input: $input) { + success + need { + id + body + priority + customer { + id + name + } + issue { + id + identifier + title + } + } + } +} +""" + +ISSUE_CREATE = """ +mutation IssueCreate($input: IssueCreateInput!) { + issueCreate(input: $input) { + success + issue { + id + identifier + title + url + } + } +} +""" + + +# --------------------------------------------------------------------------- +# TESTS +# --------------------------------------------------------------------------- + + +def test_1_search_feature_requests(): + """Search for feature requests in the target project by keyword.""" + print("\n" + "=" * 60) + print("TEST 1: Search feature requests in project by keyword") + print("=" * 60) + + search_term = "agent" + result = graphql( + SEARCH_ISSUES_IN_PROJECT, + { + "filter": { + "project": {"id": {"eq": FEATURE_REQUEST_PROJECT_ID}}, + "or": [ + {"title": {"containsIgnoreCase": search_term}}, + {"description": {"containsIgnoreCase": search_term}}, + ], + }, + "first": 5, + }, + ) + + issues = result.get("data", {}).get("issues", {}).get("nodes", []) + for issue in issues: + proj = issue.get("project") or {} + print(f"\n [{issue['identifier']}] {issue['title']}") + print(f" Project: {proj.get('name', 'N/A')}") + print(f" State: {issue['state']['name']}") + print(f" URL: {issue['url']}") + + print(f"\n Found {len(issues)} issues matching '{search_term}'") + return issues + + +def test_2_list_all_in_project(): + """List all issues in the feature requests project.""" + print("\n" + "=" * 60) + print("TEST 2: List all issues in Customer Feature Requests project") + print("=" * 60) + + result = graphql( + SEARCH_ISSUES_IN_PROJECT, + { + "filter": { + "project": {"id": {"eq": FEATURE_REQUEST_PROJECT_ID}}, + }, + "first": 10, + }, + ) + + issues = result.get("data", {}).get("issues", {}).get("nodes", []) + if not issues: + print(" No issues in project yet (empty project)") + for issue in issues: + print(f"\n [{issue['identifier']}] {issue['title']}") + print(f" State: {issue['state']['name']}") + + print(f"\n Total: {len(issues)} issues") + return issues + + +def test_3_search_customers(): + """List existing customers.""" + print("\n" + "=" * 60) + print("TEST 3: List customers") + print("=" * 60) + + result = graphql(SEARCH_CUSTOMERS, {"first": 10}) + customers = result.get("data", {}).get("customers", {}).get("nodes", []) + + if not customers: + print(" No customers exist yet") + for c in customers: + status = c.get("status") or {} + tier = c.get("tier") or {} + print(f"\n [{c['id'][:8]}...] {c['name']}") + print(f" Domains: {c.get('domains', [])}") + print(f" External IDs: {c.get('externalIds', [])}") + print( + f" Status: {status.get('name', 'N/A')}, Tier: {tier.get('name', 'N/A')}" + ) + + print(f"\n Total: {len(customers)} customers") + return customers + + +def test_4_customer_upsert(): + """Upsert a test customer.""" + print("\n" + "=" * 60) + print("TEST 4: Customer upsert (find-or-create)") + print("=" * 60) + + result = graphql( + CUSTOMER_UPSERT, + { + "input": { + "name": "Test Customer (API Test)", + "domains": ["test-api-customer.example.com"], + "externalId": "test-customer-001", + } + }, + ) + + upsert = result.get("data", {}).get("customerUpsert", {}) + if upsert.get("success"): + customer = upsert["customer"] + print(f" Success! Customer: {customer['name']}") + print(f" ID: {customer['id']}") + print(f" Domains: {customer['domains']}") + print(f" External IDs: {customer['externalIds']}") + return customer + else: + print(f" Failed: {json.dumps(result, indent=2)}") + return None + + +def test_5_create_issue_and_need(customer_id: str): + """Create a new feature request issue and attach a customer need.""" + print("\n" + "=" * 60) + print("TEST 5: Create issue + customer need") + print("=" * 60) + + # Step 1: Create issue in the project + result = graphql( + ISSUE_CREATE, + { + "input": { + "title": "Test Feature Request (API Test - safe to delete)", + "description": "This is a test feature request created via the GraphQL API.", + "teamId": TEAM_ID, + "projectId": FEATURE_REQUEST_PROJECT_ID, + } + }, + ) + + data = result.get("data") + if not data: + print(f" Issue creation failed: {json.dumps(result, indent=2)}") + return None + issue_data = data.get("issueCreate", {}) + if not issue_data.get("success"): + print(f" Issue creation failed: {json.dumps(result, indent=2)}") + return None + + issue = issue_data["issue"] + print(f" Created issue: [{issue['identifier']}] {issue['title']}") + print(f" URL: {issue['url']}") + + # Step 2: Attach customer need + result = graphql( + CUSTOMER_NEED_CREATE, + { + "input": { + "customerId": customer_id, + "issueId": issue["id"], + "body": "Our team really needs this feature for our workflow. High priority for us!", + "priority": 0, + } + }, + ) + + need_data = result.get("data", {}).get("customerNeedCreate", {}) + if need_data.get("success"): + need = need_data["need"] + print(f" Attached customer need: {need['id']}") + print(f" Customer: {need['customer']['name']}") + print(f" Body: {need['body'][:80]}") + else: + print(f" Customer need creation failed: {json.dumps(result, indent=2)}") + + # Step 3: Verify by fetching the issue with needs + print("\n Verifying...") + verify = graphql(GET_ISSUE_WITH_NEEDS, {"id": issue["id"]}) + issue_verify = verify.get("data", {}).get("issue", {}) + needs = issue_verify.get("needs", {}).get("nodes", []) + print(f" Issue now has {len(needs)} customer need(s)") + for n in needs: + cust = n.get("customer") or {} + print(f" - {cust.get('name', 'N/A')}: {n.get('body', '')[:60]}") + + return issue + + +def test_6_add_need_to_existing(customer_id: str, issue_id: str): + """Add a customer need to an existing issue (the common case).""" + print("\n" + "=" * 60) + print("TEST 6: Add customer need to existing issue") + print("=" * 60) + + result = graphql( + CUSTOMER_NEED_CREATE, + { + "input": { + "customerId": customer_id, + "issueId": issue_id, + "body": "We also want this! +1 from our organization.", + "priority": 0, + } + }, + ) + + need_data = result.get("data", {}).get("customerNeedCreate", {}) + if need_data.get("success"): + need = need_data["need"] + print(f" Success! Need: {need['id']}") + print(f" Customer: {need['customer']['name']}") + print(f" Issue: [{need['issue']['identifier']}] {need['issue']['title']}") + return need + else: + print(f" Failed: {json.dumps(result, indent=2)}") + return None + + +def main(): + print("Linear GraphQL API - Customer Requests Test Suite") + print("=" * 60) + print(f"API URL: {LINEAR_API_URL}") + print(f"API Key: {API_KEY[:10]}...") + print(f"Project: Customer Feature Requests ({FEATURE_REQUEST_PROJECT_ID[:8]}...)") + + # --- Read-only tests --- + test_1_search_feature_requests() + test_2_list_all_in_project() + test_3_search_customers() + + # --- Write tests --- + print("\n" + "=" * 60) + answer = ( + input("Run WRITE tests? (creates test customer + issue + need) [y/N]: ") + .strip() + .lower() + ) + if answer != "y": + print("Skipped write tests.") + print("\nDone!") + return + + customer = test_4_customer_upsert() + if not customer: + print("Customer upsert failed, stopping.") + return + + issue = test_5_create_issue_and_need(customer["id"]) + if not issue: + print("Issue creation failed, stopping.") + return + + # Test adding a second need to the same issue (simulates another customer requesting same feature) + # First upsert a second customer + result = graphql( + CUSTOMER_UPSERT, + { + "input": { + "name": "Second Test Customer", + "domains": ["second-test.example.com"], + "externalId": "test-customer-002", + } + }, + ) + customer2 = result.get("data", {}).get("customerUpsert", {}).get("customer") + if customer2: + test_6_add_need_to_existing(customer2["id"], issue["id"]) + + print("\n" + "=" * 60) + print("All tests complete!") + print( + "Check the project: https://linear.app/autogpt/project/customer-feature-requests-710dcbf8bf4e/issues" + ) + + +if __name__ == "__main__": + main() diff --git a/autogpt_platform/frontend/src/app/(platform)/copilot/components/ChatMessagesContainer/ChatMessagesContainer.tsx b/autogpt_platform/frontend/src/app/(platform)/copilot/components/ChatMessagesContainer/ChatMessagesContainer.tsx index 71ade81a9f..b62e96f58a 100644 --- a/autogpt_platform/frontend/src/app/(platform)/copilot/components/ChatMessagesContainer/ChatMessagesContainer.tsx +++ b/autogpt_platform/frontend/src/app/(platform)/copilot/components/ChatMessagesContainer/ChatMessagesContainer.tsx @@ -15,6 +15,10 @@ import { ToolUIPart, UIDataTypes, UIMessage, UITools } from "ai"; import { useEffect, useRef, useState } from "react"; import { CreateAgentTool } from "../../tools/CreateAgent/CreateAgent"; import { EditAgentTool } from "../../tools/EditAgent/EditAgent"; +import { + CreateFeatureRequestTool, + SearchFeatureRequestsTool, +} from "../../tools/FeatureRequests/FeatureRequests"; import { FindAgentsTool } from "../../tools/FindAgents/FindAgents"; import { FindBlocksTool } from "../../tools/FindBlocks/FindBlocks"; import { RunAgentTool } from "../../tools/RunAgent/RunAgent"; @@ -254,6 +258,20 @@ export const ChatMessagesContainer = ({ part={part as ToolUIPart} /> ); + case "tool-search_feature_requests": + return ( + + ); + case "tool-create_feature_request": + return ( + + ); default: return null; } diff --git a/autogpt_platform/frontend/src/app/(platform)/copilot/styleguide/page.tsx b/autogpt_platform/frontend/src/app/(platform)/copilot/styleguide/page.tsx index 6030665f1c..8a35f939ca 100644 --- a/autogpt_platform/frontend/src/app/(platform)/copilot/styleguide/page.tsx +++ b/autogpt_platform/frontend/src/app/(platform)/copilot/styleguide/page.tsx @@ -14,6 +14,10 @@ import { Text } from "@/components/atoms/Text/Text"; import { CopilotChatActionsProvider } from "../components/CopilotChatActionsProvider/CopilotChatActionsProvider"; import { CreateAgentTool } from "../tools/CreateAgent/CreateAgent"; import { EditAgentTool } from "../tools/EditAgent/EditAgent"; +import { + CreateFeatureRequestTool, + SearchFeatureRequestsTool, +} from "../tools/FeatureRequests/FeatureRequests"; import { FindAgentsTool } from "../tools/FindAgents/FindAgents"; import { FindBlocksTool } from "../tools/FindBlocks/FindBlocks"; import { RunAgentTool } from "../tools/RunAgent/RunAgent"; @@ -45,6 +49,8 @@ const SECTIONS = [ "Tool: Create Agent", "Tool: Edit Agent", "Tool: View Agent Output", + "Tool: Search Feature Requests", + "Tool: Create Feature Request", "Full Conversation Example", ] as const; @@ -1421,6 +1427,235 @@ export default function StyleguidePage() { + {/* ============================================================= */} + {/* SEARCH FEATURE REQUESTS */} + {/* ============================================================= */} + +
+ + + + + + + + + + + + + + + + + + + + + + + +
+ + {/* ============================================================= */} + {/* CREATE FEATURE REQUEST */} + {/* ============================================================= */} + +
+ + + + + + + + + + + + + + + + + + + + + + + +
+ {/* ============================================================= */} {/* FULL CONVERSATION EXAMPLE */} {/* ============================================================= */} diff --git a/autogpt_platform/frontend/src/app/(platform)/copilot/tools/FeatureRequests/FeatureRequests.tsx b/autogpt_platform/frontend/src/app/(platform)/copilot/tools/FeatureRequests/FeatureRequests.tsx new file mode 100644 index 0000000000..e14ec69397 --- /dev/null +++ b/autogpt_platform/frontend/src/app/(platform)/copilot/tools/FeatureRequests/FeatureRequests.tsx @@ -0,0 +1,240 @@ +"use client"; + +import type { ToolUIPart } from "ai"; +import { useMemo } from "react"; + +import { MorphingTextAnimation } from "../../components/MorphingTextAnimation/MorphingTextAnimation"; +import { + ContentBadge, + ContentCard, + ContentCardDescription, + ContentCardHeader, + ContentCardTitle, + ContentGrid, + ContentLink, + ContentMessage, + ContentSuggestionsList, +} from "../../components/ToolAccordion/AccordionContent"; +import { ToolAccordion } from "../../components/ToolAccordion/ToolAccordion"; +import { + AccordionIcon, + getAccordionTitle, + getAnimationText, + getFeatureRequestOutput, + isCreatedOutput, + isErrorOutput, + isNoResultsOutput, + isSearchResultsOutput, + ToolIcon, + type FeatureRequestToolType, +} from "./helpers"; + +export interface FeatureRequestToolPart { + type: FeatureRequestToolType; + toolCallId: string; + state: ToolUIPart["state"]; + input?: unknown; + output?: unknown; +} + +interface Props { + part: FeatureRequestToolPart; +} + +function truncate(text: string, maxChars: number): string { + const trimmed = text.trim(); + if (trimmed.length <= maxChars) return trimmed; + return `${trimmed.slice(0, maxChars).trimEnd()}…`; +} + +export function SearchFeatureRequestsTool({ part }: Props) { + const output = getFeatureRequestOutput(part); + const text = getAnimationText(part); + const isStreaming = + part.state === "input-streaming" || part.state === "input-available"; + const isError = + part.state === "output-error" || (!!output && isErrorOutput(output)); + + const normalized = useMemo(() => { + if (!output) return null; + return { title: getAccordionTitle(part.type, output) }; + }, [output, part.type]); + + const isOutputAvailable = part.state === "output-available" && !!output; + + const searchOutput = + isOutputAvailable && output && isSearchResultsOutput(output) + ? output + : null; + const noResultsOutput = + isOutputAvailable && output && isNoResultsOutput(output) ? output : null; + const errorOutput = + isOutputAvailable && output && isErrorOutput(output) ? output : null; + + const hasExpandableContent = + isOutputAvailable && + ((!!searchOutput && searchOutput.count > 0) || + !!noResultsOutput || + !!errorOutput); + + const accordionDescription = + hasExpandableContent && searchOutput + ? `Found ${searchOutput.count} result${searchOutput.count === 1 ? "" : "s"} for "${searchOutput.query}"` + : hasExpandableContent && (noResultsOutput || errorOutput) + ? ((noResultsOutput ?? errorOutput)?.message ?? null) + : null; + + return ( +
+
+ + +
+ + {hasExpandableContent && normalized && ( + } + title={normalized.title} + description={accordionDescription} + > + {searchOutput && ( + + {searchOutput.results.map((r) => ( + + + + {r.identifier} — {r.title} + + + {r.description && ( + + {truncate(r.description, 200)} + + )} + + ))} + + )} + + {noResultsOutput && ( +
+ {noResultsOutput.message} + {noResultsOutput.suggestions && + noResultsOutput.suggestions.length > 0 && ( + + )} +
+ )} + + {errorOutput && ( +
+ {errorOutput.message} + {errorOutput.error && ( + + {errorOutput.error} + + )} +
+ )} +
+ )} +
+ ); +} + +export function CreateFeatureRequestTool({ part }: Props) { + const output = getFeatureRequestOutput(part); + const text = getAnimationText(part); + const isStreaming = + part.state === "input-streaming" || part.state === "input-available"; + const isError = + part.state === "output-error" || (!!output && isErrorOutput(output)); + + const normalized = useMemo(() => { + if (!output) return null; + return { title: getAccordionTitle(part.type, output) }; + }, [output, part.type]); + + const isOutputAvailable = part.state === "output-available" && !!output; + + const createdOutput = + isOutputAvailable && output && isCreatedOutput(output) ? output : null; + const errorOutput = + isOutputAvailable && output && isErrorOutput(output) ? output : null; + + const hasExpandableContent = + isOutputAvailable && (!!createdOutput || !!errorOutput); + + const accordionDescription = + hasExpandableContent && createdOutput + ? `${createdOutput.issue_identifier} — ${createdOutput.issue_title}` + : hasExpandableContent && errorOutput + ? errorOutput.message + : null; + + return ( +
+
+ + +
+ + {hasExpandableContent && normalized && ( + } + title={normalized.title} + description={accordionDescription} + > + {createdOutput && ( + + + View + + ) : undefined + } + > + + {createdOutput.issue_identifier} — {createdOutput.issue_title} + + +
+ + {createdOutput.is_new_issue ? "New" : "Existing"} + +
+ {createdOutput.message} +
+ )} + + {errorOutput && ( +
+ {errorOutput.message} + {errorOutput.error && ( + + {errorOutput.error} + + )} +
+ )} +
+ )} +
+ ); +} diff --git a/autogpt_platform/frontend/src/app/(platform)/copilot/tools/FeatureRequests/helpers.tsx b/autogpt_platform/frontend/src/app/(platform)/copilot/tools/FeatureRequests/helpers.tsx new file mode 100644 index 0000000000..ed292faf2b --- /dev/null +++ b/autogpt_platform/frontend/src/app/(platform)/copilot/tools/FeatureRequests/helpers.tsx @@ -0,0 +1,271 @@ +import { + CheckCircleIcon, + LightbulbIcon, + MagnifyingGlassIcon, + PlusCircleIcon, +} from "@phosphor-icons/react"; +import type { ToolUIPart } from "ai"; + +/* ------------------------------------------------------------------ */ +/* Types (local until API client is regenerated) */ +/* ------------------------------------------------------------------ */ + +interface FeatureRequestInfo { + id: string; + identifier: string; + title: string; + description?: string | null; +} + +export interface FeatureRequestSearchResponse { + type: "feature_request_search"; + message: string; + results: FeatureRequestInfo[]; + count: number; + query: string; +} + +export interface FeatureRequestCreatedResponse { + type: "feature_request_created"; + message: string; + issue_id: string; + issue_identifier: string; + issue_title: string; + issue_url: string; + is_new_issue: boolean; + customer_name: string; +} + +interface NoResultsResponse { + type: "no_results"; + message: string; + suggestions?: string[]; +} + +interface ErrorResponse { + type: "error"; + message: string; + error?: string; +} + +export type FeatureRequestOutput = + | FeatureRequestSearchResponse + | FeatureRequestCreatedResponse + | NoResultsResponse + | ErrorResponse; + +export type FeatureRequestToolType = + | "tool-search_feature_requests" + | "tool-create_feature_request" + | string; + +/* ------------------------------------------------------------------ */ +/* Output parsing */ +/* ------------------------------------------------------------------ */ + +function parseOutput(output: unknown): FeatureRequestOutput | null { + if (!output) return null; + if (typeof output === "string") { + const trimmed = output.trim(); + if (!trimmed) return null; + try { + return parseOutput(JSON.parse(trimmed) as unknown); + } catch { + return null; + } + } + if (typeof output === "object") { + const type = (output as { type?: unknown }).type; + if ( + type === "feature_request_search" || + type === "feature_request_created" || + type === "no_results" || + type === "error" + ) { + return output as FeatureRequestOutput; + } + // Fallback structural checks + if ("results" in output && "query" in output) + return output as FeatureRequestSearchResponse; + if ("issue_identifier" in output) + return output as FeatureRequestCreatedResponse; + if ("suggestions" in output && !("error" in output)) + return output as NoResultsResponse; + if ("error" in output || "details" in output) + return output as ErrorResponse; + } + return null; +} + +export function getFeatureRequestOutput( + part: unknown, +): FeatureRequestOutput | null { + if (!part || typeof part !== "object") return null; + return parseOutput((part as { output?: unknown }).output); +} + +/* ------------------------------------------------------------------ */ +/* Type guards */ +/* ------------------------------------------------------------------ */ + +export function isSearchResultsOutput( + output: FeatureRequestOutput, +): output is FeatureRequestSearchResponse { + return ( + output.type === "feature_request_search" || + ("results" in output && "query" in output) + ); +} + +export function isCreatedOutput( + output: FeatureRequestOutput, +): output is FeatureRequestCreatedResponse { + return ( + output.type === "feature_request_created" || "issue_identifier" in output + ); +} + +export function isNoResultsOutput( + output: FeatureRequestOutput, +): output is NoResultsResponse { + return ( + output.type === "no_results" || + ("suggestions" in output && !("error" in output)) + ); +} + +export function isErrorOutput( + output: FeatureRequestOutput, +): output is ErrorResponse { + return output.type === "error" || "error" in output; +} + +/* ------------------------------------------------------------------ */ +/* Accordion metadata */ +/* ------------------------------------------------------------------ */ + +export function getAccordionTitle( + toolType: FeatureRequestToolType, + output: FeatureRequestOutput, +): string { + if (toolType === "tool-search_feature_requests") { + if (isSearchResultsOutput(output)) return "Feature requests"; + if (isNoResultsOutput(output)) return "No feature requests found"; + return "Feature request search error"; + } + if (isCreatedOutput(output)) { + return output.is_new_issue + ? "Feature request created" + : "Added to feature request"; + } + if (isErrorOutput(output)) return "Feature request error"; + return "Feature request"; +} + +/* ------------------------------------------------------------------ */ +/* Animation text */ +/* ------------------------------------------------------------------ */ + +interface AnimationPart { + type: FeatureRequestToolType; + state: ToolUIPart["state"]; + input?: unknown; + output?: unknown; +} + +export function getAnimationText(part: AnimationPart): string { + if (part.type === "tool-search_feature_requests") { + const query = (part.input as { query?: string } | undefined)?.query?.trim(); + const queryText = query ? ` for "${query}"` : ""; + + switch (part.state) { + case "input-streaming": + case "input-available": + return `Searching feature requests${queryText}`; + case "output-available": { + const output = parseOutput(part.output); + if (!output) return `Searching feature requests${queryText}`; + if (isSearchResultsOutput(output)) { + return `Found ${output.count} feature request${output.count === 1 ? "" : "s"}${queryText}`; + } + if (isNoResultsOutput(output)) + return `No feature requests found${queryText}`; + return `Error searching feature requests${queryText}`; + } + case "output-error": + return `Error searching feature requests${queryText}`; + default: + return "Searching feature requests"; + } + } + + // create_feature_request + const title = (part.input as { title?: string } | undefined)?.title?.trim(); + const titleText = title ? ` "${title}"` : ""; + + switch (part.state) { + case "input-streaming": + case "input-available": + return `Creating feature request${titleText}`; + case "output-available": { + const output = parseOutput(part.output); + if (!output) return `Creating feature request${titleText}`; + if (isCreatedOutput(output)) { + return output.is_new_issue + ? `Created ${output.issue_identifier}` + : `Added to ${output.issue_identifier}`; + } + if (isErrorOutput(output)) return "Error creating feature request"; + return `Created feature request${titleText}`; + } + case "output-error": + return "Error creating feature request"; + default: + return "Creating feature request"; + } +} + +/* ------------------------------------------------------------------ */ +/* Icons */ +/* ------------------------------------------------------------------ */ + +export function ToolIcon({ + toolType, + isStreaming, + isError, +}: { + toolType: FeatureRequestToolType; + isStreaming?: boolean; + isError?: boolean; +}) { + const IconComponent = + toolType === "tool-create_feature_request" + ? PlusCircleIcon + : MagnifyingGlassIcon; + + return ( + + ); +} + +export function AccordionIcon({ + toolType, +}: { + toolType: FeatureRequestToolType; +}) { + const IconComponent = + toolType === "tool-create_feature_request" + ? CheckCircleIcon + : LightbulbIcon; + return ; +} diff --git a/autogpt_platform/frontend/src/app/api/openapi.json b/autogpt_platform/frontend/src/app/api/openapi.json index 5d2cb83f7c..a0eb141aa9 100644 --- a/autogpt_platform/frontend/src/app/api/openapi.json +++ b/autogpt_platform/frontend/src/app/api/openapi.json @@ -10495,7 +10495,9 @@ "operation_started", "operation_pending", "operation_in_progress", - "input_validation_error" + "input_validation_error", + "feature_request_search", + "feature_request_created" ], "title": "ResponseType", "description": "Types of tool responses."