mirror of https://github.com/kortix-ai/suna.git
Merge pull request #1740 from escapade-mckv/people-search-tool
People search tool
This commit is contained in:
commit
e1e94292d9
|
@ -220,6 +220,7 @@ def _get_default_agentpress_tools() -> Dict[str, bool]:
|
|||
# "sb_web_dev_tool": True,
|
||||
"browser_tool": True,
|
||||
"data_providers_tool": True,
|
||||
"people_search_tool": False,
|
||||
"agent_config_tool": True,
|
||||
"mcp_search_tool": True,
|
||||
"credential_profile_tool": True,
|
||||
|
|
|
@ -42,6 +42,8 @@ from core.tools.sb_sheets_tool import SandboxSheetsTool
|
|||
# from core.tools.sb_web_dev_tool import SandboxWebDevTool # DEACTIVATED
|
||||
from core.tools.sb_upload_file_tool import SandboxUploadFileTool
|
||||
from core.tools.sb_docs_tool import SandboxDocsTool
|
||||
from core.tools.people_search_tool import PeopleSearchTool
|
||||
from core.tools.company_search_tool import CompanySearchTool
|
||||
from core.ai_models.manager import model_manager
|
||||
|
||||
load_dotenv()
|
||||
|
@ -141,7 +143,6 @@ class ToolManager:
|
|||
# logger.debug(f"Registered {tool_name} (all methods)")
|
||||
|
||||
def _register_utility_tools(self, disabled_tools: List[str]):
|
||||
"""Register utility and data provider tools."""
|
||||
if config.RAPID_API_KEY and 'data_providers_tool' not in disabled_tools:
|
||||
# Check for granular method control
|
||||
enabled_methods = self._get_enabled_methods_for_tool('data_providers_tool')
|
||||
|
@ -153,6 +154,26 @@ class ToolManager:
|
|||
# Register all methods (backward compatibility)
|
||||
self.thread_manager.add_tool(DataProvidersTool)
|
||||
# logger.debug("Registered data_providers_tool (all methods)")
|
||||
|
||||
# Register search tools if EXA API key is available
|
||||
if config.EXA_API_KEY:
|
||||
if 'people_search_tool' not in disabled_tools:
|
||||
enabled_methods = self._get_enabled_methods_for_tool('people_search_tool')
|
||||
if enabled_methods is not None:
|
||||
self.thread_manager.add_tool(PeopleSearchTool, function_names=enabled_methods, thread_manager=self.thread_manager)
|
||||
logger.debug(f"Registered people_search_tool with methods: {enabled_methods}")
|
||||
else:
|
||||
self.thread_manager.add_tool(PeopleSearchTool, thread_manager=self.thread_manager)
|
||||
logger.debug("Registered people_search_tool (all methods)")
|
||||
|
||||
if 'company_search_tool' not in disabled_tools:
|
||||
enabled_methods = self._get_enabled_methods_for_tool('company_search_tool')
|
||||
if enabled_methods is not None:
|
||||
self.thread_manager.add_tool(CompanySearchTool, function_names=enabled_methods, thread_manager=self.thread_manager)
|
||||
logger.debug(f"Registered company_search_tool with methods: {enabled_methods}")
|
||||
else:
|
||||
self.thread_manager.add_tool(CompanySearchTool, thread_manager=self.thread_manager)
|
||||
logger.debug("Registered company_search_tool (all methods)")
|
||||
|
||||
def _register_agent_builder_tools(self, agent_id: str, disabled_tools: List[str]):
|
||||
"""Register agent builder tools."""
|
||||
|
@ -593,6 +614,22 @@ class AgentRunner:
|
|||
else:
|
||||
logger.debug("Not a Suna agent, skipping Suna-specific tool registration")
|
||||
|
||||
def _get_enabled_methods_for_tool(self, tool_name: str) -> Optional[List[str]]:
|
||||
if not self.config.agent_config or 'agentpress_tools' not in self.config.agent_config:
|
||||
return None
|
||||
|
||||
from core.utils.tool_groups import get_enabled_methods_for_tool
|
||||
from core.utils.tool_migration import migrate_legacy_tool_config
|
||||
|
||||
raw_tools = self.config.agent_config['agentpress_tools']
|
||||
|
||||
if not isinstance(raw_tools, dict):
|
||||
return None
|
||||
|
||||
migrated_tools = migrate_legacy_tool_config(raw_tools)
|
||||
|
||||
return get_enabled_methods_for_tool(tool_name, migrated_tools)
|
||||
|
||||
def _register_suna_specific_tools(self, disabled_tools: List[str]):
|
||||
if 'agent_creation_tool' not in disabled_tools:
|
||||
from core.tools.agent_creation_tool import AgentCreationTool
|
||||
|
@ -644,7 +681,7 @@ class AgentRunner:
|
|||
'sb_shell_tool', 'sb_files_tool', 'sb_deploy_tool', 'sb_expose_tool',
|
||||
'web_search_tool', 'image_search_tool', 'sb_vision_tool', 'sb_presentation_tool', 'sb_image_edit_tool',
|
||||
'sb_sheets_tool', 'sb_web_dev_tool', 'data_providers_tool', 'browser_tool',
|
||||
'agent_config_tool', 'mcp_search_tool', 'credential_profile_tool',
|
||||
'people_search_tool', 'company_search_tool', 'agent_config_tool', 'mcp_search_tool', 'credential_profile_tool',
|
||||
'workflow_tool', 'trigger_tool'
|
||||
]
|
||||
|
||||
|
|
|
@ -32,6 +32,10 @@ SUNA_CONFIG = {
|
|||
"sb_presentation_tool": True,
|
||||
"sb_sheets_tool": True,
|
||||
"sb_kb_tool": True,
|
||||
|
||||
# search tools
|
||||
"people_search_tool": True,
|
||||
"company_search_tool": True,
|
||||
|
||||
# Browser automation (both variants)
|
||||
"browser_tool": True,
|
||||
|
|
|
@ -0,0 +1,304 @@
|
|||
from typing import Optional
|
||||
import asyncio
|
||||
import structlog
|
||||
import json
|
||||
from decimal import Decimal
|
||||
from exa_py import Exa
|
||||
from exa_py.websets.types import CreateWebsetParameters, CreateEnrichmentParameters
|
||||
from core.agentpress.tool import Tool, ToolResult, openapi_schema, usage_example
|
||||
from core.utils.config import config, EnvMode
|
||||
from core.utils.logger import logger
|
||||
from core.agentpress.thread_manager import ThreadManager
|
||||
from core.billing.credit_manager import CreditManager
|
||||
from core.billing.config import TOKEN_PRICE_MULTIPLIER
|
||||
from core.services.supabase import DBConnection
|
||||
|
||||
|
||||
class CompanySearchTool(Tool):
|
||||
def __init__(self, thread_manager: ThreadManager):
|
||||
super().__init__()
|
||||
self.thread_manager = thread_manager
|
||||
self.api_key = config.EXA_API_KEY
|
||||
self.db = DBConnection()
|
||||
self.credit_manager = CreditManager()
|
||||
self.exa_client = None
|
||||
|
||||
if self.api_key:
|
||||
self.exa_client = Exa(self.api_key)
|
||||
logger.info("Company Search Tool initialized. Note: This requires an Exa Pro plan for Websets API access.")
|
||||
else:
|
||||
logger.warning("EXA_API_KEY not configured - Company Search Tool will not be available")
|
||||
|
||||
async def _get_current_thread_and_user(self) -> tuple[Optional[str], Optional[str]]:
|
||||
try:
|
||||
context_vars = structlog.contextvars.get_contextvars()
|
||||
thread_id = context_vars.get('thread_id')
|
||||
|
||||
if not thread_id:
|
||||
logger.warning("No thread_id in execution context")
|
||||
return None, None
|
||||
|
||||
client = await self.db.client
|
||||
thread = await client.from_('threads').select('account_id').eq('thread_id', thread_id).single().execute()
|
||||
if thread.data:
|
||||
return thread_id, thread.data.get('account_id')
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get thread context: {e}")
|
||||
return None, None
|
||||
|
||||
async def _deduct_credits(self, user_id: str, num_results: int, thread_id: Optional[str] = None) -> bool:
|
||||
base_cost = Decimal('0.45')
|
||||
total_cost = base_cost * TOKEN_PRICE_MULTIPLIER
|
||||
|
||||
try:
|
||||
result = await self.credit_manager.use_credits(
|
||||
account_id=user_id,
|
||||
amount=total_cost,
|
||||
description=f"Company search: {num_results} results",
|
||||
thread_id=thread_id
|
||||
)
|
||||
|
||||
if result.get('success'):
|
||||
logger.info(f"Deducted ${total_cost:.2f} for company search ({num_results} results)")
|
||||
return True
|
||||
else:
|
||||
logger.warning(f"Failed to deduct credits: {result.get('error')}")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error deducting credits: {e}")
|
||||
return False
|
||||
|
||||
@openapi_schema({
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "company_search",
|
||||
"description": "Search for companies using natural language queries and enrich with company profiles. IMPORTANT: Requires Exa Pro plan and costs $0.54 per search (10 results).",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {
|
||||
"type": "string",
|
||||
"description": "Natural language search query describing the companies you want to find. Examples: 'AI startups in San Francisco with recent funding', 'Fortune 500 companies in healthcare sector', 'B2B SaaS companies with over 100 employees in New York'"
|
||||
},
|
||||
"enrichment_description": {
|
||||
"type": "string",
|
||||
"description": "What specific information to find about each company. Default: 'Company website, funding information, and key details'",
|
||||
"default": "Company website, funding information, and key details"
|
||||
}
|
||||
},
|
||||
"required": ["query"]
|
||||
}
|
||||
}
|
||||
})
|
||||
@usage_example('''
|
||||
<function_calls>
|
||||
<invoke name="company_search">
|
||||
<parameter name="query">B2B SaaS companies in Silicon Valley with over $10M funding</parameter>
|
||||
</invoke>
|
||||
</function_calls>
|
||||
|
||||
<function_calls>
|
||||
<invoke name="company_search">
|
||||
<parameter name="query">Healthcare technology startups in Boston</parameter>
|
||||
<parameter name="enrichment_description">Company website, recent news, and leadership team</parameter>
|
||||
</invoke>
|
||||
</function_calls>
|
||||
''')
|
||||
async def company_search(
|
||||
self,
|
||||
query: str,
|
||||
enrichment_description: str = "Company website, funding information, and key details"
|
||||
) -> ToolResult:
|
||||
if not self.exa_client:
|
||||
return self.fail_response(
|
||||
"Company Search is not available. EXA_API_KEY is not configured. "
|
||||
"Please contact your administrator to enable this feature."
|
||||
)
|
||||
|
||||
if not query:
|
||||
return self.fail_response("Search query is required.")
|
||||
|
||||
thread_id, user_id = await self._get_current_thread_and_user()
|
||||
|
||||
if config.ENV_MODE != EnvMode.LOCAL and (not thread_id or not user_id):
|
||||
return self.fail_response(
|
||||
"No active session context for billing. This tool requires an active agent session."
|
||||
)
|
||||
|
||||
try:
|
||||
logger.info(f"Creating Exa webset for: '{query}' with 10 results")
|
||||
|
||||
enrichment_config = CreateEnrichmentParameters(
|
||||
description=enrichment_description,
|
||||
format="text"
|
||||
)
|
||||
|
||||
webset_params = CreateWebsetParameters(
|
||||
search={
|
||||
"query": query,
|
||||
"count": 10
|
||||
},
|
||||
enrichments=[enrichment_config]
|
||||
)
|
||||
|
||||
try:
|
||||
webset = await asyncio.to_thread(
|
||||
self.exa_client.websets.create,
|
||||
params=webset_params
|
||||
)
|
||||
|
||||
logger.info(f"Webset created with ID: {webset.id}")
|
||||
except Exception as create_error:
|
||||
logger.error(f"Failed to create webset - Error type: {type(create_error).__name__}")
|
||||
try:
|
||||
error_str = str(create_error)
|
||||
logger.error(f"Failed to create webset - Error message: {error_str}")
|
||||
except:
|
||||
error_str = "Unknown error"
|
||||
logger.error(f"Failed to create webset - Could not convert error to string")
|
||||
|
||||
if "401" in error_str:
|
||||
return self.fail_response(
|
||||
"Authentication failed with Exa API. Please check your API key and Pro plan status."
|
||||
)
|
||||
elif "400" in error_str:
|
||||
return self.fail_response(
|
||||
"Invalid request to Exa API. Please check your query format."
|
||||
)
|
||||
else:
|
||||
return self.fail_response(
|
||||
"Failed to create webset. Please try again."
|
||||
)
|
||||
|
||||
logger.info(f"Waiting for webset {webset.id} to complete processing...")
|
||||
try:
|
||||
webset = await asyncio.to_thread(
|
||||
self.exa_client.websets.wait_until_idle,
|
||||
webset.id
|
||||
)
|
||||
logger.info(f"Webset {webset.id} processing complete")
|
||||
except Exception as wait_error:
|
||||
logger.error(f"Error waiting for webset: {type(wait_error).__name__}: {repr(wait_error)}")
|
||||
return self.fail_response("Failed while waiting for search results. Please try again.")
|
||||
|
||||
logger.info(f"Retrieving items from webset {webset.id}...")
|
||||
try:
|
||||
items = await asyncio.to_thread(
|
||||
self.exa_client.websets.items.list,
|
||||
webset_id=webset.id
|
||||
)
|
||||
logger.info(f"Retrieved items from webset")
|
||||
except Exception as items_error:
|
||||
logger.error(f"Error retrieving items: {type(items_error).__name__}: {repr(items_error)}")
|
||||
return self.fail_response("Failed to retrieve search results. Please try again.")
|
||||
|
||||
results = items.data if items else []
|
||||
logger.info(f"Got {len(results)} results from webset")
|
||||
|
||||
formatted_results = []
|
||||
for idx, item in enumerate(results[:10], 1):
|
||||
if hasattr(item, 'model_dump'):
|
||||
item_dict = item.model_dump()
|
||||
elif isinstance(item, dict):
|
||||
item_dict = item
|
||||
else:
|
||||
item_dict = vars(item) if hasattr(item, '__dict__') else {}
|
||||
|
||||
properties = item_dict.get('properties', {})
|
||||
company_info = properties.get('company', {})
|
||||
|
||||
evaluations_text = ""
|
||||
evaluations = item_dict.get('evaluations', [])
|
||||
if evaluations:
|
||||
eval_items = []
|
||||
for eval_item in evaluations:
|
||||
if isinstance(eval_item, dict):
|
||||
criterion = eval_item.get('criterion', '')
|
||||
satisfied = eval_item.get('satisfied', '')
|
||||
if criterion:
|
||||
eval_items.append(f"{criterion}: {satisfied}")
|
||||
evaluations_text = " | ".join(eval_items)
|
||||
|
||||
enrichment_text = ""
|
||||
if 'enrichments' in item_dict and item_dict['enrichments']:
|
||||
enrichments = item_dict['enrichments']
|
||||
if isinstance(enrichments, list) and len(enrichments) > 0:
|
||||
enrichment = enrichments[0]
|
||||
if isinstance(enrichment, dict):
|
||||
enrich_result = enrichment.get('result')
|
||||
if enrich_result is not None:
|
||||
if isinstance(enrich_result, list) and enrich_result:
|
||||
enrichment_text = str(enrich_result[0]) if enrich_result[0] else ""
|
||||
elif isinstance(enrich_result, str):
|
||||
enrichment_text = enrich_result
|
||||
else:
|
||||
enrichment_text = str(enrich_result) if enrich_result else ""
|
||||
|
||||
logo_url = company_info.get('logo_url', '')
|
||||
if logo_url is None:
|
||||
logo_url = ''
|
||||
|
||||
result_entry = {
|
||||
"rank": idx,
|
||||
"id": item_dict.get('id', ''),
|
||||
"webset_id": item_dict.get('webset_id', ''),
|
||||
"source": str(item_dict.get('source', '')),
|
||||
"source_id": item_dict.get('source_id', ''),
|
||||
"url": properties.get('url', ''),
|
||||
"type": properties.get('type', ''),
|
||||
"description": properties.get('description', ''),
|
||||
"company_name": company_info.get('name', ''),
|
||||
"company_location": company_info.get('location', ''),
|
||||
"company_industry": company_info.get('industry', ''),
|
||||
"company_logo_url": str(logo_url) if logo_url else '',
|
||||
"evaluations": evaluations_text,
|
||||
"enrichment_data": enrichment_text,
|
||||
"created_at": str(item_dict.get('created_at', '')),
|
||||
"updated_at": str(item_dict.get('updated_at', ''))
|
||||
}
|
||||
|
||||
formatted_results.append(result_entry)
|
||||
|
||||
base_cost = Decimal('0.45')
|
||||
total_cost = base_cost * TOKEN_PRICE_MULTIPLIER
|
||||
|
||||
if config.ENV_MODE == EnvMode.LOCAL:
|
||||
logger.info("Running in LOCAL mode - skipping billing for company search")
|
||||
cost_deducted_str = f"${total_cost:.2f} (LOCAL - not charged)"
|
||||
else:
|
||||
credits_deducted = await self._deduct_credits(user_id, len(formatted_results), thread_id)
|
||||
if not credits_deducted:
|
||||
return self.fail_response(
|
||||
"Insufficient credits for company search. "
|
||||
f"This search costs ${total_cost:.2f} ({len(formatted_results)} results). "
|
||||
"Please add credits to continue."
|
||||
)
|
||||
cost_deducted_str = f"${total_cost:.2f}"
|
||||
|
||||
output = {
|
||||
"query": query,
|
||||
"total_results": len(formatted_results),
|
||||
"cost_deducted": cost_deducted_str,
|
||||
"results": formatted_results,
|
||||
"enrichment_type": enrichment_description
|
||||
}
|
||||
|
||||
logger.info(f"Successfully completed company search with {len(formatted_results)} results")
|
||||
|
||||
try:
|
||||
json_output = json.dumps(output, indent=2, default=str)
|
||||
return self.success_response(json_output)
|
||||
except Exception as json_error:
|
||||
logger.error(f"Failed to serialize output: {json_error}")
|
||||
summary = f"Found {len(formatted_results)} results for query: {query}"
|
||||
if formatted_results:
|
||||
summary += f"\n\nTop result:\nName: {formatted_results[0].get('company_name', 'Unknown')}\nIndustry: {formatted_results[0].get('company_industry', 'Unknown')}\nLocation: {formatted_results[0].get('company_location', 'Unknown')}"
|
||||
return self.success_response(summary)
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
return self.fail_response("Search timed out. Please try again with a simpler query.")
|
||||
except Exception as e:
|
||||
logger.error(f"Company search failed: {repr(e)}", exc_info=True)
|
||||
return self.fail_response("An error occurred during the search. Please try again.")
|
|
@ -0,0 +1,304 @@
|
|||
from typing import Optional
|
||||
import asyncio
|
||||
import structlog
|
||||
import json
|
||||
from decimal import Decimal
|
||||
from exa_py import Exa
|
||||
from exa_py.websets.types import CreateWebsetParameters, CreateEnrichmentParameters
|
||||
from core.agentpress.tool import Tool, ToolResult, openapi_schema, usage_example
|
||||
from core.utils.config import config, EnvMode
|
||||
from core.utils.logger import logger
|
||||
from core.agentpress.thread_manager import ThreadManager
|
||||
from core.billing.credit_manager import CreditManager
|
||||
from core.billing.config import TOKEN_PRICE_MULTIPLIER
|
||||
from core.services.supabase import DBConnection
|
||||
|
||||
|
||||
class PeopleSearchTool(Tool):
|
||||
def __init__(self, thread_manager: ThreadManager):
|
||||
super().__init__()
|
||||
self.thread_manager = thread_manager
|
||||
self.api_key = config.EXA_API_KEY
|
||||
self.db = DBConnection()
|
||||
self.credit_manager = CreditManager()
|
||||
self.exa_client = None
|
||||
|
||||
if self.api_key:
|
||||
self.exa_client = Exa(self.api_key)
|
||||
logger.info("People Search Tool initialized. Note: This requires an Exa Pro plan for Websets API access.")
|
||||
else:
|
||||
logger.warning("EXA_API_KEY not configured - People Search Tool will not be available")
|
||||
|
||||
async def _get_current_thread_and_user(self) -> tuple[Optional[str], Optional[str]]:
|
||||
try:
|
||||
context_vars = structlog.contextvars.get_contextvars()
|
||||
thread_id = context_vars.get('thread_id')
|
||||
|
||||
if not thread_id:
|
||||
logger.warning("No thread_id in execution context")
|
||||
return None, None
|
||||
|
||||
client = await self.db.client
|
||||
thread = await client.from_('threads').select('account_id').eq('thread_id', thread_id).single().execute()
|
||||
if thread.data:
|
||||
return thread_id, thread.data.get('account_id')
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get thread context: {e}")
|
||||
return None, None
|
||||
|
||||
async def _deduct_credits(self, user_id: str, num_results: int, thread_id: Optional[str] = None) -> bool:
|
||||
base_cost = Decimal('0.45')
|
||||
total_cost = base_cost * TOKEN_PRICE_MULTIPLIER
|
||||
|
||||
try:
|
||||
result = await self.credit_manager.use_credits(
|
||||
account_id=user_id,
|
||||
amount=total_cost,
|
||||
description=f"People search: {num_results} results",
|
||||
thread_id=thread_id
|
||||
)
|
||||
|
||||
if result.get('success'):
|
||||
logger.info(f"Deducted ${total_cost:.2f} for people search ({num_results} results)")
|
||||
return True
|
||||
else:
|
||||
logger.warning(f"Failed to deduct credits: {result.get('error')}")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error deducting credits: {e}")
|
||||
return False
|
||||
|
||||
@openapi_schema({
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "people_search",
|
||||
"description": "Search for people using natural language queries and enrich with LinkedIn profiles. IMPORTANT: Requires Exa Pro plan and costs $0.54 per search (10 results).",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {
|
||||
"type": "string",
|
||||
"description": "Natural language search query describing the people you want to find. Examples: 'CTOs at AI startups in San Francisco', 'Senior Python developers with machine learning experience at Google', 'Marketing managers at Fortune 500 companies in New York'"
|
||||
},
|
||||
"enrichment_description": {
|
||||
"type": "string",
|
||||
"description": "What specific information to find about each person. Default: 'LinkedIn profile URL'",
|
||||
"default": "LinkedIn profile URL"
|
||||
}
|
||||
},
|
||||
"required": ["query"]
|
||||
}
|
||||
}
|
||||
})
|
||||
@usage_example('''
|
||||
<function_calls>
|
||||
<invoke name="people_search">
|
||||
<parameter name="query">Chief Technology Officers at B2B SaaS companies in Silicon Valley</parameter>
|
||||
</invoke>
|
||||
</function_calls>
|
||||
|
||||
<function_calls>
|
||||
<invoke name="people_search">
|
||||
<parameter name="query">Senior Python developers with AI experience at FAANG companies</parameter>
|
||||
<parameter name="enrichment_description">LinkedIn profile and current job title</parameter>
|
||||
</invoke>
|
||||
</function_calls>
|
||||
''')
|
||||
async def people_search(
|
||||
self,
|
||||
query: str,
|
||||
enrichment_description: str = "LinkedIn profile URL"
|
||||
) -> ToolResult:
|
||||
if not self.exa_client:
|
||||
return self.fail_response(
|
||||
"People Search is not available. EXA_API_KEY is not configured. "
|
||||
"Please contact your administrator to enable this feature."
|
||||
)
|
||||
|
||||
if not query:
|
||||
return self.fail_response("Search query is required.")
|
||||
|
||||
thread_id, user_id = await self._get_current_thread_and_user()
|
||||
|
||||
if config.ENV_MODE != EnvMode.LOCAL and (not thread_id or not user_id):
|
||||
return self.fail_response(
|
||||
"No active session context for billing. This tool requires an active agent session."
|
||||
)
|
||||
|
||||
try:
|
||||
logger.info(f"Creating Exa webset for: '{query}' with 10 results")
|
||||
|
||||
enrichment_config = CreateEnrichmentParameters(
|
||||
description=enrichment_description,
|
||||
format="text"
|
||||
)
|
||||
|
||||
webset_params = CreateWebsetParameters(
|
||||
search={
|
||||
"query": query,
|
||||
"count": 10
|
||||
},
|
||||
enrichments=[enrichment_config]
|
||||
)
|
||||
|
||||
try:
|
||||
webset = await asyncio.to_thread(
|
||||
self.exa_client.websets.create,
|
||||
params=webset_params
|
||||
)
|
||||
|
||||
logger.info(f"Webset created with ID: {webset.id}")
|
||||
except Exception as create_error:
|
||||
logger.error(f"Failed to create webset - Error type: {type(create_error).__name__}")
|
||||
try:
|
||||
error_str = str(create_error)
|
||||
logger.error(f"Failed to create webset - Error message: {error_str}")
|
||||
except:
|
||||
error_str = "Unknown error"
|
||||
logger.error(f"Failed to create webset - Could not convert error to string")
|
||||
|
||||
if "401" in error_str:
|
||||
return self.fail_response(
|
||||
"Authentication failed with Exa API. Please check your API key and Pro plan status."
|
||||
)
|
||||
elif "400" in error_str:
|
||||
return self.fail_response(
|
||||
"Invalid request to Exa API. Please check your query format."
|
||||
)
|
||||
else:
|
||||
return self.fail_response(
|
||||
"Failed to create webset. Please try again."
|
||||
)
|
||||
|
||||
logger.info(f"Waiting for webset {webset.id} to complete processing...")
|
||||
try:
|
||||
webset = await asyncio.to_thread(
|
||||
self.exa_client.websets.wait_until_idle,
|
||||
webset.id
|
||||
)
|
||||
logger.info(f"Webset {webset.id} processing complete")
|
||||
except Exception as wait_error:
|
||||
logger.error(f"Error waiting for webset: {type(wait_error).__name__}: {repr(wait_error)}")
|
||||
return self.fail_response("Failed while waiting for search results. Please try again.")
|
||||
|
||||
logger.info(f"Retrieving items from webset {webset.id}...")
|
||||
try:
|
||||
items = await asyncio.to_thread(
|
||||
self.exa_client.websets.items.list,
|
||||
webset_id=webset.id
|
||||
)
|
||||
logger.info(f"Retrieved items from webset")
|
||||
except Exception as items_error:
|
||||
logger.error(f"Error retrieving items: {type(items_error).__name__}: {repr(items_error)}")
|
||||
return self.fail_response("Failed to retrieve search results. Please try again.")
|
||||
|
||||
results = items.data if items else []
|
||||
logger.info(f"Got {len(results)} results from webset")
|
||||
|
||||
formatted_results = []
|
||||
for idx, item in enumerate(results[:10], 1):
|
||||
if hasattr(item, 'model_dump'):
|
||||
item_dict = item.model_dump()
|
||||
elif isinstance(item, dict):
|
||||
item_dict = item
|
||||
else:
|
||||
item_dict = vars(item) if hasattr(item, '__dict__') else {}
|
||||
|
||||
properties = item_dict.get('properties', {})
|
||||
person_info = properties.get('person', {})
|
||||
|
||||
evaluations_text = ""
|
||||
evaluations = item_dict.get('evaluations', [])
|
||||
if evaluations:
|
||||
eval_items = []
|
||||
for eval_item in evaluations:
|
||||
if isinstance(eval_item, dict):
|
||||
criterion = eval_item.get('criterion', '')
|
||||
satisfied = eval_item.get('satisfied', '')
|
||||
if criterion:
|
||||
eval_items.append(f"{criterion}: {satisfied}")
|
||||
evaluations_text = " | ".join(eval_items)
|
||||
|
||||
enrichment_text = ""
|
||||
if 'enrichments' in item_dict and item_dict['enrichments']:
|
||||
enrichments = item_dict['enrichments']
|
||||
if isinstance(enrichments, list) and len(enrichments) > 0:
|
||||
enrichment = enrichments[0]
|
||||
if isinstance(enrichment, dict):
|
||||
enrich_result = enrichment.get('result')
|
||||
if enrich_result is not None:
|
||||
if isinstance(enrich_result, list) and enrich_result:
|
||||
enrichment_text = str(enrich_result[0]) if enrich_result[0] else ""
|
||||
elif isinstance(enrich_result, str):
|
||||
enrichment_text = enrich_result
|
||||
else:
|
||||
enrichment_text = str(enrich_result) if enrich_result else ""
|
||||
|
||||
picture_url = person_info.get('picture_url', '')
|
||||
if picture_url is None:
|
||||
picture_url = ''
|
||||
|
||||
result_entry = {
|
||||
"rank": idx,
|
||||
"id": item_dict.get('id', ''),
|
||||
"webset_id": item_dict.get('webset_id', ''),
|
||||
"source": str(item_dict.get('source', '')),
|
||||
"source_id": item_dict.get('source_id', ''),
|
||||
"url": properties.get('url', ''),
|
||||
"type": properties.get('type', ''),
|
||||
"description": properties.get('description', ''),
|
||||
"person_name": person_info.get('name', ''),
|
||||
"person_location": person_info.get('location', ''),
|
||||
"person_position": person_info.get('position', ''),
|
||||
"person_picture_url": str(picture_url) if picture_url else '',
|
||||
"evaluations": evaluations_text,
|
||||
"enrichment_data": enrichment_text,
|
||||
"created_at": str(item_dict.get('created_at', '')),
|
||||
"updated_at": str(item_dict.get('updated_at', ''))
|
||||
}
|
||||
|
||||
formatted_results.append(result_entry)
|
||||
|
||||
base_cost = Decimal('0.45')
|
||||
total_cost = base_cost * TOKEN_PRICE_MULTIPLIER
|
||||
|
||||
if config.ENV_MODE == EnvMode.LOCAL:
|
||||
logger.info("Running in LOCAL mode - skipping billing for people search")
|
||||
cost_deducted_str = f"${total_cost:.2f} (LOCAL - not charged)"
|
||||
else:
|
||||
credits_deducted = await self._deduct_credits(user_id, len(formatted_results), thread_id)
|
||||
if not credits_deducted:
|
||||
return self.fail_response(
|
||||
"Insufficient credits for people search. "
|
||||
f"This search costs ${total_cost:.2f} ({len(formatted_results)} results). "
|
||||
"Please add credits to continue."
|
||||
)
|
||||
cost_deducted_str = f"${total_cost:.2f}"
|
||||
|
||||
output = {
|
||||
"query": query,
|
||||
"total_results": len(formatted_results),
|
||||
"cost_deducted": cost_deducted_str,
|
||||
"results": formatted_results,
|
||||
"enrichment_type": enrichment_description
|
||||
}
|
||||
|
||||
logger.info(f"Successfully completed people search with {len(formatted_results)} results")
|
||||
|
||||
try:
|
||||
json_output = json.dumps(output, indent=2, default=str)
|
||||
return self.success_response(json_output)
|
||||
except Exception as json_error:
|
||||
logger.error(f"Failed to serialize output: {json_error}")
|
||||
summary = f"Found {len(formatted_results)} results for query: {query}"
|
||||
if formatted_results:
|
||||
summary += f"\n\nTop result:\nName: {formatted_results[0].get('person_name', 'Unknown')}\nPosition: {formatted_results[0].get('person_position', 'Unknown')}\nLocation: {formatted_results[0].get('person_location', 'Unknown')}"
|
||||
return self.success_response(summary)
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
return self.fail_response("Search timed out. Please try again with a simpler query.")
|
||||
except Exception as e:
|
||||
logger.error(f"People search failed: {repr(e)}", exc_info=True)
|
||||
return self.fail_response("An error occurred during the search. Please try again.")
|
|
@ -582,8 +582,9 @@ class WorkflowExecutor:
|
|||
'sb_deploy_tool': ['deploy'],
|
||||
'sb_expose_tool': ['expose_port'],
|
||||
'web_search_tool': ['web_search'],
|
||||
'data_providers_tool': ['get_data_provider_endpoints', 'execute_data_provider_call'],
|
||||
'people_search_tool': ['people_search'],
|
||||
'image_search_tool': ['image_search'],
|
||||
'data_providers_tool': ['get_data_provider_endpoints', 'execute_data_provider_call']
|
||||
}
|
||||
|
||||
for tool_key, tool_names in tool_mapping.items():
|
||||
|
|
|
@ -299,6 +299,7 @@ class Configuration:
|
|||
CLOUDFLARE_API_TOKEN: Optional[str] = None
|
||||
FIRECRAWL_API_KEY: str
|
||||
FIRECRAWL_URL: Optional[str] = "https://api.firecrawl.dev"
|
||||
EXA_API_KEY: Optional[str] = None
|
||||
|
||||
# Stripe configuration
|
||||
STRIPE_SECRET_KEY: Optional[str] = None
|
||||
|
|
|
@ -817,6 +817,36 @@ TOOL_GROUPS: Dict[str, ToolGroup] = {
|
|||
]
|
||||
),
|
||||
|
||||
"people_search_tool": ToolGroup(
|
||||
name="people_search_tool",
|
||||
display_name="People Search",
|
||||
description="Search for people using LinkedIn",
|
||||
tool_class="PeopleSearchTool",
|
||||
methods=[
|
||||
ToolMethod(
|
||||
name="people_search",
|
||||
display_name="People Search",
|
||||
description="Search for people using LinkedIn",
|
||||
enabled=True
|
||||
),
|
||||
]
|
||||
),
|
||||
|
||||
"company_search_tool": ToolGroup(
|
||||
name="company_search_tool",
|
||||
display_name="Company Search",
|
||||
description="Search for companies using natural language queries",
|
||||
tool_class="CompanySearchTool",
|
||||
methods=[
|
||||
ToolMethod(
|
||||
name="company_search",
|
||||
display_name="Company Search",
|
||||
description="Search for companies using natural language queries",
|
||||
enabled=True
|
||||
),
|
||||
]
|
||||
),
|
||||
|
||||
"sb_web_dev_tool": ToolGroup(
|
||||
name="sb_web_dev_tool",
|
||||
display_name="Web Development",
|
||||
|
|
|
@ -43,7 +43,6 @@ export const TOOL_GROUPS: Record<string, ToolGroup> = {
|
|||
],
|
||||
},
|
||||
|
||||
// === SHELL OPERATIONS ===
|
||||
sb_shell_tool: {
|
||||
name: 'sb_shell_tool',
|
||||
displayName: 'Shell Operations',
|
||||
|
@ -80,7 +79,6 @@ export const TOOL_GROUPS: Record<string, ToolGroup> = {
|
|||
],
|
||||
},
|
||||
|
||||
// === WEB SEARCH ===
|
||||
web_search_tool: {
|
||||
name: 'web_search_tool',
|
||||
displayName: 'Web Search',
|
||||
|
@ -105,7 +103,24 @@ export const TOOL_GROUPS: Record<string, ToolGroup> = {
|
|||
],
|
||||
},
|
||||
|
||||
// === VISION & IMAGE ANALYSIS ===
|
||||
people_search_tool: {
|
||||
name: 'people_search_tool',
|
||||
displayName: 'Web Search',
|
||||
description: 'Search for people using LinkedIn',
|
||||
icon: 'Globe',
|
||||
color: 'bg-indigo-100 dark:bg-indigo-800/50',
|
||||
toolClass: 'PeopleSearchTool',
|
||||
enabled: true,
|
||||
methods: [
|
||||
{
|
||||
name: 'people_search',
|
||||
displayName: 'People Search',
|
||||
description: 'Search for people using LinkedIn',
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
sb_vision_tool: {
|
||||
name: 'sb_vision_tool',
|
||||
displayName: 'Vision & Image Analysis',
|
||||
|
@ -130,7 +145,6 @@ export const TOOL_GROUPS: Record<string, ToolGroup> = {
|
|||
],
|
||||
},
|
||||
|
||||
// === IMAGE EDITING ===
|
||||
sb_image_edit_tool: {
|
||||
name: 'sb_image_edit_tool',
|
||||
displayName: 'Image Editing',
|
||||
|
@ -149,7 +163,6 @@ export const TOOL_GROUPS: Record<string, ToolGroup> = {
|
|||
],
|
||||
},
|
||||
|
||||
// === BROWSER AUTOMATION ===
|
||||
browser_tool: {
|
||||
name: 'browser_tool',
|
||||
displayName: 'Browser Automation',
|
||||
|
|
|
@ -0,0 +1,279 @@
|
|||
import React from 'react';
|
||||
import {
|
||||
Building2,
|
||||
CheckCircle,
|
||||
AlertTriangle,
|
||||
ExternalLink,
|
||||
MapPin,
|
||||
Globe,
|
||||
Award,
|
||||
Info,
|
||||
Building
|
||||
} from 'lucide-react';
|
||||
import { ToolViewProps } from '../types';
|
||||
import { formatTimestamp, getToolTitle } from '../utils';
|
||||
import { truncateString } from '@/lib/utils';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { LoadingState } from '../shared/LoadingState';
|
||||
import { extractCompanySearchData } from './_utils';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export function CompanySearchToolView({
|
||||
name = 'company-search',
|
||||
assistantContent,
|
||||
toolContent,
|
||||
assistantTimestamp,
|
||||
toolTimestamp,
|
||||
isSuccess = true,
|
||||
isStreaming = false,
|
||||
}: ToolViewProps) {
|
||||
const {
|
||||
query,
|
||||
total_results,
|
||||
cost_deducted,
|
||||
results,
|
||||
actualIsSuccess,
|
||||
actualToolTimestamp,
|
||||
actualAssistantTimestamp
|
||||
} = extractCompanySearchData(
|
||||
assistantContent,
|
||||
toolContent,
|
||||
isSuccess,
|
||||
toolTimestamp,
|
||||
assistantTimestamp
|
||||
);
|
||||
|
||||
const toolTitle = getToolTitle(name);
|
||||
|
||||
const parseEvaluations = (evaluationsText: string) => {
|
||||
if (!evaluationsText) return [];
|
||||
return evaluationsText.split(' | ').map(item => {
|
||||
const [criterion, satisfied] = item.split(': ');
|
||||
return { criterion, satisfied };
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="gap-0 flex border shadow-none border-t border-b-0 border-x-0 p-0 rounded-none flex-col h-full overflow-hidden bg-card">
|
||||
<CardHeader className="h-14 bg-zinc-50/80 dark:bg-zinc-900/80 backdrop-blur-sm border-b p-2 px-4 space-y-2">
|
||||
<div className="flex flex-row items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative p-2 rounded-xl bg-blue-500/20 border border-blue-500/20">
|
||||
<Building2 className="w-5 h-5 text-blue-500 dark:text-blue-400" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-base font-medium text-zinc-900 dark:text-zinc-100">
|
||||
{toolTitle}
|
||||
</CardTitle>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isStreaming && (
|
||||
<div className="flex items-center gap-2">
|
||||
{cost_deducted && (
|
||||
<Badge variant="outline" className="text-xs font-normal text-orange-600 dark:text-orange-400">
|
||||
{cost_deducted}
|
||||
</Badge>
|
||||
)}
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={
|
||||
actualIsSuccess
|
||||
? "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300"
|
||||
: "bg-rose-100 text-rose-700 dark:bg-rose-900/30 dark:text-rose-300"
|
||||
}
|
||||
>
|
||||
{actualIsSuccess ? (
|
||||
<CheckCircle className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<AlertTriangle className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{actualIsSuccess ? 'Search completed' : 'Search failed'}
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="p-0 h-full flex-1 overflow-hidden relative">
|
||||
{isStreaming && results.length === 0 ? (
|
||||
<LoadingState
|
||||
icon={Building2}
|
||||
iconColor="text-primary"
|
||||
bgColor="bg-primary/10"
|
||||
title="Searching for companies"
|
||||
filePath={query}
|
||||
showProgress={true}
|
||||
/>
|
||||
) : results.length > 0 ? (
|
||||
<ScrollArea className="h-full w-full">
|
||||
<TooltipProvider>
|
||||
<div className="p-4">
|
||||
<div className="text-sm font-medium text-zinc-800 dark:text-zinc-200 mb-3 flex items-center justify-between">
|
||||
<span>Found {total_results} companies</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{results.map((result, idx) => {
|
||||
const evaluations = parseEvaluations(result.evaluations);
|
||||
const satisfiedCount = evaluations.filter(e => e.satisfied?.includes('Satisfied.yes')).length;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={result.id || idx}
|
||||
className="group relative border-b border-border last:border-b-0 pb-3 last:pb-0"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex-shrink-0 mt-0.5">
|
||||
{result.company_logo_url && result.company_logo_url !== '' ? (
|
||||
<img
|
||||
src={result.company_logo_url}
|
||||
alt={result.company_name}
|
||||
className="w-8 h-8 rounded-lg object-cover"
|
||||
onError={(e) => {
|
||||
const target = e.target as HTMLImageElement;
|
||||
target.style.display = 'none';
|
||||
const fallback = target.nextSibling as HTMLElement;
|
||||
if (fallback) fallback.style.display = 'flex';
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<div
|
||||
className="w-8 h-8 rounded bg-muted flex items-center justify-center"
|
||||
style={{ display: result.company_logo_url ? 'none' : 'flex' }}
|
||||
>
|
||||
<Building2 className="w-4 h-4 text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0 space-y-1">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<h3 className="font-medium text-sm text-foreground truncate">
|
||||
{result.company_name || 'Unknown Company'}
|
||||
</h3>
|
||||
<div className="flex items-center gap-3 text-xs text-muted-foreground mt-1">
|
||||
{result.company_industry && (
|
||||
<span className="truncate">{result.company_industry}</span>
|
||||
)}
|
||||
{result.company_industry && result.company_location && (
|
||||
<span>•</span>
|
||||
)}
|
||||
{result.company_location && (
|
||||
<span className="truncate">{result.company_location}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
{evaluations.length > 0 && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"text-xs h-5 px-1.5",
|
||||
satisfiedCount > 0
|
||||
? "border-emerald-200 text-emerald-700 dark:border-emerald-800 dark:text-emerald-300"
|
||||
: "border-muted-foreground/20"
|
||||
)}
|
||||
>
|
||||
{satisfiedCount}/{evaluations.length}
|
||||
</Badge>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-xs">
|
||||
<div className="space-y-1">
|
||||
<p className="font-medium text-xs">Criteria match:</p>
|
||||
{evaluations.map((evaluation, evalIdx) => (
|
||||
<div key={evalIdx} className="text-xs flex items-center gap-2">
|
||||
<div className={cn(
|
||||
"w-1.5 h-1.5 rounded-full flex-shrink-0",
|
||||
evaluation.satisfied?.includes('Satisfied.yes')
|
||||
? "bg-emerald-400"
|
||||
: "bg-muted-foreground"
|
||||
)} />
|
||||
<span className="truncate">{evaluation.criterion}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0 opacity-60 hover:opacity-100 transition-opacity"
|
||||
asChild
|
||||
>
|
||||
<a
|
||||
href={result.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title="View company details"
|
||||
>
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{result.description && (
|
||||
<p className="text-xs text-muted-foreground leading-relaxed line-clamp-2">
|
||||
{truncateString(result.description, 120)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</ScrollArea>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center h-full py-12 px-6 bg-zinc-50/50 dark:bg-zinc-900/50">
|
||||
<div className="w-20 h-20 rounded-full flex items-center justify-center mb-6 bg-muted">
|
||||
<Building2 className="h-10 w-10 text-muted-foreground" />
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold mb-2 text-zinc-900 dark:text-zinc-100">
|
||||
No Companies Found
|
||||
</h3>
|
||||
<div className="bg-zinc-50 dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-800 rounded-lg p-4 w-full max-w-md text-center mb-4">
|
||||
<code className="text-sm font-mono text-zinc-700 dark:text-zinc-300 break-all">
|
||||
{query || 'Unknown query'}
|
||||
</code>
|
||||
</div>
|
||||
<p className="text-sm text-zinc-500 dark:text-zinc-400">
|
||||
Try refining your search criteria for better results
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
<div className="px-4 py-2 h-10 bg-zinc-50/90 dark:bg-zinc-900/90 backdrop-blur-sm border-t border-zinc-200 dark:border-zinc-800 flex justify-between items-center gap-4">
|
||||
<div className="h-full flex items-center gap-2 text-sm text-zinc-500 dark:text-zinc-400">
|
||||
{!isStreaming && results.length > 0 && (
|
||||
<Badge variant="outline" className="h-6 py-0.5 text-xs">
|
||||
<Building2 className="h-3 w-3 mr-1" />
|
||||
{results.length} results
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-zinc-500 dark:text-zinc-400">
|
||||
{actualToolTimestamp && !isStreaming
|
||||
? formatTimestamp(actualToolTimestamp)
|
||||
: actualAssistantTimestamp
|
||||
? formatTimestamp(actualAssistantTimestamp)
|
||||
: ''}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,236 @@
|
|||
import { extractToolData } from '../utils';
|
||||
|
||||
export interface CompanySearchResult {
|
||||
rank: number;
|
||||
id: string;
|
||||
webset_id: string;
|
||||
source: string;
|
||||
source_id: string;
|
||||
url: string;
|
||||
type: string;
|
||||
description: string;
|
||||
company_name: string;
|
||||
company_location: string;
|
||||
company_industry: string;
|
||||
company_logo_url: string;
|
||||
evaluations: string;
|
||||
enrichment_data: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface CompanySearchData {
|
||||
query: string | null;
|
||||
total_results: number;
|
||||
cost_deducted: string;
|
||||
enrichment_type: string;
|
||||
results: CompanySearchResult[];
|
||||
success?: boolean;
|
||||
timestamp?: string;
|
||||
}
|
||||
|
||||
const parseContent = (content: any): any => {
|
||||
if (typeof content === 'string') {
|
||||
try {
|
||||
return JSON.parse(content);
|
||||
} catch (e) {
|
||||
return content;
|
||||
}
|
||||
}
|
||||
return content;
|
||||
};
|
||||
|
||||
const extractFromNewFormat = (content: any): CompanySearchData => {
|
||||
const parsedContent = parseContent(content);
|
||||
|
||||
if (!parsedContent || typeof parsedContent !== 'object') {
|
||||
return {
|
||||
query: null,
|
||||
total_results: 0,
|
||||
cost_deducted: '$0.54',
|
||||
enrichment_type: '',
|
||||
results: [],
|
||||
success: undefined,
|
||||
timestamp: undefined
|
||||
};
|
||||
}
|
||||
|
||||
if ('tool_execution' in parsedContent && typeof parsedContent.tool_execution === 'object') {
|
||||
const toolExecution = parsedContent.tool_execution;
|
||||
const args = toolExecution.arguments || {};
|
||||
|
||||
let parsedOutput = toolExecution.result?.output;
|
||||
if (typeof parsedOutput === 'string') {
|
||||
try {
|
||||
parsedOutput = JSON.parse(parsedOutput);
|
||||
} catch (e) {
|
||||
}
|
||||
}
|
||||
parsedOutput = parsedOutput || {};
|
||||
|
||||
const extractedData = {
|
||||
query: args.query || parsedOutput?.query || null,
|
||||
total_results: parsedOutput?.total_results || 0,
|
||||
cost_deducted: parsedOutput?.cost_deducted || '$0.54',
|
||||
enrichment_type: args.enrichment_description || parsedOutput?.enrichment_type || '',
|
||||
results: parsedOutput?.results?.map((result: any) => ({
|
||||
rank: result.rank || 0,
|
||||
id: result.id || '',
|
||||
webset_id: result.webset_id || '',
|
||||
source: result.source || '',
|
||||
source_id: result.source_id || '',
|
||||
url: result.url || '',
|
||||
type: result.type || 'company',
|
||||
description: result.description || '',
|
||||
company_name: result.company_name || '',
|
||||
company_location: result.company_location || '',
|
||||
company_industry: result.company_industry || '',
|
||||
company_logo_url: result.company_logo_url || '',
|
||||
evaluations: result.evaluations || '',
|
||||
enrichment_data: result.enrichment_data || '',
|
||||
created_at: result.created_at || '',
|
||||
updated_at: result.updated_at || ''
|
||||
})) || [],
|
||||
success: toolExecution.result?.success,
|
||||
timestamp: toolExecution.execution_details?.timestamp
|
||||
};
|
||||
return extractedData;
|
||||
}
|
||||
|
||||
if ('query' in parsedContent && 'results' in parsedContent) {
|
||||
return {
|
||||
query: parsedContent.query || null,
|
||||
total_results: parsedContent.total_results || 0,
|
||||
cost_deducted: parsedContent.cost_deducted || '$0.54',
|
||||
enrichment_type: parsedContent.enrichment_type || '',
|
||||
results: parsedContent.results?.map((result: any) => ({
|
||||
rank: result.rank || 0,
|
||||
id: result.id || '',
|
||||
webset_id: result.webset_id || '',
|
||||
source: result.source || '',
|
||||
source_id: result.source_id || '',
|
||||
url: result.url || '',
|
||||
type: result.type || 'company',
|
||||
description: result.description || '',
|
||||
company_name: result.company_name || '',
|
||||
company_location: result.company_location || '',
|
||||
company_industry: result.company_industry || '',
|
||||
company_logo_url: result.company_logo_url || '',
|
||||
evaluations: result.evaluations || '',
|
||||
enrichment_data: result.enrichment_data || '',
|
||||
created_at: result.created_at || '',
|
||||
updated_at: result.updated_at || ''
|
||||
})) || [],
|
||||
success: true,
|
||||
timestamp: undefined
|
||||
};
|
||||
}
|
||||
|
||||
if ('role' in parsedContent && 'content' in parsedContent) {
|
||||
return extractFromNewFormat(parsedContent.content);
|
||||
}
|
||||
|
||||
return {
|
||||
query: null,
|
||||
total_results: 0,
|
||||
cost_deducted: '$0.54',
|
||||
enrichment_type: '',
|
||||
results: [],
|
||||
success: undefined,
|
||||
timestamp: undefined
|
||||
};
|
||||
};
|
||||
|
||||
const extractFromLegacyFormat = (content: any): Omit<CompanySearchData, 'success' | 'timestamp'> => {
|
||||
const toolData = extractToolData(content);
|
||||
|
||||
if (toolData.toolResult) {
|
||||
const args = toolData.arguments || {};
|
||||
return {
|
||||
query: toolData.query || args.query || null,
|
||||
total_results: 0,
|
||||
cost_deducted: '$0.54',
|
||||
enrichment_type: args.enrichment_description || '',
|
||||
results: []
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
query: null,
|
||||
total_results: 0,
|
||||
cost_deducted: '$0.54',
|
||||
enrichment_type: '',
|
||||
results: []
|
||||
};
|
||||
};
|
||||
|
||||
export function extractCompanySearchData(
|
||||
assistantContent: any,
|
||||
toolContent: any,
|
||||
isSuccess: boolean,
|
||||
toolTimestamp?: string,
|
||||
assistantTimestamp?: string
|
||||
): {
|
||||
query: string | null;
|
||||
total_results: number;
|
||||
cost_deducted: string;
|
||||
enrichment_type: string;
|
||||
results: CompanySearchResult[];
|
||||
actualIsSuccess: boolean;
|
||||
actualToolTimestamp?: string;
|
||||
actualAssistantTimestamp?: string;
|
||||
} {
|
||||
let data: CompanySearchData = {
|
||||
query: null,
|
||||
total_results: 0,
|
||||
cost_deducted: '$0.54',
|
||||
enrichment_type: '',
|
||||
results: []
|
||||
};
|
||||
let actualIsSuccess = isSuccess;
|
||||
let actualToolTimestamp = toolTimestamp;
|
||||
let actualAssistantTimestamp = assistantTimestamp;
|
||||
|
||||
const assistantNewFormat = extractFromNewFormat(assistantContent);
|
||||
const toolNewFormat = extractFromNewFormat(toolContent);
|
||||
|
||||
if (assistantNewFormat.query || assistantNewFormat.results.length > 0) {
|
||||
data = assistantNewFormat;
|
||||
if (assistantNewFormat.success !== undefined) {
|
||||
actualIsSuccess = assistantNewFormat.success;
|
||||
}
|
||||
if (assistantNewFormat.timestamp) {
|
||||
actualAssistantTimestamp = assistantNewFormat.timestamp;
|
||||
}
|
||||
} else if (toolNewFormat.query || toolNewFormat.results.length > 0) {
|
||||
data = toolNewFormat;
|
||||
if (toolNewFormat.success !== undefined) {
|
||||
actualIsSuccess = toolNewFormat.success;
|
||||
}
|
||||
if (toolNewFormat.timestamp) {
|
||||
actualToolTimestamp = toolNewFormat.timestamp;
|
||||
}
|
||||
} else {
|
||||
const assistantLegacy = extractFromLegacyFormat(assistantContent);
|
||||
const toolLegacy = extractFromLegacyFormat(toolContent);
|
||||
|
||||
data = {
|
||||
...assistantLegacy,
|
||||
...toolLegacy,
|
||||
query: assistantLegacy.query || toolLegacy.query,
|
||||
success: undefined,
|
||||
timestamp: undefined
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
query: data.query,
|
||||
total_results: data.total_results,
|
||||
cost_deducted: data.cost_deducted,
|
||||
enrichment_type: data.enrichment_type,
|
||||
results: data.results,
|
||||
actualIsSuccess,
|
||||
actualToolTimestamp,
|
||||
actualAssistantTimestamp
|
||||
};
|
||||
}
|
|
@ -0,0 +1,319 @@
|
|||
import React from 'react';
|
||||
import {
|
||||
Users,
|
||||
CheckCircle,
|
||||
AlertTriangle,
|
||||
ExternalLink,
|
||||
MapPin,
|
||||
Briefcase,
|
||||
User,
|
||||
Globe,
|
||||
Award,
|
||||
Mail,
|
||||
Info
|
||||
} from 'lucide-react';
|
||||
import { ToolViewProps } from '../types';
|
||||
import { formatTimestamp, getToolTitle } from '../utils';
|
||||
import { truncateString } from '@/lib/utils';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { LoadingState } from '../shared/LoadingState';
|
||||
import { extractPeopleSearchData } from './_utils';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export function PeopleSearchToolView({
|
||||
name = 'people-search',
|
||||
assistantContent,
|
||||
toolContent,
|
||||
assistantTimestamp,
|
||||
toolTimestamp,
|
||||
isSuccess = true,
|
||||
isStreaming = false,
|
||||
}: ToolViewProps) {
|
||||
const {
|
||||
query,
|
||||
total_results,
|
||||
cost_deducted,
|
||||
results,
|
||||
actualIsSuccess,
|
||||
actualToolTimestamp,
|
||||
actualAssistantTimestamp
|
||||
} = extractPeopleSearchData(
|
||||
assistantContent,
|
||||
toolContent,
|
||||
isSuccess,
|
||||
toolTimestamp,
|
||||
assistantTimestamp
|
||||
);
|
||||
|
||||
const toolTitle = getToolTitle(name);
|
||||
|
||||
const parseEvaluations = (evaluationsText: string) => {
|
||||
if (!evaluationsText) return [];
|
||||
return evaluationsText.split(' | ').map(item => {
|
||||
const [criterion, satisfied] = item.split(': ');
|
||||
return { criterion, satisfied };
|
||||
});
|
||||
};
|
||||
|
||||
const parseEnrichmentData = (enrichmentData: string) => {
|
||||
if (!enrichmentData) return {};
|
||||
const parts = enrichmentData.split(' | ');
|
||||
return {
|
||||
linkedinUrl: parts[0] || '',
|
||||
email: parts[1] !== 'null' ? parts[1] : '',
|
||||
position: parts[2] || '',
|
||||
company: parts[3] || ''
|
||||
};
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="gap-0 flex border shadow-none border-t border-b-0 border-x-0 p-0 rounded-none flex-col h-full overflow-hidden bg-card">
|
||||
<CardHeader className="h-14 bg-zinc-50/80 dark:bg-zinc-900/80 backdrop-blur-sm border-b p-2 px-4 space-y-2">
|
||||
<div className="flex flex-row items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative p-2 rounded-xl bg-purple-500/20 border border-purple-500/20">
|
||||
<Users className="w-5 h-5 text-purple-500 dark:text-purple-400" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-base font-medium text-zinc-900 dark:text-zinc-100">
|
||||
{toolTitle}
|
||||
</CardTitle>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isStreaming && (
|
||||
<div className="flex items-center gap-2">
|
||||
{cost_deducted && (
|
||||
<Badge variant="outline" className="text-xs font-normal text-orange-600 dark:text-orange-400">
|
||||
{cost_deducted}
|
||||
</Badge>
|
||||
)}
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={
|
||||
actualIsSuccess
|
||||
? "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300"
|
||||
: "bg-rose-100 text-rose-700 dark:bg-rose-900/30 dark:text-rose-300"
|
||||
}
|
||||
>
|
||||
{actualIsSuccess ? (
|
||||
<CheckCircle className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<AlertTriangle className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{actualIsSuccess ? 'Search completed' : 'Search failed'}
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="p-0 h-full flex-1 overflow-hidden relative">
|
||||
{isStreaming && results.length === 0 ? (
|
||||
<LoadingState
|
||||
icon={Users}
|
||||
iconColor="text-primary"
|
||||
bgColor="bg-primary/10"
|
||||
title="Searching for people"
|
||||
filePath={query}
|
||||
showProgress={true}
|
||||
/>
|
||||
) : results.length > 0 ? (
|
||||
<ScrollArea className="h-full w-full">
|
||||
<TooltipProvider>
|
||||
<div className="p-3">
|
||||
<div className="text-sm font-medium text-zinc-800 dark:text-zinc-200 mb-3 flex items-center justify-between">
|
||||
<span>Found {total_results} people</span>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
{results.map((result, idx) => {
|
||||
const evaluations = parseEvaluations(result.evaluations);
|
||||
const enrichment = parseEnrichmentData(result.enrichment_data);
|
||||
const satisfiedCount = evaluations.filter(e => e.satisfied?.includes('Satisfied.yes')).length;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={result.id || idx}
|
||||
className="group bg-card border rounded-lg hover:border-primary/30"
|
||||
>
|
||||
<div className="p-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex-shrink-0">
|
||||
{result.person_picture_url && result.person_picture_url !== '' ? (
|
||||
<img
|
||||
src={result.person_picture_url}
|
||||
alt={result.person_name}
|
||||
className="w-10 h-10 rounded-full object-cover border border-zinc-200 dark:border-zinc-700"
|
||||
onError={(e) => {
|
||||
const target = e.target as HTMLImageElement;
|
||||
target.src = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='40' height='40' viewBox='0 0 24 24' fill='none' stroke='%23888' stroke-width='1.5'%3E%3Cpath d='M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2'%3E%3C/path%3E%3Ccircle cx='12' cy='7' r='4'%3E%3C/circle%3E%3C/svg%3E";
|
||||
target.classList.add("bg-zinc-100", "dark:bg-zinc-800", "p-2");
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-10 h-10 rounded-full bg-muted flex items-center justify-center border">
|
||||
<User className="w-5 h-5 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-sm font-semibold text-zinc-900 dark:text-zinc-100 truncate">
|
||||
{result.person_name || 'Unknown Person'}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
{enrichment.email && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<Badge variant="secondary" className="text-xs px-1.5 py-0 h-5">
|
||||
<Mail className="w-3 h-3" />
|
||||
</Badge>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{enrichment.email}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{evaluations.length > 0 && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<Badge
|
||||
variant={satisfiedCount > 0 ? "default" : "secondary"}
|
||||
className={cn(
|
||||
"text-xs px-1.5 py-0 h-5",
|
||||
satisfiedCount > 0
|
||||
? "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300"
|
||||
: "bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300"
|
||||
)}
|
||||
>
|
||||
<Award className="w-3 h-3 mr-1" />
|
||||
{satisfiedCount}/{evaluations.length}
|
||||
</Badge>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-xs">
|
||||
<div className="space-y-1">
|
||||
<p className="font-medium">Evaluation Criteria:</p>
|
||||
{evaluations.map((evaluation, evalIdx) => (
|
||||
<div key={evalIdx} className="text-xs flex items-center gap-2">
|
||||
<div className={cn(
|
||||
"w-2 h-2 rounded-full flex-shrink-0",
|
||||
evaluation.satisfied?.includes('Satisfied.yes')
|
||||
? "bg-emerald-400"
|
||||
: "bg-gray-400"
|
||||
)} />
|
||||
<span>{evaluation.criterion}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{result.description && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<Info className="w-4 h-4 text-muted-foreground hover:text-primary transition-colors" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-sm">
|
||||
<p className="text-xs whitespace-pre-wrap">{truncateString(result.description, 300)}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between text-xs text-zinc-500 dark:text-zinc-400">
|
||||
<div className="flex items-center gap-3 min-w-0 flex-1">
|
||||
{result.person_position && (
|
||||
<div className="flex items-center gap-1 truncate">
|
||||
<Briefcase className="h-3 w-3 flex-shrink-0" />
|
||||
<span className="truncate">{truncateString(result.person_position, 30)}</span>
|
||||
</div>
|
||||
)}
|
||||
{result.person_location && (
|
||||
<div className="flex items-center gap-1 truncate">
|
||||
<MapPin className="h-3 w-3 flex-shrink-0" />
|
||||
<span className="truncate">{truncateString(result.person_location, 25)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-2 text-xs opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
asChild
|
||||
>
|
||||
<a
|
||||
href={result.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Globe className="h-3 w-3" />
|
||||
View
|
||||
<ExternalLink className="h-3 w-3 ml-1" />
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</ScrollArea>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center h-full py-12 px-6 bg-zinc-50/50 dark:bg-zinc-900/50">
|
||||
<div className="w-20 h-20 rounded-full flex items-center justify-center mb-6 bg-muted">
|
||||
<Users className="h-10 w-10 text-muted-foreground" />
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold mb-2 text-zinc-900 dark:text-zinc-100">
|
||||
No People Found
|
||||
</h3>
|
||||
<div className="bg-zinc-50 dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-800 rounded-lg p-4 w-full max-w-md text-center mb-4 shadow-sm">
|
||||
<code className="text-sm font-mono text-zinc-700 dark:text-zinc-300 break-all">
|
||||
{query || 'Unknown query'}
|
||||
</code>
|
||||
</div>
|
||||
<p className="text-sm text-zinc-500 dark:text-zinc-400">
|
||||
Try refining your search criteria for better results
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
<div className="px-4 py-2 h-10 bg-zinc-50/90 dark:bg-zinc-900/90 backdrop-blur-sm border-t border-zinc-200 dark:border-zinc-800 flex justify-between items-center gap-4">
|
||||
<div className="h-full flex items-center gap-2 text-sm text-zinc-500 dark:text-zinc-400">
|
||||
{!isStreaming && results.length > 0 && (
|
||||
<Badge variant="outline" className="h-6 py-0.5 text-xs">
|
||||
<Users className="h-3 w-3 mr-1" />
|
||||
{results.length} results
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-zinc-500 dark:text-zinc-400">
|
||||
{actualToolTimestamp && !isStreaming
|
||||
? formatTimestamp(actualToolTimestamp)
|
||||
: actualAssistantTimestamp
|
||||
? formatTimestamp(actualAssistantTimestamp)
|
||||
: ''}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,236 @@
|
|||
import { extractToolData } from '../utils';
|
||||
|
||||
export interface PeopleSearchResult {
|
||||
rank: number;
|
||||
id: string;
|
||||
webset_id: string;
|
||||
source: string;
|
||||
source_id: string;
|
||||
url: string;
|
||||
type: string;
|
||||
description: string;
|
||||
person_name: string;
|
||||
person_location: string;
|
||||
person_position: string;
|
||||
person_picture_url: string;
|
||||
evaluations: string;
|
||||
enrichment_data: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface PeopleSearchData {
|
||||
query: string | null;
|
||||
total_results: number;
|
||||
cost_deducted: string;
|
||||
enrichment_type: string;
|
||||
results: PeopleSearchResult[];
|
||||
success?: boolean;
|
||||
timestamp?: string;
|
||||
}
|
||||
|
||||
const parseContent = (content: any): any => {
|
||||
if (typeof content === 'string') {
|
||||
try {
|
||||
return JSON.parse(content);
|
||||
} catch (e) {
|
||||
return content;
|
||||
}
|
||||
}
|
||||
return content;
|
||||
};
|
||||
|
||||
const extractFromNewFormat = (content: any): PeopleSearchData => {
|
||||
const parsedContent = parseContent(content);
|
||||
|
||||
if (!parsedContent || typeof parsedContent !== 'object') {
|
||||
return {
|
||||
query: null,
|
||||
total_results: 0,
|
||||
cost_deducted: '$0.54',
|
||||
enrichment_type: '',
|
||||
results: [],
|
||||
success: undefined,
|
||||
timestamp: undefined
|
||||
};
|
||||
}
|
||||
|
||||
if ('tool_execution' in parsedContent && typeof parsedContent.tool_execution === 'object') {
|
||||
const toolExecution = parsedContent.tool_execution;
|
||||
const args = toolExecution.arguments || {};
|
||||
|
||||
let parsedOutput = toolExecution.result?.output;
|
||||
if (typeof parsedOutput === 'string') {
|
||||
try {
|
||||
parsedOutput = JSON.parse(parsedOutput);
|
||||
} catch (e) {
|
||||
}
|
||||
}
|
||||
parsedOutput = parsedOutput || {};
|
||||
|
||||
const extractedData = {
|
||||
query: args.query || parsedOutput?.query || null,
|
||||
total_results: parsedOutput?.total_results || 0,
|
||||
cost_deducted: parsedOutput?.cost_deducted || '$0.54',
|
||||
enrichment_type: args.enrichment_description || parsedOutput?.enrichment_type || '',
|
||||
results: parsedOutput?.results?.map((result: any) => ({
|
||||
rank: result.rank || 0,
|
||||
id: result.id || '',
|
||||
webset_id: result.webset_id || '',
|
||||
source: result.source || '',
|
||||
source_id: result.source_id || '',
|
||||
url: result.url || '',
|
||||
type: result.type || 'person',
|
||||
description: result.description || '',
|
||||
person_name: result.person_name || '',
|
||||
person_location: result.person_location || '',
|
||||
person_position: result.person_position || '',
|
||||
person_picture_url: result.person_picture_url || '',
|
||||
evaluations: result.evaluations || '',
|
||||
enrichment_data: result.enrichment_data || '',
|
||||
created_at: result.created_at || '',
|
||||
updated_at: result.updated_at || ''
|
||||
})) || [],
|
||||
success: toolExecution.result?.success,
|
||||
timestamp: toolExecution.execution_details?.timestamp
|
||||
};
|
||||
return extractedData;
|
||||
}
|
||||
|
||||
if ('query' in parsedContent && 'results' in parsedContent) {
|
||||
return {
|
||||
query: parsedContent.query || null,
|
||||
total_results: parsedContent.total_results || 0,
|
||||
cost_deducted: parsedContent.cost_deducted || '$0.54',
|
||||
enrichment_type: parsedContent.enrichment_type || '',
|
||||
results: parsedContent.results?.map((result: any) => ({
|
||||
rank: result.rank || 0,
|
||||
id: result.id || '',
|
||||
webset_id: result.webset_id || '',
|
||||
source: result.source || '',
|
||||
source_id: result.source_id || '',
|
||||
url: result.url || '',
|
||||
type: result.type || 'person',
|
||||
description: result.description || '',
|
||||
person_name: result.person_name || '',
|
||||
person_location: result.person_location || '',
|
||||
person_position: result.person_position || '',
|
||||
person_picture_url: result.person_picture_url || '',
|
||||
evaluations: result.evaluations || '',
|
||||
enrichment_data: result.enrichment_data || '',
|
||||
created_at: result.created_at || '',
|
||||
updated_at: result.updated_at || ''
|
||||
})) || [],
|
||||
success: true,
|
||||
timestamp: undefined
|
||||
};
|
||||
}
|
||||
|
||||
if ('role' in parsedContent && 'content' in parsedContent) {
|
||||
return extractFromNewFormat(parsedContent.content);
|
||||
}
|
||||
|
||||
return {
|
||||
query: null,
|
||||
total_results: 0,
|
||||
cost_deducted: '$0.54',
|
||||
enrichment_type: '',
|
||||
results: [],
|
||||
success: undefined,
|
||||
timestamp: undefined
|
||||
};
|
||||
};
|
||||
|
||||
const extractFromLegacyFormat = (content: any): Omit<PeopleSearchData, 'success' | 'timestamp'> => {
|
||||
const toolData = extractToolData(content);
|
||||
|
||||
if (toolData.toolResult) {
|
||||
const args = toolData.arguments || {};
|
||||
return {
|
||||
query: toolData.query || args.query || null,
|
||||
total_results: 0,
|
||||
cost_deducted: '$0.54',
|
||||
enrichment_type: args.enrichment_description || '',
|
||||
results: []
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
query: null,
|
||||
total_results: 0,
|
||||
cost_deducted: '$0.54',
|
||||
enrichment_type: '',
|
||||
results: []
|
||||
};
|
||||
};
|
||||
|
||||
export function extractPeopleSearchData(
|
||||
assistantContent: any,
|
||||
toolContent: any,
|
||||
isSuccess: boolean,
|
||||
toolTimestamp?: string,
|
||||
assistantTimestamp?: string
|
||||
): {
|
||||
query: string | null;
|
||||
total_results: number;
|
||||
cost_deducted: string;
|
||||
enrichment_type: string;
|
||||
results: PeopleSearchResult[];
|
||||
actualIsSuccess: boolean;
|
||||
actualToolTimestamp?: string;
|
||||
actualAssistantTimestamp?: string;
|
||||
} {
|
||||
let data: PeopleSearchData = {
|
||||
query: null,
|
||||
total_results: 0,
|
||||
cost_deducted: '$0.54',
|
||||
enrichment_type: '',
|
||||
results: []
|
||||
};
|
||||
let actualIsSuccess = isSuccess;
|
||||
let actualToolTimestamp = toolTimestamp;
|
||||
let actualAssistantTimestamp = assistantTimestamp;
|
||||
|
||||
const assistantNewFormat = extractFromNewFormat(assistantContent);
|
||||
const toolNewFormat = extractFromNewFormat(toolContent);
|
||||
|
||||
if (assistantNewFormat.query || assistantNewFormat.results.length > 0) {
|
||||
data = assistantNewFormat;
|
||||
if (assistantNewFormat.success !== undefined) {
|
||||
actualIsSuccess = assistantNewFormat.success;
|
||||
}
|
||||
if (assistantNewFormat.timestamp) {
|
||||
actualAssistantTimestamp = assistantNewFormat.timestamp;
|
||||
}
|
||||
} else if (toolNewFormat.query || toolNewFormat.results.length > 0) {
|
||||
data = toolNewFormat;
|
||||
if (toolNewFormat.success !== undefined) {
|
||||
actualIsSuccess = toolNewFormat.success;
|
||||
}
|
||||
if (toolNewFormat.timestamp) {
|
||||
actualToolTimestamp = toolNewFormat.timestamp;
|
||||
}
|
||||
} else {
|
||||
const assistantLegacy = extractFromLegacyFormat(assistantContent);
|
||||
const toolLegacy = extractFromLegacyFormat(toolContent);
|
||||
|
||||
data = {
|
||||
...assistantLegacy,
|
||||
...toolLegacy,
|
||||
query: assistantLegacy.query || toolLegacy.query,
|
||||
success: undefined,
|
||||
timestamp: undefined
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
query: data.query,
|
||||
total_results: data.total_results,
|
||||
cost_deducted: data.cost_deducted,
|
||||
enrichment_type: data.enrichment_type,
|
||||
results: data.results,
|
||||
actualIsSuccess,
|
||||
actualToolTimestamp,
|
||||
actualAssistantTimestamp
|
||||
};
|
||||
}
|
|
@ -11,6 +11,8 @@ import { StrReplaceToolView } from '../str-replace/StrReplaceToolView';
|
|||
import { WebCrawlToolView } from '../WebCrawlToolView';
|
||||
import { WebScrapeToolView } from '../web-scrape-tool/WebScrapeToolView';
|
||||
import { WebSearchToolView } from '../web-search-tool/WebSearchToolView';
|
||||
import { PeopleSearchToolView } from '../people-search-tool/PeopleSearchToolView';
|
||||
import { CompanySearchToolView } from '../company-search-tool/CompanySearchToolView';
|
||||
import { SeeImageToolView } from '../see-image-tool/SeeImageToolView';
|
||||
import { TerminateCommandToolView } from '../command-tool/TerminateCommandToolView';
|
||||
import { AskToolView } from '../ask-tool/AskToolView';
|
||||
|
@ -80,6 +82,8 @@ const defaultRegistry: ToolViewRegistryType = {
|
|||
'str-replace': StrReplaceToolView,
|
||||
|
||||
'web-search': WebSearchToolView,
|
||||
'people-search': PeopleSearchToolView,
|
||||
'company-search': CompanySearchToolView,
|
||||
'crawl-webpage': WebCrawlToolView,
|
||||
'scrape-webpage': WebScrapeToolView,
|
||||
'image-search': WebSearchToolView,
|
||||
|
|
20
setup.py
20
setup.py
|
@ -138,6 +138,7 @@ def load_existing_env_vars():
|
|||
"TAVILY_API_KEY": backend_env.get("TAVILY_API_KEY", ""),
|
||||
"FIRECRAWL_API_KEY": backend_env.get("FIRECRAWL_API_KEY", ""),
|
||||
"FIRECRAWL_URL": backend_env.get("FIRECRAWL_URL", ""),
|
||||
"EXA_API_KEY": backend_env.get("EXA_API_KEY", ""),
|
||||
},
|
||||
"rapidapi": {
|
||||
"RAPID_API_KEY": backend_env.get("RAPID_API_KEY", ""),
|
||||
|
@ -848,9 +849,10 @@ class SetupWizard:
|
|||
)
|
||||
else:
|
||||
print_info(
|
||||
"Suna uses Tavily for search and Firecrawl for web scraping.")
|
||||
"Suna uses Tavily for search, Firecrawl for web scraping, and Exa for people search.")
|
||||
print_info(
|
||||
"Get a Tavily key at https://tavily.com and a Firecrawl key at https://firecrawl.dev"
|
||||
"Get a Tavily key at https://tavily.com, a Firecrawl key at https://firecrawl.dev, "
|
||||
"and an Exa key at https://exa.ai"
|
||||
)
|
||||
input("Press Enter to continue once you have your keys...")
|
||||
|
||||
|
@ -866,6 +868,20 @@ class SetupWizard:
|
|||
"Invalid API key.",
|
||||
default_value=self.env_vars["search"]["FIRECRAWL_API_KEY"],
|
||||
)
|
||||
|
||||
# Exa API key (optional for people search)
|
||||
print_info(
|
||||
"\nExa API enables advanced people search with LinkedIn/email enrichment using Websets."
|
||||
)
|
||||
print_info(
|
||||
"This is optional but required for the People Search tool. Leave blank to skip."
|
||||
)
|
||||
self.env_vars["search"]["EXA_API_KEY"] = self._get_input(
|
||||
"Enter your Exa API key (optional): ",
|
||||
lambda x: x == "" or validate_api_key(x),
|
||||
"Invalid API key.",
|
||||
default_value=self.env_vars["search"]["EXA_API_KEY"],
|
||||
)
|
||||
|
||||
# Handle Firecrawl URL configuration
|
||||
current_url = self.env_vars["search"]["FIRECRAWL_URL"]
|
||||
|
|
Loading…
Reference in New Issue