versioning setup

This commit is contained in:
Saumya 2025-07-13 19:59:38 +05:30
parent 6b04d1dad9
commit 9b0571a7dc
38 changed files with 2554 additions and 828 deletions

View File

@ -25,6 +25,7 @@ from utils.constants import MODEL_NAME_ALIASES
from flags.flags import is_enabled
from .config_helper import extract_agent_config, build_unified_config, extract_tools_for_agent_run, get_mcp_configs
from .version_manager import version_manager, VersionData
router = APIRouter()
db = None
@ -77,6 +78,8 @@ class AgentVersionCreateRequest(BaseModel):
configured_mcps: Optional[List[Dict[str, Any]]] = []
custom_mcps: Optional[List[Dict[str, Any]]] = []
agentpress_tools: Optional[Dict[str, Any]] = {}
version_name: Optional[str] = None # Custom version name
description: Optional[str] = None # Version description
class AgentUpdateRequest(BaseModel):
name: Optional[str] = None
@ -306,9 +309,17 @@ async def start_agent(
agent_config = None
effective_agent_id = body.agent_id or thread_agent_id # Use provided agent_id or the one stored in thread
logger.info(f"[AGENT LOAD] Agent loading flow:")
logger.info(f" - body.agent_id: {body.agent_id}")
logger.info(f" - thread_agent_id: {thread_agent_id}")
logger.info(f" - effective_agent_id: {effective_agent_id}")
if effective_agent_id:
logger.info(f"[AGENT LOAD] Querying for agent: {effective_agent_id}")
# Get agent with current version
agent_result = await client.table('agents').select('*, agent_versions!current_version_id(*)').eq('agent_id', effective_agent_id).eq('account_id', account_id).execute()
logger.info(f"[AGENT LOAD] Query result: found {len(agent_result.data) if agent_result.data else 0} agents")
if not agent_result.data:
if body.agent_id:
raise HTTPException(status_code=404, detail="Agent not found or access denied")
@ -318,6 +329,9 @@ async def start_agent(
else:
agent_data = agent_result.data[0]
version_data = agent_data.get('agent_versions')
logger.info(f"[AGENT LOAD] About to call extract_agent_config with agent_data keys: {list(agent_data.keys())}")
logger.info(f"[AGENT LOAD] version_data type: {type(version_data)}, content: {version_data}")
agent_config = extract_agent_config(agent_data, version_data)
if version_data:
@ -325,19 +339,32 @@ async def start_agent(
else:
logger.info(f"Using agent {agent_config['name']} ({effective_agent_id}) - no version data")
source = "request" if body.agent_id else "thread"
else:
logger.info(f"[AGENT LOAD] No effective_agent_id, will try default agent")
if not agent_config:
logger.info(f"[AGENT LOAD] No agent config yet, querying for default agent")
default_agent_result = await client.table('agents').select('*, agent_versions!current_version_id(*)').eq('account_id', account_id).eq('is_default', True).execute()
logger.info(f"[AGENT LOAD] Default agent query result: found {len(default_agent_result.data) if default_agent_result.data else 0} default agents")
if default_agent_result.data:
agent_data = default_agent_result.data[0]
version_data = agent_data.get('agent_versions')
logger.info(f"[AGENT LOAD] About to call extract_agent_config for DEFAULT agent with version_data: {version_data}")
agent_config = extract_agent_config(agent_data, version_data)
if version_data:
logger.info(f"Using default agent: {agent_config['name']} ({agent_config['agent_id']}) version {agent_config.get('version_name', 'v1')}")
else:
logger.info(f"Using default agent: {agent_config['name']} ({agent_config['agent_id']}) - no version data")
else:
logger.warning(f"[AGENT LOAD] No default agent found for account {account_id}")
logger.info(f"[AGENT LOAD] Final agent_config: {agent_config is not None}")
if agent_config:
logger.info(f"[AGENT LOAD] Agent config keys: {list(agent_config.keys())}")
if body.agent_id and body.agent_id != thread_agent_id and agent_config:
logger.info(f"Using agent {agent_config['agent_id']} for this agent run (thread remains agent-agnostic)")
@ -843,21 +870,55 @@ async def initiate_agent_with_files(
client = await db.client
account_id = user_id # In Basejump, personal account_id is the same as user_id
# Load agent configuration if agent_id is provided
# Load agent configuration with version support (same as start_agent endpoint)
agent_config = None
logger.info(f"[AGENT INITIATE] Agent loading flow:")
logger.info(f" - agent_id param: {agent_id}")
if agent_id:
agent_result = await client.table('agents').select('*').eq('agent_id', agent_id).eq('account_id', account_id).execute()
logger.info(f"[AGENT INITIATE] Querying for specific agent: {agent_id}")
# Get agent with current version
agent_result = await client.table('agents').select('*, agent_versions!current_version_id(*)').eq('agent_id', agent_id).eq('account_id', account_id).execute()
logger.info(f"[AGENT INITIATE] Query result: found {len(agent_result.data) if agent_result.data else 0} agents")
if not agent_result.data:
raise HTTPException(status_code=404, detail="Agent not found or access denied")
agent_config = agent_result.data[0]
logger.info(f"Using custom agent: {agent_config['name']} ({agent_id})")
agent_data = agent_result.data[0]
version_data = agent_data.get('agent_versions')
logger.info(f"[AGENT INITIATE] About to call extract_agent_config with version_data: {version_data}")
agent_config = extract_agent_config(agent_data, version_data)
if version_data:
logger.info(f"Using custom agent: {agent_config['name']} ({agent_id}) version {agent_config.get('version_name', 'v1')}")
else:
logger.info(f"Using custom agent: {agent_config['name']} ({agent_id}) - no version data")
else:
# Try to get default agent for the account
default_agent_result = await client.table('agents').select('*').eq('account_id', account_id).eq('is_default', True).execute()
logger.info(f"[AGENT INITIATE] No agent_id provided, querying for default agent")
# Try to get default agent for the account with version support
default_agent_result = await client.table('agents').select('*, agent_versions!current_version_id(*)').eq('account_id', account_id).eq('is_default', True).execute()
logger.info(f"[AGENT INITIATE] Default agent query result: found {len(default_agent_result.data) if default_agent_result.data else 0} default agents")
if default_agent_result.data:
agent_config = default_agent_result.data[0]
logger.info(f"Using default agent: {agent_config['name']} ({agent_config['agent_id']})")
agent_data = default_agent_result.data[0]
version_data = agent_data.get('agent_versions')
logger.info(f"[AGENT INITIATE] About to call extract_agent_config for DEFAULT agent with version_data: {version_data}")
agent_config = extract_agent_config(agent_data, version_data)
if version_data:
logger.info(f"Using default agent: {agent_config['name']} ({agent_config['agent_id']}) version {agent_config.get('version_name', 'v1')}")
else:
logger.info(f"Using default agent: {agent_config['name']} ({agent_config['agent_id']}) - no version data")
else:
logger.warning(f"[AGENT INITIATE] No default agent found for account {account_id}")
logger.info(f"[AGENT INITIATE] Final agent_config: {agent_config is not None}")
if agent_config:
logger.info(f"[AGENT INITIATE] Agent config keys: {list(agent_config.keys())}")
can_use, model_message, allowed_models = await can_use_model(client, account_id, model_name)
if not can_use:
raise HTTPException(status_code=403, detail={"message": model_message, "allowed_models": allowed_models})
@ -1733,6 +1794,36 @@ async def update_agent(
logger.info(f"Updated agent {agent_id} for user: {user_id}")
try:
auto_version_id = await version_manager.auto_create_version_on_config_change(
agent_id=agent_id,
user_id=user_id,
change_description="Auto-saved configuration changes"
)
if auto_version_id:
logger.info(f"Auto-created version {auto_version_id} for agent {agent_id}")
updated_agent = await client.table('agents').select('*, agent_versions!current_version_id(*)').eq("agent_id", agent_id).eq("account_id", user_id).maybe_single().execute()
if updated_agent.data:
agent = updated_agent.data
if agent.get('agent_versions'):
version_data = agent['agent_versions']
current_version = AgentVersionResponse(
version_id=version_data['version_id'],
agent_id=version_data['agent_id'],
version_number=version_data['version_number'],
version_name=version_data['version_name'],
system_prompt=version_data['system_prompt'],
configured_mcps=version_data.get('configured_mcps', []),
custom_mcps=version_data.get('custom_mcps', []),
agentpress_tools=version_data.get('agentpress_tools', {}),
is_active=version_data.get('is_active', True),
created_at=version_data['created_at'],
updated_at=version_data.get('updated_at', version_data['created_at']),
created_by=version_data.get('created_by')
)
except Exception as e:
logger.warning(f"Auto-versioning failed for agent {agent_id}: {e}")
return AgentResponse(
agent_id=agent['agent_id'],
account_id=agent['account_id'],
@ -1864,22 +1955,14 @@ async def get_agent_versions(
agent_id: str,
user_id: str = Depends(get_current_user_id_from_jwt)
):
"""Get all versions of an agent."""
client = await db.client
# Check if user has access to this agent
agent_result = await client.table('agents').select("*").eq("agent_id", agent_id).execute()
if not agent_result.data:
raise HTTPException(status_code=404, detail="Agent not found")
agent = agent_result.data[0]
if agent['account_id'] != user_id and not agent.get('is_public', False):
raise HTTPException(status_code=403, detail="Access denied")
# Get all versions
versions_result = await client.table('agent_versions').select("*").eq("agent_id", agent_id).order("version_number", desc=True).execute()
return versions_result.data
try:
versions = await version_manager.get_all_versions(agent_id, user_id)
return versions
except HTTPException:
raise
except Exception as e:
logger.error(f"Error fetching versions for agent {agent_id}: {str(e)}")
raise HTTPException(status_code=500, detail="Failed to fetch versions")
@router.post("/agents/{agent_id}/versions", response_model=AgentVersionResponse)
async def create_agent_version(
@ -1888,61 +1971,29 @@ async def create_agent_version(
user_id: str = Depends(get_current_user_id_from_jwt)
):
"""Create a new version of an agent."""
client = await db.client
# Check if user owns this agent
agent_result = await client.table('agents').select("*").eq("agent_id", agent_id).eq("account_id", user_id).execute()
if not agent_result.data:
raise HTTPException(status_code=404, detail="Agent not found or access denied")
agent = agent_result.data[0]
# Get next version number
versions_result = await client.table('agent_versions').select("version_number").eq("agent_id", agent_id).order("version_number", desc=True).limit(1).execute()
next_version_number = 1
if versions_result.data:
next_version_number = versions_result.data[0]['version_number'] + 1
# Create new version
new_version_data = {
"agent_id": agent_id,
"version_number": next_version_number,
"version_name": f"v{next_version_number}",
"system_prompt": version_data.system_prompt,
"configured_mcps": version_data.configured_mcps or [],
"custom_mcps": version_data.custom_mcps or [],
"agentpress_tools": version_data.agentpress_tools or {},
"is_active": True,
"created_by": user_id
}
# Build unified config for the new version
version_unified_config = build_unified_config(
system_prompt=new_version_data["system_prompt"],
agentpress_tools=new_version_data["agentpress_tools"],
configured_mcps=new_version_data["configured_mcps"],
custom_mcps=new_version_data["custom_mcps"],
avatar=None, # Avatar is not versioned
avatar_color=None # Avatar color is not versioned
)
new_version_data["config"] = version_unified_config
new_version = await client.table('agent_versions').insert(new_version_data).execute()
if not new_version.data:
try:
# Convert request to VersionData model
version_data_model = VersionData(
system_prompt=version_data.system_prompt,
configured_mcps=version_data.configured_mcps or [],
custom_mcps=version_data.custom_mcps or [],
agentpress_tools=version_data.agentpress_tools or {}
)
version = await version_manager.create_version(
agent_id=agent_id,
version_data=version_data_model,
user_id=user_id,
version_name=version_data.version_name,
description=version_data.description
)
return version
except HTTPException:
raise
except Exception as e:
logger.error(f"Error creating version for agent {agent_id}: {str(e)}")
raise HTTPException(status_code=500, detail="Failed to create version")
version = new_version.data[0]
# Update agent with new version
await client.table('agents').update({
"current_version_id": version['version_id'],
"version_count": next_version_number
}).eq("agent_id", agent_id).execute()
logger.info(f"Created version v{next_version_number} for agent {agent_id}")
return version
@router.put("/agents/{agent_id}/versions/{version_id}/activate")
async def activate_agent_version(
@ -1951,24 +2002,14 @@ async def activate_agent_version(
user_id: str = Depends(get_current_user_id_from_jwt)
):
"""Switch agent to use a specific version."""
client = await db.client
# Check if user owns this agent
agent_result = await client.table('agents').select("*").eq("agent_id", agent_id).eq("account_id", user_id).execute()
if not agent_result.data:
raise HTTPException(status_code=404, detail="Agent not found or access denied")
# Check if version exists
version_result = await client.table('agent_versions').select("*").eq("version_id", version_id).eq("agent_id", agent_id).execute()
if not version_result.data:
raise HTTPException(status_code=404, detail="Version not found")
# Update agent's current version
await client.table('agents').update({
"current_version_id": version_id
}).eq("agent_id", agent_id).execute()
return {"message": "Version activated successfully"}
try:
await version_manager.activate_version(agent_id, version_id, user_id)
return {"message": "Version activated successfully"}
except HTTPException:
raise
except Exception as e:
logger.error(f"Error activating version {version_id} for agent {agent_id}: {str(e)}")
raise HTTPException(status_code=500, detail="Failed to activate version")
@router.get("/agents/{agent_id}/versions/{version_id}", response_model=AgentVersionResponse)
async def get_agent_version(
@ -1977,24 +2018,31 @@ async def get_agent_version(
user_id: str = Depends(get_current_user_id_from_jwt)
):
"""Get a specific version of an agent."""
client = await db.client
# Check if user has access to this agent
agent_result = await client.table('agents').select("*").eq("agent_id", agent_id).execute()
if not agent_result.data:
raise HTTPException(status_code=404, detail="Agent not found")
agent = agent_result.data[0]
if agent['account_id'] != user_id and not agent.get('is_public', False):
raise HTTPException(status_code=403, detail="Access denied")
# Get the specific version
version_result = await client.table('agent_versions').select("*").eq("version_id", version_id).eq("agent_id", agent_id).execute()
if not version_result.data:
raise HTTPException(status_code=404, detail="Version not found")
return version_result.data[0]
try:
version = await version_manager.get_version(agent_id, version_id, user_id)
return version
except HTTPException:
raise
except Exception as e:
logger.error(f"Error fetching version {version_id} for agent {agent_id}: {str(e)}")
raise HTTPException(status_code=500, detail="Failed to fetch version")
@router.get("/agents/{agent_id}/versions/compare")
async def compare_agent_versions(
agent_id: str,
version1_id: str = Query(..., description="First version ID to compare"),
version2_id: str = Query(..., description="Second version ID to compare"),
user_id: str = Depends(get_current_user_id_from_jwt)
):
"""Compare two versions of an agent."""
try:
comparison = await version_manager.compare_versions(agent_id, version1_id, version2_id, user_id)
return comparison
except HTTPException:
raise
except Exception as e:
logger.error(f"Error comparing versions for agent {agent_id}: {str(e)}")
raise HTTPException(status_code=500, detail="Failed to compare versions")
@router.get("/agents/{agent_id}/pipedream-tools/{profile_id}")
async def get_pipedream_tools_for_agent(
@ -2161,6 +2209,18 @@ async def update_pipedream_tools_for_agent(
raise HTTPException(status_code=500, detail="Failed to update agent")
logger.info(f"Successfully updated {len(enabled_tools)} tools for agent {agent_id}")
# 🎯 AUTO-VERSIONING: Create version for Pipedream tools update
try:
auto_version_id = await version_manager.auto_create_version_on_config_change(
agent_id=agent_id,
user_id=user_id,
change_description=f"Auto-saved after updating {profile.app_name} tools"
)
if auto_version_id:
logger.info(f"Auto-created version {auto_version_id} for Pipedream tools update on agent {agent_id}")
except Exception as e:
logger.warning(f"Auto-versioning failed for Pipedream tools update on agent {agent_id}: {e}")
return {
'success': True,
@ -2270,7 +2330,6 @@ async def update_custom_mcp_tools_for_agent(
agent = agent_result.data[0]
custom_mcps = agent.get('custom_mcps', [])
# Get request data
mcp_url = request.get('url')
mcp_type = request.get('type', 'sse')
enabled_tools = request.get('enabled_tools', [])
@ -2287,7 +2346,6 @@ async def update_custom_mcp_tools_for_agent(
break
if not updated:
# Create new MCP config
new_mcp_config = {
"name": f"Custom MCP ({mcp_type.upper()})",
"customType": mcp_type,
@ -2299,7 +2357,6 @@ async def update_custom_mcp_tools_for_agent(
}
custom_mcps.append(new_mcp_config)
# Update the agent
update_result = await client.table('agents').update({
'custom_mcps': custom_mcps
}).eq('agent_id', agent_id).execute()
@ -2308,6 +2365,16 @@ async def update_custom_mcp_tools_for_agent(
raise HTTPException(status_code=500, detail="Failed to update agent")
logger.info(f"Successfully updated {len(enabled_tools)} custom MCP tools for agent {agent_id}")
try:
auto_version_id = await version_manager.auto_create_version_on_config_change(
agent_id=agent_id,
user_id=user_id,
change_description=f"Auto-saved after updating custom MCP tools"
)
if auto_version_id:
logger.info(f"Auto-created version {auto_version_id} for custom MCP tools update on agent {agent_id}")
except Exception as e:
logger.warning(f"Auto-versioning failed for custom MCP tools update on agent {agent_id}: {e}")
return {
'success': True,

View File

@ -3,6 +3,32 @@ from utils.logger import logger
def extract_agent_config(agent_data: Dict[str, Any], version_data: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
agent_id = agent_data.get('agent_id', 'Unknown')
agent_has_config = bool(agent_data.get('config') and agent_data['config'] != {})
version_has_config = bool(version_data and version_data.get('config') and version_data['config'] != {})
if version_data and version_data.get('config') and version_data['config'] != {}:
config = version_data['config'].copy()
config['agent_id'] = agent_data['agent_id']
config['name'] = agent_data['name']
config['description'] = agent_data.get('description')
config['is_default'] = agent_data.get('is_default', False)
config['account_id'] = agent_data.get('account_id')
config['current_version_id'] = agent_data.get('current_version_id')
config['version_name'] = version_data.get('version_name', 'v1')
metadata = config.get('metadata', {})
config['avatar'] = metadata.get('avatar', agent_data.get('avatar'))
config['avatar_color'] = metadata.get('avatar_color', agent_data.get('avatar_color'))
config['agentpress_tools'] = extract_tools_for_agent_run(config)
config['configured_mcps'] = config.get('tools', {}).get('mcp', [])
config['custom_mcps'] = config.get('tools', {}).get('custom_mcp', [])
return config
if agent_data.get('config') and agent_data['config'] != {}:
config = agent_data['config'].copy()
if 'tools' not in config:
@ -32,31 +58,6 @@ def extract_agent_config(agent_data: Dict[str, Any], version_data: Optional[Dict
return config
if version_data and version_data.get('config') and version_data['config'] != {}:
config = version_data['config'].copy()
config['agent_id'] = agent_data['agent_id']
config['name'] = agent_data['name']
config['description'] = agent_data.get('description')
config['is_default'] = agent_data.get('is_default', False)
config['account_id'] = agent_data.get('account_id')
config['current_version_id'] = agent_data.get('current_version_id')
config['version_name'] = version_data.get('version_name', 'v1')
metadata = config.get('metadata', {})
config['avatar'] = metadata.get('avatar', agent_data.get('avatar'))
config['avatar_color'] = metadata.get('avatar_color', agent_data.get('avatar_color'))
# Convert agentpress tools to legacy format for run_agent
config['agentpress_tools'] = extract_tools_for_agent_run(config)
# Also extract MCP configs
config['configured_mcps'] = config.get('tools', {}).get('mcp', [])
config['custom_mcps'] = config.get('tools', {}).get('custom_mcp', [])
return config
logger.info(f"Building config from legacy columns for agent {agent_data.get('agent_id')}")
source_data = version_data if version_data else agent_data
legacy_tools = source_data.get('agentpress_tools', {})

View File

@ -50,11 +50,6 @@ async def run_agent(
is_agent_builder: Optional[bool] = False,
target_agent_id: Optional[str] = None
):
"""Run the development agent with specified configuration."""
logger.info(f"🚀 Starting agent with model: {model_name}")
if agent_config:
logger.info(f"Using custom agent: {agent_config.get('name', 'Unknown')}")
if not trace:
trace = langfuse.trace(name="run_agent", session_id=thread_id, metadata={"project_id": project_id})
thread_manager = ThreadManager(trace=trace, is_agent_builder=is_agent_builder or False, target_agent_id=target_agent_id, agent_config=agent_config)
@ -83,7 +78,12 @@ async def run_agent(
enabled_tools = None
if agent_config and 'agentpress_tools' in agent_config:
enabled_tools = agent_config['agentpress_tools']
logger.info(f"Using custom tool configuration from agent")
logger.info(f"[AGENT RUN] Using custom tool configuration from agent version")
# 🔍 DEBUG: Log which tools are enabled
enabled_tool_names = [name for name, config in enabled_tools.items()
if (isinstance(config, dict) and config.get('enabled', False)) or
(isinstance(config, bool) and config)]
logger.info(f"[AGENT RUN] Enabled tools from version: {enabled_tool_names}")
if is_agent_builder:
@ -103,7 +103,7 @@ async def run_agent(
if enabled_tools is None:
logger.info("No agent specified - registering all tools for full Suna capabilities")
logger.info("[AGENT RUN] No agent specified - registering all tools for full Suna capabilities")
thread_manager.add_tool(SandboxShellTool, project_id=project_id, thread_manager=thread_manager)
thread_manager.add_tool(SandboxFilesTool, project_id=project_id, thread_manager=thread_manager)
thread_manager.add_tool(SandboxBrowserTool, project_id=project_id, thread_id=thread_id, thread_manager=thread_manager)
@ -117,24 +117,32 @@ async def run_agent(
if config.RAPID_API_KEY:
thread_manager.add_tool(DataProvidersTool)
else:
logger.info("Custom agent specified - registering only enabled tools")
logger.info("[AGENT RUN] Custom agent specified - registering only enabled tools from version")
thread_manager.add_tool(ExpandMessageTool, thread_id=thread_id, thread_manager=thread_manager)
thread_manager.add_tool(MessageTool)
if enabled_tools.get('sb_shell_tool', {}).get('enabled', False):
logger.info("[AGENT RUN] Adding sb_shell_tool (enabled in version)")
thread_manager.add_tool(SandboxShellTool, project_id=project_id, thread_manager=thread_manager)
if enabled_tools.get('sb_files_tool', {}).get('enabled', False):
logger.info("[AGENT RUN] Adding sb_files_tool (enabled in version)")
thread_manager.add_tool(SandboxFilesTool, project_id=project_id, thread_manager=thread_manager)
if enabled_tools.get('sb_browser_tool', {}).get('enabled', False):
logger.info("[AGENT RUN] Adding sb_browser_tool (enabled in version)")
thread_manager.add_tool(SandboxBrowserTool, project_id=project_id, thread_id=thread_id, thread_manager=thread_manager)
if enabled_tools.get('sb_deploy_tool', {}).get('enabled', False):
logger.info("[AGENT RUN] Adding sb_deploy_tool (enabled in version)")
thread_manager.add_tool(SandboxDeployTool, project_id=project_id, thread_manager=thread_manager)
if enabled_tools.get('sb_expose_tool', {}).get('enabled', False):
logger.info("[AGENT RUN] Adding sb_expose_tool (enabled in version)")
thread_manager.add_tool(SandboxExposeTool, project_id=project_id, thread_manager=thread_manager)
if enabled_tools.get('web_search_tool', {}).get('enabled', False):
logger.info("[AGENT RUN] Adding web_search_tool (enabled in version)")
thread_manager.add_tool(SandboxWebSearchTool, project_id=project_id, thread_manager=thread_manager)
if enabled_tools.get('sb_vision_tool', {}).get('enabled', False):
logger.info("[AGENT RUN] Adding sb_vision_tool (enabled in version)")
thread_manager.add_tool(SandboxVisionTool, project_id=project_id, thread_id=thread_id, thread_manager=thread_manager)
if config.RAPID_API_KEY and enabled_tools.get('data_providers_tool', {}).get('enabled', False):
logger.info("[AGENT RUN] Adding data_providers_tool (enabled in version)")
thread_manager.add_tool(DataProvidersTool)
# Register MCP tool wrapper if agent has configured MCPs or custom MCPs
@ -250,14 +258,15 @@ async def run_agent(
# Completely replace the default system prompt with the custom one
# This prevents confusion and tool hallucination
system_content = custom_system_prompt
logger.info(f"Using ONLY custom agent system prompt for: {agent_config.get('name', 'Unknown')}")
logger.info(f"[AGENT RUN] Using ONLY custom agent system prompt for: {agent_config.get('name', 'Unknown')}")
logger.info(f"[AGENT RUN] System prompt source: version {agent_config.get('version_name', 'no version')} (length: {len(custom_system_prompt)})")
elif is_agent_builder:
system_content = get_agent_builder_prompt()
logger.info("Using agent builder system prompt")
logger.info("[AGENT RUN] Using agent builder system prompt")
else:
# Use just the default system prompt
system_content = default_system_content
logger.info("Using default system prompt only")
logger.info("[AGENT RUN] Using default system prompt only")
if await is_enabled("knowledge_base"):
try:

View File

@ -0,0 +1,379 @@
"""
Agent Version Manager - Comprehensive module for managing agent versions
"""
from typing import Dict, List, Optional, Any, Union
from datetime import datetime
from uuid import UUID
import json
from pydantic import BaseModel, Field, validator
from utils.logger import logger
from services.supabase import DBConnection
from fastapi import HTTPException
class VersionData(BaseModel):
"""Model for version data with proper validation"""
system_prompt: str
configured_mcps: List[Dict[str, Any]] = Field(default_factory=list)
custom_mcps: List[Dict[str, Any]] = Field(default_factory=list)
agentpress_tools: Dict[str, Any] = Field(default_factory=dict)
@validator('custom_mcps', pre=True)
def normalize_custom_mcps(cls, v):
"""Ensure custom MCPs have consistent structure"""
if not isinstance(v, list):
return []
normalized = []
for mcp in v:
if isinstance(mcp, dict):
normalized.append({
'name': mcp.get('name', 'Unnamed MCP'),
'type': mcp.get('type') or mcp.get('customType', 'sse'),
'customType': mcp.get('customType') or mcp.get('type', 'sse'),
'config': mcp.get('config', {}),
'enabledTools': mcp.get('enabledTools') or mcp.get('enabled_tools', [])
})
return normalized
@validator('configured_mcps', pre=True)
def normalize_configured_mcps(cls, v):
"""Ensure configured MCPs are always a list"""
return v if isinstance(v, list) else []
@validator('agentpress_tools', pre=True)
def normalize_agentpress_tools(cls, v):
"""Ensure agentpress tools are always a dict"""
return v if isinstance(v, dict) else {}
class AgentVersionManager:
"""Manager class for handling all agent version operations"""
def __init__(self):
self.logger = logger
self.db = DBConnection()
async def get_version(self, agent_id: str, version_id: str, user_id: str) -> Dict[str, Any]:
"""Get a specific version with proper data normalization"""
client = await self.db.client
# Verify access
agent_result = await client.table('agents').select('*').eq('agent_id', agent_id).execute()
if not agent_result.data:
raise HTTPException(status_code=404, detail="Agent not found")
agent = agent_result.data[0]
if agent['account_id'] != user_id and not agent.get('is_public', False):
raise HTTPException(status_code=403, detail="Access denied")
# Get version
version_result = await client.table('agent_versions').select('*').eq('version_id', version_id).eq('agent_id', agent_id).execute()
if not version_result.data:
raise HTTPException(status_code=404, detail="Version not found")
version_data = version_result.data[0]
return self._normalize_version_data(version_data)
async def get_all_versions(self, agent_id: str, user_id: str) -> List[Dict[str, Any]]:
"""Get all versions for an agent"""
client = await self.db.client
# Verify access
agent_result = await client.table('agents').select('*').eq('agent_id', agent_id).execute()
if not agent_result.data:
raise HTTPException(status_code=404, detail="Agent not found")
agent = agent_result.data[0]
if agent['account_id'] != user_id and not agent.get('is_public', False):
raise HTTPException(status_code=403, detail="Access denied")
# Get all versions
versions_result = await client.table('agent_versions').select('*').eq('agent_id', agent_id).order('version_number', desc=True).execute()
return [self._normalize_version_data(v) for v in versions_result.data]
async def create_version(
self,
agent_id: str,
version_data: VersionData,
user_id: str,
version_name: Optional[str] = None,
description: Optional[str] = None
) -> Dict[str, Any]:
"""Create a new version with proper validation"""
client = await self.db.client
# Verify ownership
agent_result = await client.table('agents').select('*').eq('agent_id', agent_id).eq('account_id', user_id).execute()
if not agent_result.data:
raise HTTPException(status_code=404, detail="Agent not found or access denied")
# Get next version number
versions_result = await client.table('agent_versions').select('version_number').eq('agent_id', agent_id).order('version_number', desc=True).limit(1).execute()
next_version_number = 1
if versions_result.data:
next_version_number = versions_result.data[0]['version_number'] + 1
# Create version
new_version_data = {
"agent_id": agent_id,
"version_number": next_version_number,
"version_name": version_name or f"v{next_version_number}",
"system_prompt": version_data.system_prompt,
"configured_mcps": version_data.configured_mcps,
"custom_mcps": version_data.custom_mcps,
"agentpress_tools": version_data.agentpress_tools,
"is_active": True,
"created_by": user_id,
"change_description": description
}
# Build unified config
config = self._build_unified_config(version_data)
new_version_data['config'] = config
try:
version_result = await client.table('agent_versions').insert(new_version_data).execute()
if not version_result.data:
raise HTTPException(status_code=500, detail="Failed to create version")
version = version_result.data[0]
# Update agent's current version
await client.table('agents').update({
'current_version_id': version['version_id'],
'version_count': next_version_number
}).eq('agent_id', agent_id).execute()
self.logger.info(f"Created version {version['version_name']} for agent {agent_id}")
return self._normalize_version_data(version)
except Exception as e:
self.logger.error(f"Error creating version: {str(e)}")
raise HTTPException(status_code=500, detail=f"Failed to create version: {str(e)}")
async def auto_create_version_on_config_change(
self,
agent_id: str,
user_id: str,
change_description: str = "Auto-saved configuration changes"
) -> Optional[str]:
try:
db = DBConnection()
current_agent = await db.fetch_one(
"SELECT * FROM agents WHERE agent_id = %s", (agent_id,)
)
if not current_agent:
logger.warning(f"Agent {agent_id} not found for auto-versioning")
return None
current_version = None
if current_agent['current_version_id']:
current_version = await db.fetch_one(
"SELECT * FROM agent_versions WHERE version_id = %s",
(current_agent['current_version_id'],)
)
current_config = {
'system_prompt': current_agent['system_prompt'],
'configured_mcps': current_agent['configured_mcps'] or [],
'custom_mcps': current_agent['custom_mcps'] or [],
'agentpress_tools': current_agent['agentpress_tools'] or {}
}
version_config = None
if current_version:
version_config = {
'system_prompt': current_version['system_prompt'],
'configured_mcps': current_version['configured_mcps'] or [],
'custom_mcps': current_version['custom_mcps'] or [],
'agentpress_tools': current_version['agentpress_tools'] or {}
}
if version_config and current_config == version_config:
logger.info(f"No configuration changes detected for agent {agent_id}")
return None
logger.info(f"Configuration changes detected for agent {agent_id}, creating auto-version")
version_data = VersionData(
system_prompt=current_config['system_prompt'],
configured_mcps=current_config['configured_mcps'],
custom_mcps=current_config['custom_mcps'],
agentpress_tools=current_config['agentpress_tools']
)
new_version = await self.create_version(
agent_id=agent_id,
version_data=version_data,
user_id=user_id,
description=change_description
)
if new_version:
await self.activate_version(agent_id, new_version['version_id'], user_id)
logger.info(f"Auto-created and activated version {new_version['version_name']} for agent {agent_id}")
return new_version['version_id']
return None
except Exception as e:
logger.error(f"Failed to auto-create version for agent {agent_id}: {e}")
return None
async def activate_version(self, agent_id: str, version_id: str, user_id: str) -> None:
"""Activate a specific version"""
client = await self.db.client
# Verify ownership
agent_result = await client.table('agents').select('*').eq('agent_id', agent_id).eq('account_id', user_id).execute()
if not agent_result.data:
raise HTTPException(status_code=404, detail="Agent not found or access denied")
# Verify version exists
version_result = await client.table('agent_versions').select('*').eq('version_id', version_id).eq('agent_id', agent_id).execute()
if not version_result.data:
raise HTTPException(status_code=404, detail="Version not found")
# Update agent's current version
await client.table('agents').update({
'current_version_id': version_id
}).eq('agent_id', agent_id).execute()
self.logger.info(f"Activated version {version_id} for agent {agent_id}")
async def compare_versions(
self,
agent_id: str,
version1_id: str,
version2_id: str,
user_id: str
) -> Dict[str, Any]:
"""Compare two versions"""
version1 = await self.get_version(agent_id, version1_id, user_id)
version2 = await self.get_version(agent_id, version2_id, user_id)
return {
'version1': version1,
'version2': version2,
'differences': self._calculate_differences(version1, version2)
}
def _normalize_version_data(self, version_data: Dict[str, Any]) -> Dict[str, Any]:
"""Normalize version data to ensure consistent structure"""
# Handle custom MCPs normalization
custom_mcps = version_data.get('custom_mcps', [])
if custom_mcps is None:
custom_mcps = []
elif isinstance(custom_mcps, list):
custom_mcps = [
{
'name': mcp.get('name', 'Unnamed MCP'),
'type': mcp.get('type') or mcp.get('customType', 'sse'),
'customType': mcp.get('customType') or mcp.get('type', 'sse'),
'config': mcp.get('config', {}),
'enabledTools': mcp.get('enabledTools') or mcp.get('enabled_tools', [])
}
for mcp in custom_mcps
if isinstance(mcp, dict)
]
# Handle configured MCPs
configured_mcps = version_data.get('configured_mcps', [])
if configured_mcps is None:
configured_mcps = []
elif not isinstance(configured_mcps, list):
configured_mcps = []
# Handle agentpress tools
agentpress_tools = version_data.get('agentpress_tools', {})
if not isinstance(agentpress_tools, dict):
agentpress_tools = {}
return {
'version_id': version_data['version_id'],
'agent_id': version_data['agent_id'],
'version_number': version_data['version_number'],
'version_name': version_data['version_name'],
'system_prompt': version_data.get('system_prompt', ''),
'configured_mcps': configured_mcps,
'custom_mcps': custom_mcps,
'agentpress_tools': agentpress_tools,
'is_active': version_data.get('is_active', True),
'created_at': version_data['created_at'],
'updated_at': version_data.get('updated_at', version_data['created_at']),
'created_by': version_data.get('created_by'),
'change_description': version_data.get('change_description'),
'config': version_data.get('config', {})
}
def _build_unified_config(self, version_data: VersionData) -> Dict[str, Any]:
"""Build unified config object"""
simplified_tools = {}
for tool_name, tool_config in version_data.agentpress_tools.items():
if isinstance(tool_config, dict):
simplified_tools[tool_name] = tool_config.get('enabled', False)
elif isinstance(tool_config, bool):
simplified_tools[tool_name] = tool_config
return {
'system_prompt': version_data.system_prompt,
'tools': {
'agentpress': simplified_tools,
'mcp': version_data.configured_mcps,
'custom_mcp': version_data.custom_mcps
}
}
def _calculate_differences(self, version1: Dict[str, Any], version2: Dict[str, Any]) -> Dict[str, Any]:
"""Calculate differences between two versions"""
differences = {}
# Check system prompt
if version1['system_prompt'] != version2['system_prompt']:
differences['system_prompt'] = {
'changed': True,
'version1': version1['system_prompt'][:100] + '...' if len(version1['system_prompt']) > 100 else version1['system_prompt'],
'version2': version2['system_prompt'][:100] + '...' if len(version2['system_prompt']) > 100 else version2['system_prompt']
}
# Check tools
v1_tools = set(version1['agentpress_tools'].keys())
v2_tools = set(version2['agentpress_tools'].keys())
tools_added = v2_tools - v1_tools
tools_removed = v1_tools - v2_tools
tools_changed = []
for tool in v1_tools & v2_tools:
if version1['agentpress_tools'][tool] != version2['agentpress_tools'][tool]:
tools_changed.append(tool)
if tools_added or tools_removed or tools_changed:
differences['agentpress_tools'] = {
'added': list(tools_added),
'removed': list(tools_removed),
'changed': tools_changed
}
# Check MCPs
if json.dumps(version1['configured_mcps'], sort_keys=True) != json.dumps(version2['configured_mcps'], sort_keys=True):
differences['configured_mcps'] = {
'version1_count': len(version1['configured_mcps']),
'version2_count': len(version2['configured_mcps'])
}
if json.dumps(version1['custom_mcps'], sort_keys=True) != json.dumps(version2['custom_mcps'], sort_keys=True):
differences['custom_mcps'] = {
'version1_count': len(version1['custom_mcps']),
'version2_count': len(version2['custom_mcps'])
}
return differences
# Singleton instance
version_manager = AgentVersionManager()

View File

@ -183,6 +183,13 @@ async def get_mcp_server_details(
"""
logger.info(f"Fetching details for MCP server: {qualified_name} for user {user_id}")
# 🔥 FIX: Handle Pipedream qualified names by stripping the pipedream: prefix
# Pipedream MCPs use format "pipedream:{app_slug}" but Smithery API expects just "{app_slug}"
smithery_lookup_name = qualified_name
if qualified_name.startswith("pipedream:"):
smithery_lookup_name = qualified_name[len("pipedream:"):]
logger.info(f"Pipedream MCP detected - using app_slug '{smithery_lookup_name}' for Smithery lookup")
try:
async with httpx.AsyncClient() as client:
headers = {
@ -194,15 +201,17 @@ async def get_mcp_server_details(
if SMITHERY_API_KEY:
headers["Authorization"] = f"Bearer {SMITHERY_API_KEY}"
# URL encode the qualified name only if it contains special characters
if '@' in qualified_name or '/' in qualified_name:
encoded_name = quote(qualified_name, safe='')
# URL encode the lookup name only if it contains special characters
if '@' in smithery_lookup_name or '/' in smithery_lookup_name:
encoded_name = quote(smithery_lookup_name, safe='')
else:
# Don't encode simple names like "exa"
encoded_name = qualified_name
# Don't encode simple names like "exa" or "gmail"
encoded_name = smithery_lookup_name
url = f"{SMITHERY_API_BASE_URL}/servers/{encoded_name}"
logger.debug(f"Requesting MCP server details from: {url}")
if qualified_name != smithery_lookup_name:
logger.debug(f"Original qualified name: {qualified_name}, Smithery lookup name: {smithery_lookup_name}")
response = await client.get(
url, # Use registry API for metadata
@ -218,12 +227,22 @@ async def get_mcp_server_details(
logger.info(f"Successfully fetched details for MCP server: {qualified_name}")
logger.debug(f"Response data keys: {list(data.keys()) if isinstance(data, dict) else 'not a dict'}")
# 🔥 FIX: For Pipedream MCPs, restore the original qualified name in the response
# This ensures the frontend receives the same qualified name it requested
if qualified_name.startswith("pipedream:") and 'qualifiedName' in data:
original_qualified_name = data['qualifiedName']
data['qualifiedName'] = qualified_name
logger.debug(f"Restored original qualified name: {qualified_name} (Smithery returned: {original_qualified_name})")
return MCPServerDetailResponse(**data)
except httpx.HTTPStatusError as e:
if e.response.status_code == 404:
logger.error(f"Server not found. Response: {e.response.text}")
raise HTTPException(status_code=404, detail=f"MCP server '{qualified_name}' not found")
if qualified_name.startswith("pipedream:"):
raise HTTPException(status_code=404, detail=f"MCP server '{qualified_name}' not found (searched for '{smithery_lookup_name}' in Smithery registry)")
else:
raise HTTPException(status_code=404, detail=f"MCP server '{qualified_name}' not found")
logger.error(f"HTTP error fetching MCP server details: {e.response.status_code} - {e.response.text}")
raise HTTPException(

View File

@ -431,19 +431,11 @@ class CredentialManager:
account_id: str,
mcp_qualified_name: str
) -> List[MCPCredentialProfile]:
"""
Get all credential profiles for a specific MCP server
Args:
account_id: User's account ID
mcp_qualified_name: MCP server qualified name
Returns:
List of MCPCredentialProfile objects
"""
try:
client = await db.client
logger.debug(f"Querying credential profiles: account_id={account_id}, mcp_qualified_name='{mcp_qualified_name}'")
result = await client.table('user_mcp_credential_profiles').select('*')\
.eq('account_id', account_id)\
.eq('mcp_qualified_name', mcp_qualified_name)\
@ -451,6 +443,8 @@ class CredentialManager:
.order('is_default', desc=True)\
.order('created_at', desc=False)\
.execute()
logger.debug(f"Database query returned {len(result.data) if result.data else 0} profiles for '{mcp_qualified_name}'")
profiles = []
for profile_data in result.data:
@ -561,7 +555,7 @@ class CredentialManager:
mcp_qualified_name: str
) -> Optional[MCPCredentialProfile]:
"""
Get the default credential profile for an MCP server
Get the default credential profile for an MCP server using robust lookup
Args:
account_id: User's account ID
@ -570,12 +564,22 @@ class CredentialManager:
Returns:
Default MCPCredentialProfile or first available profile
"""
profiles = await self.get_credential_profiles(account_id, mcp_qualified_name)
logger.debug(f"Looking for default profile: account_id={account_id}, mcp_qualified_name={mcp_qualified_name}")
# Use robust profile finder
profiles = await self.find_credential_profiles_robust(account_id, mcp_qualified_name)
logger.debug(f"Found {len(profiles)} profiles for {mcp_qualified_name}")
for profile in profiles:
if profile.is_default:
logger.debug(f"Found default profile: {profile.profile_id} ({profile.display_name})")
return profile
if profiles:
logger.debug(f"No default profile found, returning first profile: {profiles[0].profile_id} ({profiles[0].display_name})")
else:
logger.debug(f"No profiles found for {mcp_qualified_name}")
return profiles[0] if profiles else None
async def set_default_profile(
@ -651,8 +655,58 @@ class CredentialManager:
logger.error(f"Error deleting credential profile {profile_id}: {str(e)}")
return False
async def find_credential_profiles_robust(
self,
account_id: str,
mcp_qualified_name: str
) -> List[MCPCredentialProfile]:
profiles = []
profiles = await self.get_credential_profiles(account_id, mcp_qualified_name)
if profiles:
logger.debug(f"Found {len(profiles)} profiles with exact match for '{mcp_qualified_name}'")
return profiles
if mcp_qualified_name.startswith("pipedream:"):
app_slug = mcp_qualified_name[len("pipedream:"):]
try:
from pipedream.profiles import get_profile_manager
profile_manager = get_profile_manager(db)
pipedream_profiles = await profile_manager.get_profiles(account_id, app_slug=app_slug, is_active=True)
for pd_profile in pipedream_profiles:
cred_profile = await self.get_credential_by_profile(account_id, str(pd_profile.profile_id))
if cred_profile:
profiles.append(cred_profile)
if profiles:
logger.debug(f"Found {len(profiles)} Pipedream profiles via profile manager for '{mcp_qualified_name}'")
return profiles
except Exception as e:
logger.debug(f"Error using Pipedream profile manager: {e}")
elif not mcp_qualified_name.startswith("pipedream:"):
pipedream_name = f"pipedream:{mcp_qualified_name}"
profiles = await self.get_credential_profiles(account_id, pipedream_name)
if profiles:
logger.debug(f"Found {len(profiles)} profiles with pipedream prefix '{pipedream_name}'")
return profiles
all_profiles = await self.get_all_user_credential_profiles(account_id)
req_name_clean = mcp_qualified_name.replace("pipedream:", "").lower()
for profile in all_profiles:
profile_name_clean = profile.mcp_qualified_name.replace("pipedream:", "").lower()
if req_name_clean == profile_name_clean:
profiles.append(profile)
if profiles:
logger.debug(f"Found {len(profiles)} profiles via fuzzy matching for '{mcp_qualified_name}'")
else:
logger.debug(f"No profiles found for '{mcp_qualified_name}' after all strategies")
return profiles
async def get_all_user_credential_profiles(self, account_id: str) -> List[MCPCredentialProfile]:
"""Get all credential profiles for a user across all MCP servers"""
try:
client = await db.client

View File

@ -255,12 +255,12 @@ async def get_credential_profiles_for_mcp(
mcp_qualified_name: str,
user_id: str = Depends(get_current_user_id_from_jwt)
):
"""Get all credential profiles for a specific MCP server"""
decoded_name = urllib.parse.unquote(mcp_qualified_name)
logger.info(f"Getting credential profiles for '{decoded_name}' for user {user_id}")
try:
profiles = await credential_manager.get_credential_profiles(user_id, decoded_name)
profiles = await credential_manager.find_credential_profiles_robust(user_id, decoded_name)
logger.info(f"Found {len(profiles)} credential profiles for '{decoded_name}'")
return [
CredentialProfileResponse(
@ -287,9 +287,7 @@ async def get_credential_profile_by_id(
profile_id: str,
user_id: str = Depends(get_current_user_id_from_jwt)
):
"""Get a specific credential profile by its ID"""
logger.info(f"Getting credential profile {profile_id} for user {user_id}")
try:
profile = await credential_manager.get_credential_by_profile(user_id, profile_id)

View File

@ -81,8 +81,8 @@ class TemplateManager:
try:
client = await db.client
# Get the agent
agent_result = await client.table('agents').select('*').eq('agent_id', agent_id).execute()
# 🔥 FIX: Get the agent WITH current version data
agent_result = await client.table('agents').select('*, agent_versions!current_version_id(*)').eq('agent_id', agent_id).execute()
if not agent_result.data:
raise ValueError("Agent not found")
@ -92,11 +92,29 @@ class TemplateManager:
if agent['account_id'] != creator_id:
raise ValueError("Access denied - you can only create templates from your own agents")
# 🔥 FIX: Get configuration from current version, not legacy columns
version_data = agent.get('agent_versions')
if version_data and version_data.get('config'):
# Use version config (new structure)
version_config = version_data['config']
system_prompt = version_config.get('system_prompt', '')
agentpress_tools = version_config.get('tools', {}).get('agentpress', {})
configured_mcps = version_config.get('tools', {}).get('mcp', [])
custom_mcps = version_config.get('tools', {}).get('custom_mcp', [])
logger.info(f"Using VERSION config for template creation from agent {agent_id}")
else:
# Fallback to legacy columns if no version data
system_prompt = agent.get('system_prompt', '')
agentpress_tools = agent.get('agentpress_tools', {})
configured_mcps = agent.get('configured_mcps', [])
custom_mcps = agent.get('custom_mcps', [])
logger.info(f"Using LEGACY config for template creation from agent {agent_id}")
# Extract MCP requirements from agent configuration
mcp_requirements = []
# Process configured_mcps (regular MCP servers)
for mcp in agent.get('configured_mcps', []):
for mcp in configured_mcps:
if isinstance(mcp, dict) and 'qualifiedName' in mcp:
# Extract required config keys from the config
config_keys = list(mcp.get('config', {}).keys())
@ -110,17 +128,37 @@ class TemplateManager:
mcp_requirements.append(requirement)
# Process custom_mcps (custom MCP servers)
for custom_mcp in agent.get('custom_mcps', []):
for custom_mcp in custom_mcps:
if isinstance(custom_mcp, dict) and 'name' in custom_mcp:
# Extract required config keys from the config
config_keys = list(custom_mcp.get('config', {}).keys())
# 🔥 FIX: Handle Pipedream MCPs with correct qualified name format
custom_type = custom_mcp.get('customType', custom_mcp.get('type', 'http'))
if custom_type == 'pipedream':
# For Pipedream MCPs, extract app_slug and use pipedream:{app_slug} format
app_slug = custom_mcp.get('config', {}).get('app_slug')
if not app_slug and 'headers' in custom_mcp.get('config', {}):
app_slug = custom_mcp['config']['headers'].get('x-pd-app-slug')
if app_slug:
qualified_name = f"pipedream:{app_slug}"
logger.info(f"Using Pipedream qualified name: {qualified_name} for app_slug: {app_slug}")
else:
# Fallback if no app_slug found
qualified_name = f"pipedream:{custom_mcp['name'].lower().replace(' ', '_')}"
logger.warning(f"No app_slug found for Pipedream MCP {custom_mcp['name']}, using fallback: {qualified_name}")
else:
# For other custom MCPs, use the original logic
qualified_name = custom_mcp['name'].lower().replace(' ', '_')
requirement = {
'qualified_name': custom_mcp['name'].lower().replace(' ', '_'),
'qualified_name': qualified_name,
'display_name': custom_mcp['name'],
'enabled_tools': custom_mcp.get('enabledTools', []),
'required_config': config_keys,
'custom_type': custom_mcp.get('type', 'http') # Default to http
'custom_type': custom_type
}
mcp_requirements.append(requirement)
@ -130,14 +168,14 @@ class TemplateManager:
is_kortix_team = creator_id in kortix_team_account_ids
# Create the template
# Create the template using version data
template_data = {
'creator_id': creator_id,
'name': agent['name'],
'description': agent.get('description'),
'system_prompt': agent['system_prompt'],
'system_prompt': system_prompt, # 🔥 From version
'mcp_requirements': mcp_requirements,
'agentpress_tools': agent.get('agentpress_tools', {}),
'agentpress_tools': agentpress_tools, # 🔥 From version
'tags': tags or [],
'is_public': make_public,
'is_kortix_team': is_kortix_team,
@ -146,7 +184,7 @@ class TemplateManager:
'metadata': {
'source_agent_id': agent_id,
'source_version_id': agent.get('current_version_id'),
'source_version_name': agent.get('current_version', {}).get('version_name', 'v1.0')
'source_version_name': version_data.get('version_name', 'v1') if version_data else 'v1'
}
}
@ -159,7 +197,7 @@ class TemplateManager:
raise ValueError("Failed to create template")
template_id = result.data[0]['template_id']
logger.info(f"Successfully created template {template_id} from agent {agent_id} with is_kortix_team={is_kortix_team}")
logger.info(f"Successfully created template {template_id} from agent {agent_id} version {version_data.get('version_name', 'v1') if version_data else 'legacy'} with is_kortix_team={is_kortix_team}")
return template_id
@ -223,53 +261,33 @@ class TemplateManager:
profile_mappings: Optional[Dict[str, str]] = None,
custom_mcp_configs: Optional[Dict[str, Dict[str, Any]]] = None
) -> Dict[str, Any]:
"""
Install a template as an agent instance for a user
Args:
template_id: ID of the template to install
account_id: ID of the user installing the template
instance_name: Optional custom name for the instance
custom_system_prompt: Optional custom system prompt override
profile_mappings: Optional dict mapping qualified_name to profile_id
custom_mcp_configs: Optional dict mapping qualified_name to config for custom MCPs
Returns:
Dictionary with installation result and any missing credentials
"""
logger.info(f"Installing template {template_id} for user {account_id}")
try:
# Get the template
template = await self.get_template(template_id)
if not template:
raise ValueError("Template not found")
# Check if template is accessible
if not template.is_public:
# Check if user owns the template
if template.creator_id != account_id:
raise ValueError("Access denied to private template")
# Debug: Log template requirements
logger.info(f"Template MCP requirements: {[(req.qualified_name, req.display_name, getattr(req, 'custom_type', None)) for req in template.mcp_requirements]}")
# Separate custom and regular MCP requirements
custom_requirements = [req for req in template.mcp_requirements if getattr(req, 'custom_type', None)]
regular_requirements = [req for req in template.mcp_requirements if not getattr(req, 'custom_type', None)]
# If no profile mappings provided, try to use default profiles
if not profile_mappings and regular_requirements:
profile_mappings = {}
for req in regular_requirements:
# Get default profile for this MCP service
default_profile = await credential_manager.get_default_credential_profile(
account_id, req.qualified_name
)
if default_profile:
profile_mappings[req.qualified_name] = default_profile.profile_id
logger.info(f"✅ Mapped {req.qualified_name} -> {default_profile.profile_id} ({default_profile.display_name})")
else:
logger.warning(f"❌ No profile found for {req.qualified_name} after robust lookup")
# Check for missing profile mappings for regular requirements
missing_profile_mappings = []
if regular_requirements:
provided_mappings = profile_mappings or {}
@ -281,7 +299,6 @@ class TemplateManager:
'required_config': req.required_config
})
# Check for missing custom MCP configs
missing_custom_configs = []
if custom_requirements:
provided_custom_configs = custom_mcp_configs or {}
@ -294,7 +311,6 @@ class TemplateManager:
'required_config': req.required_config
})
# If we have any missing profile mappings or configs, return them
if missing_profile_mappings or missing_custom_configs:
return {
'status': 'configs_required',
@ -307,10 +323,8 @@ class TemplateManager:
}
}
# Create regular agent with secure credentials
client = await db.client
# Build configured_mcps and custom_mcps with user's credential profiles
configured_mcps = []
custom_mcps = []
@ -318,32 +332,55 @@ class TemplateManager:
logger.info(f"Processing requirement: {req.qualified_name}, custom_type: {getattr(req, 'custom_type', None)}")
if hasattr(req, 'custom_type') and req.custom_type:
# For custom MCP servers, use the provided config from installation
if custom_mcp_configs and req.qualified_name in custom_mcp_configs:
provided_config = custom_mcp_configs[req.qualified_name]
if req.custom_type == 'pipedream':
profile_id = provided_config.get('profile_id')
if profile_id:
logger.info(f"Looking up Pipedream profile {profile_id} for {req.qualified_name}")
try:
from pipedream.profiles import get_profile_manager
profile_manager = get_profile_manager(db)
profile = await profile_manager.get_profile(account_id, profile_id)
if profile:
actual_config = {
'app_slug': profile.app_slug,
'profile_id': profile_id,
'url': 'https://remote.mcp.pipedream.net',
'headers': {
'x-pd-app-slug': profile.app_slug,
}
}
logger.info(f"Found Pipedream profile: {profile.app_name} ({profile.profile_name})")
provided_config = actual_config
else:
logger.error(f"Pipedream profile {profile_id} not found for {req.qualified_name}")
raise ValueError(f"Pipedream profile not found for {req.display_name}")
except Exception as e:
logger.error(f"Error looking up Pipedream profile {profile_id}: {e}")
raise ValueError(f"Failed to lookup Pipedream profile for {req.display_name}")
custom_mcp_config = {
'name': req.display_name,
'type': req.custom_type,
'customType': req.custom_type,
'config': provided_config,
'enabledTools': req.enabled_tools
}
custom_mcps.append(custom_mcp_config)
logger.info(f"Added custom MCP with provided config: {custom_mcp_config}")
logger.info(f"Added custom MCP with config: {custom_mcp_config}")
else:
logger.warning(f"No custom config provided for {req.qualified_name}")
continue
else:
# For regular MCP servers, use the selected credential profile
if profile_mappings and req.qualified_name in profile_mappings:
profile_id = profile_mappings[req.qualified_name]
# Validate profile_id is not empty
if not profile_id or profile_id.strip() == '':
logger.error(f"Empty profile_id provided for {req.qualified_name}")
raise ValueError(f"Invalid credential profile selected for {req.display_name}")
# Get the credential profile
profile = await credential_manager.get_credential_by_profile(
account_id, profile_id
)
@ -352,20 +389,21 @@ class TemplateManager:
logger.error(f"Credential profile {profile_id} not found for {req.qualified_name}")
raise ValueError(f"Credential profile not found for {req.display_name}. Please select a valid profile or create a new one.")
# Validate profile is active
if not profile.is_active:
logger.error(f"Credential profile {profile_id} is inactive for {req.qualified_name}")
raise ValueError(f"Selected credential profile for {req.display_name} is inactive. Please select an active profile.")
mcp_config = {
'name': req.display_name,
'qualifiedName': req.qualified_name,
'qualifiedName': profile.mcp_qualified_name, # Use profile's qualified name!
'config': profile.config,
'enabledTools': req.enabled_tools,
'selectedProfileId': profile_id
}
configured_mcps.append(mcp_config)
logger.info(f"Added regular MCP with profile: {mcp_config}")
logger.info(f"Used qualified name from profile: {profile.mcp_qualified_name} (template had: {req.qualified_name})")
else:
logger.error(f"No profile mapping provided for {req.qualified_name}")
raise ValueError(f"Missing credential profile for {req.display_name}. Please select a credential profile.")
@ -373,17 +411,33 @@ class TemplateManager:
logger.info(f"Final configured_mcps: {configured_mcps}")
logger.info(f"Final custom_mcps: {custom_mcps}")
# 🔥 FIX: Build unified config structure like in agent creation
from agent.config_helper import build_unified_config
system_prompt = custom_system_prompt or template.system_prompt
unified_config = build_unified_config(
system_prompt=system_prompt,
agentpress_tools=template.agentpress_tools,
configured_mcps=configured_mcps,
custom_mcps=custom_mcps,
avatar=template.avatar,
avatar_color=template.avatar_color
)
agent_data = {
'account_id': account_id,
'name': instance_name or f"{template.name} (from marketplace)",
'description': template.description,
'system_prompt': custom_system_prompt or template.system_prompt,
'config': unified_config,
# Keep legacy columns for backward compatibility
'system_prompt': system_prompt,
'configured_mcps': configured_mcps,
'custom_mcps': custom_mcps,
'agentpress_tools': template.agentpress_tools,
'is_default': False,
'avatar': template.avatar,
'avatar_color': template.avatar_color
'avatar_color': template.avatar_color,
'version_count': 1
}
result = await client.table('agents').insert(agent_data).execute()
@ -398,10 +452,12 @@ class TemplateManager:
"agent_id": instance_id,
"version_number": 1,
"version_name": "v1",
"system_prompt": agent_data['system_prompt'],
"configured_mcps": agent_data['configured_mcps'],
"custom_mcps": agent_data['custom_mcps'],
"agentpress_tools": agent_data['agentpress_tools'],
"config": unified_config,
# Keep legacy columns for backward compatibility
"system_prompt": system_prompt,
"configured_mcps": configured_mcps,
"custom_mcps": custom_mcps,
"agentpress_tools": template.agentpress_tools,
"is_active": True,
"created_by": account_id
}
@ -411,8 +467,7 @@ class TemplateManager:
if version_result.data:
version_id = version_result.data[0]['version_id']
await client.table('agents').update({
'current_version_id': version_id,
'version_count': 1
'current_version_id': version_id
}).eq('agent_id', instance_id).execute()
logger.info(f"Created initial version v1 for installed agent {instance_id}")
else:
@ -527,14 +582,14 @@ class TemplateManager:
}
custom_mcps.append(custom_mcp_config)
else:
# Build regular MCP config
mcp_config = {
'name': req.display_name,
'qualifiedName': req.qualified_name,
'qualifiedName': credential.mcp_qualified_name, # Use credential's qualified name!
'config': credential.config,
'enabledTools': req.enabled_tools
}
configured_mcps.append(mcp_config)
logger.info(f"Runtime config - Used qualified name from credential: {credential.mcp_qualified_name} (template had: {req.qualified_name})")
# Build complete agent config
agent_config = {

View File

@ -90,9 +90,15 @@ async def create_connection_token(
):
logger.info(f"Creating Pipedream connection token for user: {user_id}, app: {request.app}")
# 🔥 FIX: Strip pipedream: prefix if present
actual_app = request.app
if request.app and request.app.startswith("pipedream:"):
actual_app = request.app[len("pipedream:"):]
logger.info(f"Stripped pipedream prefix: {request.app} -> {actual_app}")
try:
client = get_pipedream_client()
result = await client.create_connection_token(user_id, request.app)
result = await client.create_connection_token(user_id, actual_app)
logger.info(f"Successfully created connection token for user: {user_id}")
return ConnectionTokenResponse(
@ -100,7 +106,7 @@ async def create_connection_token(
link=result.get("connect_link_url"),
token=result.get("token"),
external_user_id=user_id,
app=request.app,
app=request.app, # Return original app name that was requested
expires_at=result.get("expires_at")
)
@ -141,11 +147,17 @@ async def discover_mcp_servers(
):
logger.info(f"Discovering MCP servers for user: {user_id}, app: {request.app_slug}")
# 🔥 FIX: Strip pipedream: prefix if present
actual_app_slug = request.app_slug
if request.app_slug and request.app_slug.startswith("pipedream:"):
actual_app_slug = request.app_slug[len("pipedream:"):]
logger.info(f"Stripped pipedream prefix: {request.app_slug} -> {actual_app_slug}")
try:
client = get_pipedream_client()
mcp_servers = await client.discover_mcp_servers(
external_user_id=user_id,
app_slug=request.app_slug,
app_slug=actual_app_slug,
oauth_app_id=request.oauth_app_id
)
@ -171,11 +183,17 @@ async def discover_mcp_servers_for_profile(
"""Discover MCP servers for a specific profile's external_user_id"""
logger.info(f"Discovering MCP servers for external_user_id: {request.external_user_id}, app: {request.app_slug}")
# 🔥 FIX: Strip pipedream: prefix if present
actual_app_slug = request.app_slug
if request.app_slug and request.app_slug.startswith("pipedream:"):
actual_app_slug = request.app_slug[len("pipedream:"):]
logger.info(f"Stripped pipedream prefix: {request.app_slug} -> {actual_app_slug}")
try:
client = get_pipedream_client()
mcp_servers = await client.discover_mcp_servers(
external_user_id=request.external_user_id,
app_slug=request.app_slug,
app_slug=actual_app_slug,
oauth_app_id=request.oauth_app_id
)
@ -199,11 +217,18 @@ async def create_mcp_connection(
user_id: str = Depends(get_current_user_id_from_jwt)
):
logger.info(f"Creating MCP connection for user: {user_id}, app: {request.app_slug}")
# 🔥 FIX: Strip pipedream: prefix if present
actual_app_slug = request.app_slug
if request.app_slug and request.app_slug.startswith("pipedream:"):
actual_app_slug = request.app_slug[len("pipedream:"):]
logger.info(f"Stripped pipedream prefix: {request.app_slug} -> {actual_app_slug}")
try:
client = get_pipedream_client()
mcp_config = await client.create_mcp_connection(
external_user_id=user_id,
app_slug=request.app_slug,
app_slug=actual_app_slug,
oauth_app_id=request.oauth_app_id
)
logger.info(f"Successfully created MCP connection for user: {user_id}, app: {request.app_slug}")
@ -439,6 +464,11 @@ async def get_credential_profiles(
user_id: str = Depends(get_current_user_id_from_jwt)
):
logger.info(f"Getting credential profiles for user: {user_id}, app: {app_slug}")
actual_app_slug = app_slug
if app_slug and app_slug.startswith("pipedream:"):
actual_app_slug = app_slug[len("pipedream:"):]
logger.info(f"Stripped pipedream prefix: {app_slug} -> {actual_app_slug}")
try:
profile_manager = get_profile_manager(db)
@ -537,12 +567,14 @@ async def connect_credential_profile(
app: Optional[str] = Query(None),
user_id: str = Depends(get_current_user_id_from_jwt)
):
"""Generate connection token for a specific credential profile"""
logger.info(f"Connecting credential profile: {profile_id} for user: {user_id}")
actual_app = app
if app and app.startswith("pipedream:"):
actual_app = app[len("pipedream:"):]
logger.info(f"Stripped pipedream prefix: {app} -> {actual_app}")
try:
profile_manager = get_profile_manager(db)
result = await profile_manager.connect_profile(user_id, profile_id, app)
result = await profile_manager.connect_profile(user_id, profile_id, actual_app)
logger.info(f"Successfully generated connection token for profile: {profile_id}")
return result
@ -561,7 +593,6 @@ async def get_profile_connections(
profile_id: str,
user_id: str = Depends(get_current_user_id_from_jwt)
):
"""Get connections for a specific credential profile"""
logger.info(f"Getting connections for profile: {profile_id}, user: {user_id}")
try:
@ -583,3 +614,85 @@ async def get_profile_connections(
status_code=500,
detail=f"Failed to get profile connections: {str(e)}"
)
POPULAR_APP_SLUGS = [
"gmail",
'google_calendar',
'google_drive',
'google_docs',
'google_sheets',
'google_slides',
'google_forms',
'google_meet',
"slack",
"discord",
"github",
"google_sheets",
"notion",
"airtable",
"telegram_bot_api",
"openai",
'linear',
'asana',
'supabase'
]
@router.get("/apps/popular", response_model=Dict[str, Any])
async def get_popular_pipedream_apps():
logger.info("Fetching popular Pipedream apps")
try:
client = get_pipedream_client()
access_token = await client._obtain_access_token()
await client._ensure_rate_limit_token()
popular_apps = []
for app_slug in POPULAR_APP_SLUGS:
try:
url = f"https://api.pipedream.com/v1/apps"
params = {"q": app_slug}
headers = {
"Authorization": f"Bearer {access_token}",
"Content-Type": "application/json"
}
response = await client._make_request_with_retry(
"GET",
url,
headers=headers,
params=params
)
data = response.json()
apps = data.get("data", [])
exact_match = next((app for app in apps if app.get("name_slug") == app_slug), None)
if exact_match:
popular_apps.append(exact_match)
logger.debug(f"Found popular app: {app_slug}")
else:
logger.warning(f"Popular app not found: {app_slug}")
except Exception as e:
logger.warning(f"Error fetching popular app {app_slug}: {e}")
continue
logger.info(f"Successfully fetched {len(popular_apps)} popular apps")
return {
"success": True,
"apps": popular_apps,
"page_info": {
"total_count": len(popular_apps),
"count": len(popular_apps),
"has_more": False
}
}
except Exception as e:
logger.error(f"Failed to fetch popular Pipedream apps: {str(e)}")
raise HTTPException(
status_code=500,
detail=f"Failed to fetch popular Pipedream apps: {str(e)}"
)

View File

@ -65,7 +65,7 @@ class PipedreamClient:
)
return self.session
async def _obtain_rate_limit_token(self, window_size_seconds: int = 10, requests_per_window: int = 1000) -> str:
async def _obtain_rate_limit_token(self, window_size_seconds: int = 5, requests_per_window: int = 100000) -> str:
"""Obtain a rate limit token from Pipedream to bypass rate limits"""
if self.rate_limit_token:
logger.debug(f"Using existing rate limit token: {self.rate_limit_token[:20]}...")
@ -196,7 +196,7 @@ class PipedreamClient:
logger.error(f"Error obtaining access token: {str(e)}")
raise
async def refresh_rate_limit_token(self, window_size_seconds: int = 10, requests_per_window: int = 1000) -> str:
async def refresh_rate_limit_token(self, window_size_seconds: int = 10, requests_per_window: int = 100000) -> str:
"""Manually refresh the rate limit token with custom parameters"""
self.rate_limit_token = None # Clear existing token
return await self._obtain_rate_limit_token(window_size_seconds, requests_per_window)

View File

@ -1,15 +1,17 @@
'use client';
import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react';
import React, { useState, useCallback, useEffect } from 'react';
import { useParams, useRouter } from 'next/navigation';
import { Loader2, Settings2, Sparkles, Check, Clock, Eye, Menu, Zap, Brain, Workflow } from 'lucide-react';
import { Loader2, Save, Eye, Check, Wrench, Server, BookOpen, Workflow, Zap } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion';
import { Badge } from '@/components/ui/badge';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Drawer, DrawerContent, DrawerHeader, DrawerTitle, DrawerTrigger, DrawerClose } from '@/components/ui/drawer';
import { Drawer, DrawerContent, DrawerHeader, DrawerTitle, DrawerTrigger } from '@/components/ui/drawer';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { useAgent, useUpdateAgent } from '@/hooks/react-query/agents/use-agents';
import { Dialog, DialogContent } from '@/components/ui/dialog';
import { useUpdateAgent } from '@/hooks/react-query/agents/use-agents';
import { useCreateAgentVersion, useActivateAgentVersion } from '@/hooks/react-query/agents/use-agent-versions';
import { AgentMCPConfiguration } from '../../../../../components/agents/agent-mcp-configuration';
import { toast } from 'sonner';
import { AgentToolsConfiguration } from '../../../../../components/agents/agent-tools-configuration';
@ -18,27 +20,43 @@ import { getAgentAvatar } from '../../../../../lib/utils/get-agent-style';
import { EditableText } from '@/components/ui/editable';
import { ExpandableMarkdownEditor } from '@/components/ui/expandable-markdown-editor';
import { StylePicker } from '../../../../../components/agents/style-picker';
import { useSidebar } from '@/components/ui/sidebar';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { AgentBuilderChat } from '../../../../../components/agents/agent-builder-chat';
import { AgentTriggersConfiguration } from '@/components/agents/triggers/agent-triggers-configuration';
import { AgentKnowledgeBaseManager } from '@/components/agents/knowledge-base/agent-knowledge-base-manager';
import { AgentWorkflowsConfiguration } from '@/components/agents/workflows/agent-workflows-configuration';
import { AgentVersionSwitcher } from '@/components/agents/agent-version-switcher';
import { CreateVersionButton } from '@/components/agents/create-version-button';
import { VersionComparison } from '../../../../../components/agents/version-comparison';
import { useAgentVersionData } from '../../../../../hooks/use-agent-version-data';
import { useAgentVersionStore } from '../../../../../lib/stores/agent-version-store';
type SaveStatus = 'idle' | 'saving' | 'saved' | 'error';
interface FormData {
name: string;
description: string;
system_prompt: string;
agentpress_tools: any;
configured_mcps: any[];
custom_mcps: any[];
is_default: boolean;
avatar: string;
avatar_color: string;
}
export default function AgentConfigurationPage() {
export default function AgentConfigurationPageRefactored() {
const params = useParams();
const router = useRouter();
const agentId = params.agentId as string;
const { data: agent, isLoading, error } = useAgent(agentId);
// Use modular hooks
const { agent, versionData, isViewingOldVersion, isLoading, error } = useAgentVersionData({ agentId });
const { hasUnsavedChanges, setHasUnsavedChanges } = useAgentVersionStore();
const updateAgentMutation = useUpdateAgent();
const { state, setOpen, setOpenMobile } = useSidebar();
const createVersionMutation = useCreateAgentVersion();
const activateVersionMutation = useActivateAgentVersion();
const initialLayoutAppliedRef = useRef(false);
const [formData, setFormData] = useState({
// State management
const [formData, setFormData] = useState<FormData>({
name: '',
description: '',
system_prompt: '',
@ -50,477 +68,488 @@ export default function AgentConfigurationPage() {
avatar_color: '',
});
const originalDataRef = useRef<typeof formData | null>(null);
const currentFormDataRef = useRef(formData);
const [saveStatus, setSaveStatus] = useState<SaveStatus>('idle');
const debounceTimerRef = useRef<NodeJS.Timeout | null>(null);
const [originalData, setOriginalData] = useState<FormData>(formData);
const [isSaving, setIsSaving] = useState(false);
const [isPreviewOpen, setIsPreviewOpen] = useState(false);
const [isComparisonOpen, setIsComparisonOpen] = useState(false);
const [activeTab, setActiveTab] = useState('agent-builder');
const accordionRef = useRef<HTMLDivElement>(null);
// Initialize form data from agent/version data
useEffect(() => {
if (!agent) return;
// Determine the source of configuration data
let configSource = agent;
// If we have explicit version data (from URL param), use it
if (versionData) {
configSource = versionData;
}
// If no URL param but agent has current_version data, use that
else if (agent.current_version) {
configSource = agent.current_version;
}
const initialData: FormData = {
name: agent.name || '',
description: agent.description || '',
system_prompt: configSource.system_prompt || '',
agentpress_tools: configSource.agentpress_tools || {},
configured_mcps: configSource.configured_mcps || [],
custom_mcps: configSource.custom_mcps || [],
is_default: agent.is_default || false,
avatar: agent.avatar || '',
avatar_color: agent.avatar_color || '',
};
setFormData(initialData);
setOriginalData(initialData);
}, [agent, versionData]);
useEffect(() => {
if (!initialLayoutAppliedRef.current) {
setOpen(false);
initialLayoutAppliedRef.current = true;
}
}, [setOpen]);
const hasChanges = JSON.stringify(formData) !== JSON.stringify(originalData);
setHasUnsavedChanges(hasChanges);
}, [formData, originalData, setHasUnsavedChanges]);
useEffect(() => {
if (agent) {
const agentData = agent as any;
const initialData = {
name: agentData.name || '',
description: agentData.description || '',
system_prompt: agentData.system_prompt || '',
agentpress_tools: agentData.agentpress_tools || {},
configured_mcps: agentData.configured_mcps || [],
custom_mcps: agentData.custom_mcps || [],
is_default: agentData.is_default || false,
avatar: agentData.avatar || '',
avatar_color: agentData.avatar_color || '',
};
setFormData(initialData);
originalDataRef.current = { ...initialData };
}
}, [agent]);
useEffect(() => {
if (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
if (errorMessage.includes('Access denied') || errorMessage.includes('403')) {
toast.error('You don\'t have permission to edit this agent');
router.push('/agents');
return;
}
}
}, [error, router]);
useEffect(() => {
currentFormDataRef.current = formData;
}, [formData]);
const hasDataChanged = useCallback((newData: typeof formData, originalData: typeof formData | null): boolean => {
if (!originalData) return true;
if (newData.name !== originalData.name ||
newData.description !== originalData.description ||
newData.system_prompt !== originalData.system_prompt ||
newData.is_default !== originalData.is_default ||
newData.avatar !== originalData.avatar ||
newData.avatar_color !== originalData.avatar_color) {
return true;
}
if (JSON.stringify(newData.agentpress_tools) !== JSON.stringify(originalData.agentpress_tools) ||
JSON.stringify(newData.configured_mcps) !== JSON.stringify(originalData.configured_mcps) ||
JSON.stringify(newData.custom_mcps) !== JSON.stringify(originalData.custom_mcps)) {
return true;
}
return false;
}, []);
const saveAgent = useCallback(async (data: typeof formData) => {
const handleSave = useCallback(async () => {
if (!agent || isViewingOldVersion) return;
setIsSaving(true);
try {
setSaveStatus('saving');
const normalizedCustomMcps = (formData.custom_mcps || []).map(mcp => ({
name: mcp.name || 'Unnamed MCP',
type: mcp.type || mcp.customType || 'sse',
config: mcp.config || {},
enabledTools: Array.isArray(mcp.enabledTools) ? mcp.enabledTools : [],
}));
await createVersionMutation.mutateAsync({
agentId,
data: {
system_prompt: formData.system_prompt,
configured_mcps: formData.configured_mcps,
custom_mcps: normalizedCustomMcps,
agentpress_tools: formData.agentpress_tools,
description: 'Manual save'
}
});
await updateAgentMutation.mutateAsync({
agentId,
...data
name: formData.name,
description: formData.description,
is_default: formData.is_default,
avatar: formData.avatar,
avatar_color: formData.avatar_color
});
originalDataRef.current = { ...data };
setSaveStatus('saved');
setTimeout(() => setSaveStatus('idle'), 2000);
setOriginalData(formData);
toast.success('Changes saved successfully');
} catch (error) {
console.error('Error updating agent:', error);
setSaveStatus('error');
toast.error('Failed to update agent');
setTimeout(() => setSaveStatus('idle'), 3000);
console.error('Save error:', error);
toast.error('Failed to save changes');
} finally {
setIsSaving(false);
}
}, [agentId, updateAgentMutation]);
}, [agent, formData, isViewingOldVersion, agentId, createVersionMutation, updateAgentMutation]);
const debouncedSave = useCallback((data: typeof formData) => {
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
if (!hasDataChanged(data, originalDataRef.current)) {
const handleFieldChange = useCallback((field: keyof FormData, value: any) => {
if (isViewingOldVersion) {
toast.error('Cannot edit old versions. Please activate this version first to make changes.');
return;
}
const timer = setTimeout(() => {
if (hasDataChanged(data, originalDataRef.current)) {
saveAgent(data);
}
}, 500);
debounceTimerRef.current = timer;
}, [saveAgent, hasDataChanged]);
setFormData(prev => ({ ...prev, [field]: value }));
}, [isViewingOldVersion]);
const handleFieldChange = useCallback((field: string, value: any) => {
const newFormData = {
...currentFormDataRef.current,
[field]: value
};
setFormData(newFormData);
debouncedSave(newFormData);
}, [debouncedSave]);
const handleBatchMCPChange = useCallback((updates: { configured_mcps: any[]; custom_mcps: any[] }) => {
const newFormData = {
...currentFormDataRef.current,
const handleMCPChange = useCallback((updates: { configured_mcps: any[]; custom_mcps: any[] }) => {
if (isViewingOldVersion) {
toast.error('Cannot edit old versions. Please activate this version first to make changes.');
return;
}
setFormData(prev => ({
...prev,
configured_mcps: updates.configured_mcps,
custom_mcps: updates.custom_mcps
};
setFormData(newFormData);
debouncedSave(newFormData);
}, [debouncedSave]);
const scrollToAccordion = useCallback(() => {
if (accordionRef.current) {
accordionRef.current.scrollIntoView({
behavior: 'smooth',
block: 'end'
});
}
}, []);
}));
}, [isViewingOldVersion]);
const handleStyleChange = useCallback((emoji: string, color: string) => {
const newFormData = {
...currentFormDataRef.current,
if (isViewingOldVersion) {
toast.error('Cannot edit old versions. Please activate this version first to make changes.');
return;
}
setFormData(prev => ({
...prev,
avatar: emoji,
avatar_color: color,
};
setFormData(newFormData);
debouncedSave(newFormData);
}, [debouncedSave]);
avatar_color: color
}));
}, [isViewingOldVersion]);
const currentStyle = useMemo(() => {
if (formData.avatar && formData.avatar_color) {
return {
avatar: formData.avatar,
color: formData.avatar_color,
};
// Version activation handler
const handleActivateVersion = useCallback(async (versionId: string) => {
try {
await activateVersionMutation.mutateAsync({ agentId, versionId });
toast.success('Version activated successfully');
// Refresh page without version param
router.push(`/agents/config/${agentId}`);
} catch (error) {
toast.error('Failed to activate version');
}
return getAgentAvatar(agentId);
}, [formData.avatar, formData.avatar_color, agentId]);
}, [agentId, activateVersionMutation, router]);
const memoizedAgentBuilderChat = useMemo(() => (
<AgentBuilderChat
agentId={agentId}
formData={formData}
handleFieldChange={handleFieldChange}
handleStyleChange={handleStyleChange}
currentStyle={currentStyle}
/>
), [agentId, formData, handleFieldChange, handleStyleChange, currentStyle]);
// eslint-disable-next-line react-hooks/exhaustive-deps
const getSaveStatusBadge = () => {
const showSaved = saveStatus === 'idle' && !hasDataChanged(formData, originalDataRef.current);
switch (saveStatus) {
case 'saving':
return (
<Badge variant="secondary" className="flex items-center gap-1 text-amber-700 dark:text-amber-300 bg-amber-600/30 hover:bg-amber-700/40">
<Clock className="h-3 w-3 animate-pulse" />
Saving...
</Badge>
);
case 'saved':
return (
<Badge variant="default" className="flex items-center gap-1 text-green-700 dark:text-green-300 bg-green-600/30 hover:bg-green-700/40">
<Check className="h-3 w-3" />
Saved
</Badge>
);
case 'error':
return (
<Badge variant="destructive" className="flex items-center gap-1 text-red-700 dark:text-red-300 bg-red-600/30 hover:bg-red-700/40">
Error saving
</Badge>
);
default:
return showSaved ? (
<Badge variant="default" className="flex items-center gap-1 text-green-700 dark:text-green-300 bg-green-600/30 hover:bg-green-700/40">
<Check className="h-3 w-3" />
Saved
</Badge>
) : (
<Badge variant="destructive" className="flex items-center gap-1 text-red-700 dark:text-red-300 bg-red-600/30 hover:bg-red-700/40">
Error saving
</Badge>
);
// Auto-switch to configuration tab when viewing old versions
useEffect(() => {
if (isViewingOldVersion && activeTab === 'agent-builder') {
setActiveTab('configuration');
}
};
}, [isViewingOldVersion, activeTab]);
const ConfigurationContent = useMemo(() => {
if (error) {
return (
<div className="h-full flex flex-col">
<div className="md:hidden flex justify-between items-center mb-4 p-4 pb-0">
<div className="flex items-center gap-2">
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={() => setOpenMobile(true)}
className="h-8 w-8 flex items-center justify-center rounded-md hover:bg-accent"
>
<Menu className="h-4 w-4" />
</button>
</TooltipTrigger>
<TooltipContent>Open menu</TooltipContent>
</Tooltip>
<div className="md:hidden flex justify-center">
{getSaveStatusBadge()}
</div>
</div>
<Drawer open={isPreviewOpen} onOpenChange={setIsPreviewOpen}>
<DrawerTrigger asChild>
<Button variant="outline" size="sm">
<Eye className="h-4 w-4" />
Preview
</Button>
</DrawerTrigger>
<DrawerContent className="h-[90vh] bg-muted">
<DrawerHeader>
<DrawerTitle>Agent Preview</DrawerTitle>
</DrawerHeader>
<div className="flex-1 overflow-y-auto px-4 pb-4">
<AgentPreview agent={{ ...agent, ...formData }} />
</div>
</DrawerContent>
</Drawer>
</div>
<Tabs value={activeTab} onValueChange={setActiveTab} className="flex-1 flex flex-col overflow-hidden">
<div className='w-full flex items-center justify-center flex-shrink-0 px-4 md:px-12 md:mt-10'>
<div className='w-auto flex items-center gap-2'>
<TabsList className="grid h-auto w-full grid-cols-2 bg-muted-foreground/10">
<TabsTrigger value="agent-builder" className="w-48 flex items-center gap-1.5 px-2">
<span className="truncate">Prompt to configure</span>
</TabsTrigger>
<TabsTrigger value="manual">Config</TabsTrigger>
</TabsList>
</div>
</div>
<TabsContent value="manual" className="mt-0 flex-1 overflow-y-auto overflow-x-hidden px-4 md:px-12 pb-4 md:pb-12 scrollbar-hide">
<div className="max-w-full">
<div className="hidden md:flex justify-end mb-4 mt-4">
{getSaveStatusBadge()}
</div>
<div className='flex items-start md:items-center flex-col md:flex-row mt-6'>
<StylePicker
agentId={agentId}
currentEmoji={currentStyle.avatar}
currentColor={currentStyle.color}
onStyleChange={handleStyleChange}
>
<div
className="flex-shrink-0 h-12 w-12 md:h-16 md:w-16 flex items-center justify-center rounded-2xl text-xl md:text-2xl cursor-pointer hover:opacity-80 transition-opacity mb-3 md:mb-0"
style={{ backgroundColor: currentStyle.color }}
>
{currentStyle.avatar}
</div>
</StylePicker>
<div className='flex flex-col md:ml-3 w-full min-w-0'>
<EditableText
value={formData.name}
onSave={(value) => handleFieldChange('name', value)}
className="text-lg md:text-xl font-semibold bg-transparent"
placeholder="Click to add agent name..."
/>
<EditableText
value={formData.description}
onSave={(value) => handleFieldChange('description', value)}
className="text-muted-foreground text-sm"
placeholder="Click to add description..."
/>
</div>
</div>
<div className='flex flex-col mt-6 md:mt-8'>
<div className='flex items-center justify-between mb-2'>
<div className='text-sm font-semibold text-muted-foreground'>Instructions</div>
</div>
<ExpandableMarkdownEditor
value={formData.system_prompt}
onSave={(value) => handleFieldChange('system_prompt', value)}
placeholder='Click to set system instructions...'
title='System Instructions'
/>
</div>
<div ref={accordionRef} className="mt-6 border-t">
<Accordion
type="multiple"
defaultValue={[]}
className="space-y-2"
onValueChange={scrollToAccordion}
>
<AccordionItem value="tools" className="border-b">
<AccordionTrigger className="hover:no-underline text-sm md:text-base">
<div className="flex items-center gap-2">
<Settings2 className="h-4 w-4" />
Default Tools
</div>
</AccordionTrigger>
<AccordionContent className="pb-4 overflow-x-hidden">
<AgentToolsConfiguration
tools={formData.agentpress_tools}
onToolsChange={(tools) => handleFieldChange('agentpress_tools', tools)}
/>
</AccordionContent>
</AccordionItem>
<AccordionItem value="mcp" className="border-b">
<AccordionTrigger className="hover:no-underline text-sm md:text-base">
<div className="flex items-center gap-2">
<Sparkles className="h-4 w-4" />
Integrations & MCPs
<Badge variant='new'>New</Badge>
</div>
</AccordionTrigger>
<AccordionContent className="pb-4 overflow-x-hidden">
<AgentMCPConfiguration
configuredMCPs={formData.configured_mcps}
customMCPs={formData.custom_mcps}
onMCPChange={handleBatchMCPChange}
agentId={agentId}
/>
</AccordionContent>
</AccordionItem>
<AccordionItem value="triggers" className="border-b">
<AccordionTrigger className="hover:no-underline text-sm md:text-base">
<div className="flex items-center gap-2">
<Zap className="h-4 w-4" />
Triggers
<Badge variant='new'>New</Badge>
</div>
</AccordionTrigger>
<AccordionContent className="pb-4 overflow-x-hidden">
<AgentTriggersConfiguration
agentId={agentId}
/>
</AccordionContent>
</AccordionItem>
<AccordionItem value="knowledge-base" className="border-b">
<AccordionTrigger className="hover:no-underline text-sm md:text-base">
<div className="flex items-center gap-2">
<Brain className="h-4 w-4" />
Knowledge Base
<Badge variant='new'>New</Badge>
</div>
</AccordionTrigger>
<AccordionContent className="pb-4 overflow-x-hidden">
<AgentKnowledgeBaseManager
agentId={agentId}
agentName={formData.name || 'Agent'}
/>
</AccordionContent>
</AccordionItem>
<AccordionItem value="workflows" className="border-b">
<AccordionTrigger className="hover:no-underline text-sm md:text-base">
<div className="flex items-center gap-2">
<Workflow className="h-4 w-4" />
Workflows
<Badge variant='new'>New</Badge>
</div>
</AccordionTrigger>
<AccordionContent className="pb-4 overflow-x-hidden">
<AgentWorkflowsConfiguration
agentId={agentId}
agentName={formData.name || 'Agent'}
/>
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
</div>
</TabsContent>
<TabsContent value="agent-builder" className="mt-0 flex-1 flex flex-col overflow-hidden">
{memoizedAgentBuilderChat}
</TabsContent>
</Tabs>
<div className="flex items-center justify-center h-screen">
<Alert variant="destructive">
<AlertDescription>
{error.message || 'Failed to load agent configuration'}
</AlertDescription>
</Alert>
</div>
);
}, [
activeTab,
agentId,
agent,
formData,
currentStyle,
isPreviewOpen,
memoizedAgentBuilderChat,
handleFieldChange,
handleStyleChange,
setOpenMobile,
setIsPreviewOpen,
setActiveTab,
scrollToAccordion,
getSaveStatusBadge,
handleBatchMCPChange
]);
useEffect(() => {
return () => {
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
};
}, []);
}
if (isLoading) {
return (
<div className="container mx-auto max-w-7xl px-4 py-8">
<div className="flex items-center justify-center min-h-[400px]">
<div className="flex items-center gap-2">
<Loader2 className="h-6 w-6 animate-spin" />
<span>Loading agent...</span>
</div>
</div>
<div className="flex items-center justify-center h-screen">
<Loader2 className="h-8 w-8 animate-spin" />
</div>
);
}
if (error || !agent) {
const errorMessage = error instanceof Error ? error.message : String(error);
const isAccessDenied = errorMessage.includes('Access denied') || errorMessage.includes('403');
if (!agent) {
return (
<div className="container mx-auto max-w-7xl px-4 py-8">
<div className="text-center space-y-4">
{isAccessDenied ? (
<Alert variant="destructive">
<AlertDescription>
You don't have permission to edit this agent. You can only edit agents that you created.
</AlertDescription>
</Alert>
) : (
<>
<h2 className="text-xl font-semibold mb-2">Agent not found</h2>
<p className="text-muted-foreground mb-4">The agent you're looking for doesn't exist.</p>
</>
)}
</div>
<div className="flex items-center justify-center h-screen">
<Alert>
<AlertDescription>Agent not found</AlertDescription>
</Alert>
</div>
);
}
// Use version data when viewing old version, otherwise use form data
const displayData = isViewingOldVersion && versionData ? {
name: agent?.name || '',
description: agent?.description || '',
system_prompt: versionData.system_prompt || '',
agentpress_tools: versionData.agentpress_tools || {},
configured_mcps: versionData.configured_mcps || [],
custom_mcps: versionData.custom_mcps || [],
is_default: agent?.is_default || false,
avatar: agent?.avatar || '',
avatar_color: agent?.avatar_color || '',
} : formData;
const currentStyle = displayData.avatar && displayData.avatar_color
? { avatar: displayData.avatar, color: displayData.avatar_color }
: getAgentAvatar(agentId);
const previewAgent = {
...agent,
...displayData,
agent_id: agentId,
};
return (
<div className="h-screen flex flex-col">
<div className="flex-1 flex overflow-hidden">
<div className="hidden md:flex w-full h-full">
<div className="w-1/2 border-r bg-background h-full flex flex-col">
{ConfigurationContent}
<div className="h-full flex flex-col">
<div className="flex justify-between items-center mb-4 p-4 border-b">
<div className="flex items-center gap-2">
<AgentVersionSwitcher
agentId={agentId}
currentVersionId={agent?.current_version_id}
currentFormData={{
system_prompt: formData.system_prompt,
configured_mcps: formData.configured_mcps,
custom_mcps: formData.custom_mcps,
agentpress_tools: formData.agentpress_tools
}}
/>
<CreateVersionButton
agentId={agentId}
currentFormData={{
system_prompt: formData.system_prompt,
configured_mcps: formData.configured_mcps,
custom_mcps: formData.custom_mcps,
agentpress_tools: formData.agentpress_tools
}}
hasChanges={hasUnsavedChanges && !isViewingOldVersion}
onVersionCreated={() => {
setOriginalData(formData);
}}
/>
</div>
<div className="flex items-center gap-2">
{hasUnsavedChanges && !isViewingOldVersion && (
<Button
size="sm"
onClick={handleSave}
disabled={isSaving}
>
{isSaving ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Save className="h-4 w-4" />
)}
Save Changes
</Button>
)}
</div>
</div>
<div className="flex-1 flex flex-col overflow-hidden -mt-4">
<div className="flex-shrink-0 space-y-6 px-4 mt-2">
{isViewingOldVersion && (
<Alert className="mb-4">
<Eye className="h-4 w-4" />
<div className="flex items-center justify-between w-full">
<AlertDescription>
You are viewing a read-only version. To make changes, please activate this version or switch back to the current version.
</AlertDescription>
<div className="ml-4 flex items-center gap-2">
{versionData && (
<Badge className="text-xs">
{versionData.version_name}
</Badge>
)}
<Button
size="sm"
variant="outline"
onClick={() => versionData && handleActivateVersion(versionData.version_id)}
disabled={activateVersionMutation.isPending}
>
{activateVersionMutation.isPending ? (
<Loader2 className="h-3 w-3 animate-spin mr-1" />
) : (
<Check className="h-3 w-3" />
)}
Set as Current
</Button>
</div>
</div>
</Alert>
)}
</div>
<Tabs value={activeTab} onValueChange={setActiveTab} className="flex flex-col flex-1 overflow-hidden">
<div className="flex-shrink-0 space-y-4 px-4 py-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<StylePicker
currentEmoji={currentStyle.avatar}
currentColor={currentStyle.color}
onStyleChange={handleStyleChange}
agentId={agentId}
>
<div className="h-10 w-10 rounded-xl flex items-center justify-center gap-2" style={{ backgroundColor: currentStyle.color }}>
<div className="text-lg font-medium">{currentStyle.avatar}</div>
</div>
</StylePicker>
<EditableText
value={displayData.name}
onSave={(value) => handleFieldChange('name', value)}
className="text-md font-semibold bg-transparent"
placeholder="Click to add agent name..."
disabled={isViewingOldVersion}
/>
</div>
<TabsList className="grid grid-cols-2">
<TabsTrigger
value="agent-builder"
disabled={isViewingOldVersion}
className={isViewingOldVersion ? "opacity-50 cursor-not-allowed" : ""}
>
Agent Builder
</TabsTrigger>
<TabsTrigger value="configuration">Configuration</TabsTrigger>
</TabsList>
</div>
</div>
<TabsContent value="agent-builder" className="flex-1 h-0 px-4">
{isViewingOldVersion ? (
<div className="flex items-center justify-center h-full">
<div className="text-center space-y-4 max-w-md">
<div className="text-6xl">🔒</div>
<div>
<h3 className="text-lg font-semibold text-muted-foreground">Agent Builder Disabled</h3>
<p className="text-sm text-muted-foreground mt-2">
The Agent Builder is only available for the current version.
To use the Agent Builder, please activate this version first.
</p>
</div>
</div>
</div>
) : (
<AgentBuilderChat
agentId={agentId}
formData={displayData}
handleFieldChange={handleFieldChange}
handleStyleChange={handleStyleChange}
currentStyle={currentStyle}
/>
)}
</TabsContent>
{activeTab === 'configuration' && (
<div className='px-4'>
<ExpandableMarkdownEditor
value={displayData.system_prompt}
onSave={(value) => handleFieldChange('system_prompt', value)}
placeholder="Click to set system instructions..."
title="System Instructions"
disabled={isViewingOldVersion}
/>
</div>
)}
<TabsContent value="configuration" className="flex-1 h-0 overflow-y-auto px-4">
<Accordion type="single" collapsible defaultValue="system" className='space-y-2'>
<AccordionItem className='border rounded-xl px-4' value="tools">
<AccordionTrigger>
<div className='flex items-center gap-2'>
<div className='bg-muted rounded-full h-8 w-8 flex items-center justify-center'>
<Wrench className='h-4 w-4' />
</div>
Tools
</div>
</AccordionTrigger>
<AccordionContent>
<AgentToolsConfiguration
tools={displayData.agentpress_tools}
onToolsChange={(tools) => handleFieldChange('agentpress_tools', tools)}
/>
</AccordionContent>
</AccordionItem>
<AccordionItem className='border rounded-xl px-4' value="integrations">
<AccordionTrigger>
<div className='flex items-center gap-2'>
<div className='bg-muted rounded-full h-8 w-8 flex items-center justify-center'>
<Server className='h-4 w-4' />
</div>
Integrations
</div>
</AccordionTrigger>
<AccordionContent>
<AgentMCPConfiguration
configuredMCPs={displayData.configured_mcps}
customMCPs={displayData.custom_mcps}
onMCPChange={handleMCPChange}
agentId={agentId}
versionData={{
configured_mcps: displayData.configured_mcps,
custom_mcps: displayData.custom_mcps,
system_prompt: displayData.system_prompt,
agentpress_tools: displayData.agentpress_tools
}}
/>
</AccordionContent>
</AccordionItem>
<AccordionItem className='border rounded-xl px-4' value="knowledge">
<AccordionTrigger>
<div className='flex items-center gap-2'>
<div className='bg-muted rounded-full h-8 w-8 flex items-center justify-center'>
<BookOpen className='h-4 w-4' />
</div>
Knowledge Base
</div>
</AccordionTrigger>
<AccordionContent>
<AgentKnowledgeBaseManager
agentId={agentId}
agentName={displayData.name || 'Agent'}
/>
</AccordionContent>
</AccordionItem>
<AccordionItem className='border rounded-xl px-4' value="workflows">
<AccordionTrigger>
<div className='flex items-center gap-2'>
<div className='bg-muted rounded-full h-8 w-8 flex items-center justify-center'>
<Workflow className='h-4 w-4' />
</div>
Workflows
</div>
</AccordionTrigger>
<AccordionContent>
<AgentWorkflowsConfiguration
agentId={agentId}
agentName={displayData.name || 'Agent'}
/>
</AccordionContent>
</AccordionItem>
<AccordionItem className='border rounded-xl px-4' value="triggers">
<AccordionTrigger>
<div className='flex items-center gap-2'>
<div className='bg-muted rounded-full h-8 w-8 flex items-center justify-center'>
<Zap className='h-4 w-4' />
</div>
Triggers
</div>
</AccordionTrigger>
<AccordionContent>
<AgentTriggersConfiguration agentId={agentId} />
</AccordionContent>
</AccordionItem>
</Accordion>
</TabsContent>
</Tabs>
</div>
</div>
</div>
{/* Right Panel - Preview */}
<div className="w-1/2 overflow-y-auto">
<AgentPreview agent={{ ...agent, ...formData }} />
{previewAgent && <AgentPreview agent={previewAgent} />}
</div>
</div>
<div className="md:hidden w-full h-full flex flex-col">
{ConfigurationContent}
{/* Mobile Layout */}
<div className="md:hidden flex flex-col h-full w-full">
{/* Mobile content similar to desktop but with drawer for preview */}
<div className="flex-1 overflow-y-auto p-4">
{/* Similar content structure as desktop */}
</div>
{/* Mobile Preview Drawer */}
<Drawer open={isPreviewOpen} onOpenChange={setIsPreviewOpen}>
<DrawerTrigger asChild>
<Button
className="fixed bottom-4 right-4 rounded-full shadow-lg"
size="icon"
>
<Eye className="h-4 w-4" />
</Button>
</DrawerTrigger>
<DrawerContent>
<DrawerHeader>
<DrawerTitle>Agent Preview</DrawerTitle>
</DrawerHeader>
<div className="flex-1 overflow-y-auto px-4 pb-4">
{previewAgent && <AgentPreview agent={previewAgent} />}
</div>
</DrawerContent>
</Drawer>
</div>
</div>
<Dialog open={isComparisonOpen} onOpenChange={setIsComparisonOpen}>
<DialogContent className="max-w-6xl max-h-[90vh] overflow-y-auto">
<VersionComparison
agentId={agentId}
onClose={() => setIsComparisonOpen(false)}
onActivateVersion={handleActivateVersion}
/>
</DialogContent>
</Dialog>
</div>
);
}
}

View File

@ -16,7 +16,7 @@ import {
Plus
} from 'lucide-react';
import { formatDistanceToNow } from 'date-fns';
import { useAgentVersions, useActivateAgentVersion } from '@/hooks/react-query/agents/useAgentVersions';
import { useAgentVersions, useActivateAgentVersion } from '@/hooks/react-query/agents/use-agent-versions';
import { Agent } from '@/hooks/react-query/agents/utils';
import { cn } from '@/lib/utils';

View File

@ -389,8 +389,7 @@ export const AgentBuilderChat = React.memo(function AgentBuilderChat({
<div ref={messagesEndRef} />
</div>
</div>
<div className="flex-shrink-0 md:pb-4 md:px-12 px-4">
<div className="flex-shrink-0 md:pb-4 md:px-6 px-4">
<ChatInput
ref={chatInputRef}
onSubmit={threadId ? handleSubmitMessage : handleSubmitFirstMessage}
@ -410,7 +409,7 @@ export const AgentBuilderChat = React.memo(function AgentBuilderChat({
</div>
);
}, (prevProps, nextProps) => {
// Custom comparison function to prevent unnecessary re-renders
return (
prevProps.agentId === nextProps.agentId &&
JSON.stringify(prevProps.formData) === JSON.stringify(nextProps.formData) &&

View File

@ -6,15 +6,21 @@ interface AgentMCPConfigurationProps {
customMCPs: any[];
onMCPChange: (updates: { configured_mcps: any[]; custom_mcps: any[] }) => void;
agentId?: string;
versionData?: {
configured_mcps?: any[];
custom_mcps?: any[];
system_prompt?: string;
agentpress_tools?: any;
};
}
export const AgentMCPConfiguration: React.FC<AgentMCPConfigurationProps> = ({
configuredMCPs,
customMCPs,
onMCPChange,
agentId
agentId,
versionData
}) => {
// Combine all MCPs into a single array for the new component
const allMCPs = [
...(configuredMCPs || []),
...(customMCPs || []).map(customMcp => ({
@ -28,7 +34,6 @@ export const AgentMCPConfiguration: React.FC<AgentMCPConfigurationProps> = ({
];
const handleConfigurationChange = (mcps: any[]) => {
// Separate back into configured and custom MCPs
const configured = mcps.filter(mcp => !mcp.isCustom);
const custom = mcps
.filter(mcp => mcp.isCustom)
@ -40,7 +45,6 @@ export const AgentMCPConfiguration: React.FC<AgentMCPConfigurationProps> = ({
enabledTools: mcp.enabledTools
}));
// Call the parent handler with the proper structure
onMCPChange({
configured_mcps: configured,
custom_mcps: custom
@ -52,6 +56,7 @@ export const AgentMCPConfiguration: React.FC<AgentMCPConfigurationProps> = ({
configuredMCPs={allMCPs}
onConfigurationChange={handleConfigurationChange}
agentId={agentId}
versionData={versionData}
/>
);
};

View File

@ -0,0 +1,281 @@
'use client';
import React, { useState } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { GitBranch, ChevronDown, Clock, RotateCcw, Check, AlertCircle, Loader2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Badge } from '@/components/ui/badge';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { useAgentVersions, useActivateAgentVersion, useCreateAgentVersion } from '@/hooks/react-query/agents/use-agent-versions';
import { formatDistanceToNow } from 'date-fns';
import { toast } from 'sonner';
import { AgentVersion } from '@/hooks/react-query/agents/utils';
interface AgentVersionSwitcherProps {
agentId: string;
currentVersionId?: string | null;
currentFormData: {
system_prompt: string;
configured_mcps: any[];
custom_mcps: any[];
agentpress_tools: Record<string, any>;
};
}
export function AgentVersionSwitcher({
agentId,
currentVersionId,
currentFormData
}: AgentVersionSwitcherProps) {
const router = useRouter();
const searchParams = useSearchParams();
const versionParam = searchParams.get('version');
const { data: versions, isLoading } = useAgentVersions(agentId);
const activateVersionMutation = useActivateAgentVersion();
const createVersionMutation = useCreateAgentVersion();
const [showRollbackDialog, setShowRollbackDialog] = useState(false);
const [selectedVersion, setSelectedVersion] = useState<AgentVersion | null>(null);
const [isRollingBack, setIsRollingBack] = useState(false);
const viewingVersionId = versionParam || currentVersionId;
const viewingVersion = versions?.find(v => v.version_id === viewingVersionId) || versions?.[0];
const canRollback = viewingVersion && viewingVersion.version_number > 1;
const handleVersionSelect = async (version: AgentVersion) => {
if (version.version_id === viewingVersionId) return;
const params = new URLSearchParams(searchParams.toString());
if (version.version_id === currentVersionId) {
params.delete('version');
} else {
params.set('version', version.version_id);
}
const newUrl = params.toString() ? `?${params.toString()}` : window.location.pathname;
router.push(newUrl);
if (version.version_id !== currentVersionId) {
toast.success(`Viewing ${version.version_name} (read-only)`);
}
};
const handleRollback = async () => {
if (!selectedVersion || !viewingVersion) return;
setIsRollingBack(true);
try {
const newVersion = await createVersionMutation.mutateAsync({
agentId,
data: {
system_prompt: selectedVersion.system_prompt,
configured_mcps: selectedVersion.configured_mcps,
custom_mcps: selectedVersion.custom_mcps,
agentpress_tools: selectedVersion.agentpress_tools,
description: `Rolled back from ${viewingVersion.version_name} to ${selectedVersion.version_name}`
}
});
await activateVersionMutation.mutateAsync({
agentId,
versionId: newVersion.version_id
});
const params = new URLSearchParams(searchParams.toString());
params.delete('version');
const newUrl = params.toString() ? `?${params.toString()}` : window.location.pathname;
router.push(newUrl);
setShowRollbackDialog(false);
toast.success(`Rolled back to ${selectedVersion.version_name} configuration`);
} catch (error) {
console.error('Failed to rollback:', error);
toast.error('Failed to rollback version');
} finally {
setIsRollingBack(false);
}
};
const openRollbackDialog = (version: AgentVersion) => {
setSelectedVersion(version);
setShowRollbackDialog(true);
};
if (isLoading) {
return (
<div className="flex items-center gap-2 px-3 py-2">
<Loader2 className="h-4 w-4 animate-spin" />
<span className="text-sm text-muted-foreground">Loading versions...</span>
</div>
);
}
if (!versions || versions.length === 0) {
return null;
}
return (
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="gap-2">
<GitBranch className="h-4 w-4" />
{viewingVersion ? (
<>
{viewingVersion.version_name}
{viewingVersionId === currentVersionId && (
<div className="h-2 w-2 rounded-full bg-green-500" />
)}
</>
) : (
'Select Version'
)}
<ChevronDown className="h-4 w-4 ml-1" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-80">
<DropdownMenuLabel>Version History</DropdownMenuLabel>
<DropdownMenuSeparator />
<div className="max-h-96 overflow-y-auto">
{versions.map((version) => {
const isViewing = version.version_id === viewingVersionId;
const isCurrent = version.version_id === currentVersionId;
return (
<div key={version.version_id} className="relative">
<DropdownMenuItem
onClick={() => handleVersionSelect(version)}
className={`cursor-pointer ${isViewing ? 'bg-accent' : ''}`}
>
<div className="flex items-start justify-between w-full">
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="font-medium">{version.version_name}</span>
{isCurrent && (
<Badge variant="default" className="text-xs">
Current
</Badge>
)}
{isViewing && !isCurrent && (
<Badge variant="outline" className="text-xs">
Viewing
</Badge>
)}
</div>
<div className="flex items-center gap-2 mt-1">
<Clock className="h-3 w-3 text-muted-foreground" />
<span className="text-xs text-muted-foreground">
{formatDistanceToNow(new Date(version.created_at), { addSuffix: true })}
</span>
</div>
{version.change_description && (
<p className="text-xs text-muted-foreground mt-1 line-clamp-2">
{version.change_description}
</p>
)}
</div>
{isViewing && (
<Check className="h-4 w-4 text-primary ml-2" />
)}
</div>
</DropdownMenuItem>
{!isViewing && version.version_number < (viewingVersion?.version_number || 0) && canRollback && (
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation();
openRollbackDialog(version);
}}
className="absolute right-2 top-2"
title={`Rollback to ${version.version_name}`}
>
<RotateCcw className="h-3 w-3" />
</Button>
)}
</div>
);
})}
</div>
{versions.length === 1 && (
<div className="p-2">
<Alert>
<AlertCircle className="h-4 w-4" />
<AlertDescription>
This is the first version. Make changes to create a new version.
</AlertDescription>
</Alert>
</div>
)}
</DropdownMenuContent>
</DropdownMenu>
{/* Rollback Confirmation Dialog */}
<Dialog open={showRollbackDialog} onOpenChange={setShowRollbackDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle>Rollback to {selectedVersion?.version_name}</DialogTitle>
<DialogDescription>
This will create a new version with the configuration from {selectedVersion?.version_name}.
Your current changes will be preserved in the current version.
</DialogDescription>
</DialogHeader>
<div className="py-4">
<Alert>
<AlertCircle className="h-4 w-4" />
<AlertDescription>
<strong>Note:</strong> This action will create a new version (v{(versions?.[0]?.version_number || 0) + 1})
with the selected configuration. You can always switch back to any previous version.
</AlertDescription>
</Alert>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setShowRollbackDialog(false)}
disabled={isRollingBack}
>
Cancel
</Button>
<Button
onClick={handleRollback}
disabled={isRollingBack}
>
{isRollingBack ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Rolling back...
</>
) : (
<>
<RotateCcw className="h-4 w-4 mr-2" />
Confirm Rollback
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}

View File

@ -0,0 +1,125 @@
'use client';
import React, { useState } from 'react';
import { Save, Loader2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { useCreateAgentVersion } from '@/hooks/react-query/agents/use-agent-versions';
import { toast } from 'sonner';
interface CreateVersionButtonProps {
agentId: string;
currentFormData: {
system_prompt: string;
configured_mcps: any[];
custom_mcps: any[];
agentpress_tools: Record<string, any>;
};
hasChanges: boolean;
onVersionCreated?: () => void;
}
export function CreateVersionButton({
agentId,
currentFormData,
hasChanges,
onVersionCreated
}: CreateVersionButtonProps) {
const [showDialog, setShowDialog] = useState(false);
const [versionName, setVersionName] = useState('');
const [description, setDescription] = useState('');
const createVersionMutation = useCreateAgentVersion();
const handleCreateVersion = async () => {
if (!versionName.trim()) {
toast.error('Please provide a version name');
return;
}
try {
await createVersionMutation.mutateAsync({
agentId,
data: {
system_prompt: currentFormData.system_prompt,
configured_mcps: currentFormData.configured_mcps,
custom_mcps: currentFormData.custom_mcps,
agentpress_tools: currentFormData.agentpress_tools,
version_name: versionName.trim(),
description: description.trim() || undefined,
}
});
setShowDialog(false);
setVersionName('');
setDescription('');
if (onVersionCreated) {
onVersionCreated();
}
toast.success('New version created successfully');
} catch (error) {
console.error('Failed to create version:', error);
toast.error('Failed to create version');
}
};
return (
<>
<Dialog open={showDialog} onOpenChange={setShowDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle>Create New Version</DialogTitle>
<DialogDescription>
Save the current agent configuration as a new version. This allows you to preserve different configurations and switch between them.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="version-name">Version Name</Label>
<Input
id="version-name"
placeholder="e.g., v2, Production Ready, Beta Features"
value={versionName}
onChange={(e) => setVersionName(e.target.value)}
autoFocus
/>
</div>
<div className="space-y-2">
<Label htmlFor="description">Description (Optional)</Label>
<Textarea
id="description"
placeholder="What changes does this version include?"
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={3}
/>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setShowDialog(false)}
disabled={createVersionMutation.isPending}
>
Cancel
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}

View File

@ -57,6 +57,10 @@ export const StreamlinedInstallDialog: React.FC<StreamlinedInstallDialogProps> =
item.mcp_requirements
.filter(req => req.custom_type === 'pipedream')
.forEach(req => {
const app_slug = req.qualified_name.startsWith('pipedream:')
? req.qualified_name.substring('pipedream:'.length)
: req.qualified_name;
steps.push({
id: req.qualified_name,
title: `Connect ${req.display_name}`,
@ -64,7 +68,7 @@ export const StreamlinedInstallDialog: React.FC<StreamlinedInstallDialogProps> =
type: 'pipedream_profile',
service_name: req.display_name,
qualified_name: req.qualified_name,
app_slug: req.qualified_name
app_slug: app_slug
});
});

View File

@ -40,14 +40,16 @@ export const ProfileConnector: React.FC<ProfileConnectorProps> = ({
const createProfileMutation = useCreateCredentialProfile();
const { data: serverDetails } = useMCPServerDetails(step.qualified_name);
const { data: pipedreamProfiles } = usePipedreamProfiles();
const isPipedreamStep = step.type === 'pipedream_profile';
const { data: pipedreamProfiles } = usePipedreamProfiles(
isPipedreamStep ? { app_slug: step.app_slug, is_active: true } : undefined
);
const configProperties = serverDetails?.connections?.[0]?.configSchema?.properties || {};
const requiredFields = serverDetails?.connections?.[0]?.configSchema?.required || [];
const hasConnectedPipedreamProfile = pipedreamProfiles?.some(p =>
p.app_slug === step.app_slug && p.is_connected
);
const hasConnectedPipedreamProfile = pipedreamProfiles?.some(p => p.is_connected) || false;
useEffect(() => {
setProfileStep('select');

View File

@ -11,7 +11,8 @@ import { ToolsManager } from './tools-manager';
export const MCPConfigurationNew: React.FC<MCPConfigurationProps> = ({
configuredMCPs,
onConfigurationChange,
agentId
agentId,
versionData
}) => {
const [showCustomDialog, setShowCustomDialog] = useState(false);
const [showRegistryDialog, setShowRegistryDialog] = useState(false);
@ -153,21 +154,12 @@ export const MCPConfigurationNew: React.FC<MCPConfigurationProps> = ({
{configuredMCPs.length > 0 && (
<div className="space-y-4">
<div className="bg-card rounded-xl border border-border overflow-hidden">
<div className="px-6 py-4 border-b border-border bg-muted/30">
<h4 className="text-sm font-medium text-foreground">
Configured Integrations
</h4>
</div>
<div className="p-2 divide-y divide-border">
<ConfiguredMcpList
configuredMCPs={configuredMCPs}
onEdit={handleEditMCP}
onRemove={handleRemoveMCP}
onConfigureTools={handleConfigureTools}
/>
</div>
</div>
<ConfiguredMcpList
configuredMCPs={configuredMCPs}
onEdit={handleEditMCP}
onRemove={handleRemoveMCP}
onConfigureTools={handleConfigureTools}
/>
</div>
)}
</div>
@ -192,7 +184,7 @@ export const MCPConfigurationNew: React.FC<MCPConfigurationProps> = ({
<DialogHeader className="sr-only">
<DialogTitle>Select Integration</DialogTitle>
</DialogHeader>
<PipedreamRegistry showAgentSelector={false} selectedAgentId={selectedAgentId} onAgentChange={handleAgentChange} onToolsSelected={handleToolsSelected} />
<PipedreamRegistry showAgentSelector={false} selectedAgentId={selectedAgentId} onAgentChange={handleAgentChange} onToolsSelected={handleToolsSelected} versionData={versionData} />
</DialogContent>
</Dialog>
<CustomMCPDialog
@ -209,6 +201,7 @@ export const MCPConfigurationNew: React.FC<MCPConfigurationProps> = ({
open={showPipedreamToolsManager}
onOpenChange={setShowPipedreamToolsManager}
onToolsUpdate={handlePipedreamToolsUpdate}
versionData={versionData}
/>
)}
{selectedMCPForTools && selectedMCPForTools.customType !== 'pipedream' && (
@ -220,6 +213,7 @@ export const MCPConfigurationNew: React.FC<MCPConfigurationProps> = ({
open={showCustomToolsManager}
onOpenChange={setShowCustomToolsManager}
onToolsUpdate={handleCustomToolsUpdate}
versionData={versionData}
/>
)}
</div>

View File

@ -32,6 +32,12 @@ interface BaseToolsManagerProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onToolsUpdate?: (enabledTools: string[]) => void;
versionData?: {
configured_mcps?: any[];
custom_mcps?: any[];
system_prompt?: string;
agentpress_tools?: any;
};
}
interface PipedreamToolsManagerProps extends BaseToolsManagerProps {
@ -50,16 +56,16 @@ interface CustomToolsManagerProps extends BaseToolsManagerProps {
type ToolsManagerProps = PipedreamToolsManagerProps | CustomToolsManagerProps;
export const ToolsManager: React.FC<ToolsManagerProps> = (props) => {
const { agentId, open, onOpenChange, onToolsUpdate, mode } = props;
const { agentId, open, onOpenChange, onToolsUpdate, mode, versionData } = props;
const pipedreamResult = usePipedreamToolsData(
mode === 'pipedream' ? agentId : '',
mode === 'pipedream' ? props.profileId : ''
mode === 'pipedream' ? (props as PipedreamToolsManagerProps).profileId : ''
);
const customResult = useCustomMCPToolsData(
mode === 'custom' ? agentId : '',
mode === 'custom' ? props.mcpConfig : null
mode === 'custom' ? (props as CustomToolsManagerProps).mcpConfig : null
);
const result = mode === 'pipedream' ? pipedreamResult : customResult;
@ -68,16 +74,48 @@ export const ToolsManager: React.FC<ToolsManagerProps> = (props) => {
const [localTools, setLocalTools] = useState<Record<string, boolean>>({});
const [hasChanges, setHasChanges] = useState(false);
// Helper function to get version-specific enabled tools
const getVersionEnabledTools = (): string[] => {
if (!versionData) return [];
if (mode === 'pipedream') {
const customMcps = versionData.custom_mcps || [];
const pipedreamMcp = customMcps.find((mcp: any) =>
mcp.config?.profile_id === (props as PipedreamToolsManagerProps).profileId &&
mcp.config?.url?.includes('pipedream')
);
return pipedreamMcp?.enabledTools || [];
} else {
const customMcps = versionData.custom_mcps || [];
const customMcp = customMcps.find((mcp: any) =>
mcp.config?.url === (props as CustomToolsManagerProps).mcpConfig?.url
);
return customMcp?.enabledTools || [];
}
};
React.useEffect(() => {
if (data?.tools) {
const toolsMap: Record<string, boolean> = {};
data.tools.forEach((tool: { name: string; enabled: boolean }) => {
toolsMap[tool.name] = tool.enabled;
});
if (versionData) {
// When viewing a version, use the version's enabled tools
const versionEnabledTools = getVersionEnabledTools();
data.tools.forEach((tool: { name: string; enabled: boolean }) => {
toolsMap[tool.name] = versionEnabledTools.includes(tool.name);
});
} else {
// Normal case: use current agent data
data.tools.forEach((tool: { name: string; enabled: boolean }) => {
toolsMap[tool.name] = tool.enabled;
});
}
setLocalTools(toolsMap);
setHasChanges(false);
}
}, [data]);
}, [data, versionData, mode,
mode === 'pipedream' ? (props as PipedreamToolsManagerProps).profileId : (props as CustomToolsManagerProps).mcpConfig]);
const enabledCount = useMemo(() => {
return Object.values(localTools).filter(Boolean).length;
@ -85,20 +123,27 @@ export const ToolsManager: React.FC<ToolsManagerProps> = (props) => {
const totalCount = data?.tools?.length || 0;
const displayName = mode === 'pipedream' ? props.appName : props.mcpName;
const contextName = mode === 'pipedream' ? props.profileName || 'Profile' : 'Server';
const displayName = mode === 'pipedream' ? (props as PipedreamToolsManagerProps).appName : (props as CustomToolsManagerProps).mcpName;
const contextName = mode === 'pipedream' ? (props as PipedreamToolsManagerProps).profileName || 'Profile' : 'Server';
const handleToolToggle = (toolName: string) => {
setLocalTools(prev => {
const newValue = !prev[toolName];
const updated = { ...prev, [toolName]: newValue };
const serverTools: Record<string, boolean> = {};
if (data?.tools) {
for (const tool of data.tools) {
serverTools[tool.name] = tool.enabled;
}
const comparisonState: Record<string, boolean> = {};
if (versionData) {
const versionEnabledTools = getVersionEnabledTools();
data?.tools?.forEach((tool: any) => {
comparisonState[tool.name] = versionEnabledTools.includes(tool.name);
});
} else {
data?.tools?.forEach((tool: any) => {
comparisonState[tool.name] = tool.enabled;
});
}
const hasChanges = Object.keys(updated).some(key => updated[key] !== serverTools[key]);
const hasChanges = Object.keys(updated).some(key => updated[key] !== comparisonState[key]);
setHasChanges(hasChanges);
return updated;
});
@ -131,9 +176,20 @@ export const ToolsManager: React.FC<ToolsManagerProps> = (props) => {
const handleCancel = () => {
if (data?.tools) {
const serverState: Record<string, boolean> = {};
data.tools.forEach((tool: any) => {
serverState[tool.name] = tool.enabled;
});
if (versionData) {
// When viewing a version, reset to version state
const versionEnabledTools = getVersionEnabledTools();
data.tools.forEach((tool: any) => {
serverState[tool.name] = versionEnabledTools.includes(tool.name);
});
} else {
// Normal case: reset to current server state
data.tools.forEach((tool: any) => {
serverState[tool.name] = tool.enabled;
});
}
setLocalTools(serverState);
setHasChanges(false);
}
@ -183,7 +239,16 @@ export const ToolsManager: React.FC<ToolsManagerProps> = (props) => {
Configure {displayName} Tools
</DialogTitle>
<DialogDescription>
Choose which {displayName} tools are available to your agent
{versionData ? (
<div className="flex items-center gap-2 text-amber-600">
<Info className="h-4 w-4" />
<span>
Viewing tools configuration for a specific version. Changes will update the current version.
</span>
</div>
) : (
<span>Choose which {displayName} tools are available to your agent</span>
)}
</DialogDescription>
</DialogHeader>
@ -220,7 +285,7 @@ export const ToolsManager: React.FC<ToolsManagerProps> = (props) => {
)}
</div>
<p className="text-xs text-muted-foreground">
{contextName}: {mode === 'pipedream' ? props.profileName : displayName}
{contextName}: {mode === 'pipedream' ? (props as PipedreamToolsManagerProps).profileName : displayName}
</p>
</div>
</div>

View File

@ -12,4 +12,10 @@ export interface MCPConfigurationProps {
configuredMCPs: MCPConfiguration[];
onConfigurationChange: (mcps: MCPConfiguration[]) => void;
agentId?: string;
versionData?: {
configured_mcps?: any[];
custom_mcps?: any[];
system_prompt?: string;
agentpress_tools?: any;
};
}

View File

@ -1,45 +1,9 @@
export const categoryEmojis: Record<string, string> = {
'All': '🌟',
'Communication': '💬',
'Artificial Intelligence (AI)': '🤖',
'Social Media': '📱',
'CRM': '👥',
'Marketing': '📈',
'Analytics': '📊',
'Commerce': '📊',
'Databases': '🗄️',
'File Storage': '🗂️',
'Help Desk & Support': '🎧',
'Infrastructure & Cloud': '🌐',
'E-commerce': '🛒',
'Developer Tools': '🔧',
'Web & App Development': '🌐',
'Business Management': '💼',
'Productivity': '⚡',
'Finance': '💰',
'Email': '📧',
'Project Management': '📋',
'Storage': '💾',
'AI/ML': '🤖',
'Data & Databases': '🗄️',
'Video': '🎥',
'Calendar': '📅',
'Forms': '📝',
'Security': '🔒',
'HR': '👔',
'Sales': '💼',
'Support': '🎧',
'Design': '🎨',
'Business Intelligence': '📈',
'Automation': '🔄',
'News': '📰',
'Weather': '🌤️',
'Travel': '✈️',
'Education': '🎓',
'Health': '🏥',
'Popular': '🔥',
};
export const PAGINATION_CONSTANTS = {
FIRST_PAGE: 'FIRST_PAGE',
POPULAR_APPS_COUNT: 6,
POPULAR_APPS_COUNT: 10,
} as const;

View File

@ -40,6 +40,7 @@ interface PipedreamConnectorProps {
onOpenChange: (open: boolean) => void;
onComplete: (profileId: string, selectedTools: string[], appName: string, appSlug: string) => void;
mode?: 'full' | 'profile-only';
agentId?: string; // For backend auto-versioning
}
interface PipedreamTool {
@ -52,7 +53,8 @@ export const PipedreamConnector: React.FC<PipedreamConnectorProps> = ({
open,
onOpenChange,
onComplete,
mode = 'full'
mode = 'full',
agentId
}) => {
const [step, setStep] = useState<'profile' | 'tools'>('profile');
const [selectedProfileId, setSelectedProfileId] = useState<string>('');
@ -110,7 +112,6 @@ export const PipedreamConnector: React.FC<PipedreamConnectorProps> = ({
const newProfile = await createProfile.mutateAsync(request);
// Connect the profile
await connectProfile.mutateAsync({
profileId: newProfile.profile_id,
app: app.name_slug,

View File

@ -1,7 +1,7 @@
import React, { useState, useMemo } from 'react';
import { Button } from '@/components/ui/button';
import { X, Bot } from 'lucide-react';
import { usePipedreamApps } from '@/hooks/react-query/pipedream/use-pipedream';
import { usePipedreamApps, usePipedreamPopularApps } from '@/hooks/react-query/pipedream/use-pipedream';
import { usePipedreamProfiles } from '@/hooks/react-query/pipedream/use-pipedream-profiles';
import { useAgent } from '@/hooks/react-query/agents/use-agents';
import { PipedreamConnector } from './pipedream-connector';
@ -20,7 +20,7 @@ import {
} from './_components';
import { PAGINATION_CONSTANTS } from './constants';
import {
getCategoriesFromApps,
getSimplifiedCategories,
createConnectedAppsFromProfiles,
getAgentPipedreamProfiles,
filterAppsByCategory
@ -34,7 +34,8 @@ export const PipedreamRegistry: React.FC<PipedreamRegistryProps> = ({
onClose,
showAgentSelector = false,
selectedAgentId,
onAgentChange
onAgentChange,
versionData
}) => {
const [search, setSearch] = useState('');
const [selectedCategory, setSelectedCategory] = useState<string>('All');
@ -54,6 +55,7 @@ export const PipedreamRegistry: React.FC<PipedreamRegistryProps> = ({
const queryClient = useQueryClient();
const { data: appsData, isLoading, error, refetch } = usePipedreamApps(after, search);
const { data: popularAppsData, isLoading: isLoadingPopular } = usePipedreamPopularApps();
const { data: profiles } = usePipedreamProfiles();
const currentAgentId = selectedAgentId ?? internalSelectedAgentId;
@ -84,18 +86,25 @@ export const PipedreamRegistry: React.FC<PipedreamRegistryProps> = ({
}, [allAppsData?.apps]);
const agentPipedreamProfiles = useMemo(() => {
return getAgentPipedreamProfiles(agent, profiles, currentAgentId);
}, [agent, profiles, currentAgentId]);
return getAgentPipedreamProfiles(agent, profiles, currentAgentId, versionData);
}, [agent, profiles, currentAgentId, versionData]);
const categories = useMemo(() => {
return getCategoriesFromApps(allApps);
}, [allApps]);
return getSimplifiedCategories();
}, []);
const connectedProfiles = useMemo(() => {
return profiles?.filter(p => p.is_connected) || [];
}, [profiles]);
const filteredAppsData = useMemo(() => {
if (selectedCategory === 'Popular') {
return popularAppsData ? {
...popularAppsData,
apps: popularAppsData.apps || []
} : undefined;
}
if (!appsData) return appsData;
if (selectedCategory === 'All') {
@ -112,7 +121,7 @@ export const PipedreamRegistry: React.FC<PipedreamRegistryProps> = ({
count: filteredApps.length
}
};
}, [appsData, selectedCategory]);
}, [appsData, popularAppsData, selectedCategory]);
const connectedApps: ConnectedApp[] = useMemo(() => {
return createConnectedAppsFromProfiles(connectedProfiles, allApps);
@ -128,6 +137,9 @@ export const PipedreamRegistry: React.FC<PipedreamRegistryProps> = ({
setSelectedCategory(category);
setAfter(undefined);
setPaginationHistory([]);
if (category === 'Popular' && search) {
setSearch('');
}
};
const handleNextPage = () => {
@ -271,7 +283,7 @@ export const PipedreamRegistry: React.FC<PipedreamRegistryProps> = ({
apps={filteredAppsData.apps}
selectedCategory={selectedCategory}
mode={mode}
isLoading={isLoading}
isLoading={selectedCategory === 'Popular' ? isLoadingPopular : isLoading}
currentAgentId={currentAgentId}
agent={agent}
agentPipedreamProfiles={agentPipedreamProfiles}
@ -280,7 +292,7 @@ export const PipedreamRegistry: React.FC<PipedreamRegistryProps> = ({
onConfigureTools={handleConfigureTools}
onCategorySelect={handleCategorySelect}
/>
) : !isLoading ? (
) : !(selectedCategory === 'Popular' ? isLoadingPopular : isLoading) ? (
<EmptyState
selectedCategory={selectedCategory}
mode={mode}
@ -291,7 +303,7 @@ export const PipedreamRegistry: React.FC<PipedreamRegistryProps> = ({
apps={[]}
selectedCategory={selectedCategory}
mode={mode}
isLoading={isLoading}
isLoading={selectedCategory === 'Popular' ? isLoadingPopular : isLoading}
currentAgentId={currentAgentId}
agent={agent}
agentPipedreamProfiles={agentPipedreamProfiles}
@ -324,6 +336,7 @@ export const PipedreamRegistry: React.FC<PipedreamRegistryProps> = ({
onOpenChange={setShowStreamlinedConnector}
onComplete={handleConnectionComplete}
mode={mode === 'profile-only' ? 'profile-only' : 'full'}
agentId={currentAgentId}
/>
)}
{selectedToolsProfile && currentAgentId && (
@ -343,6 +356,7 @@ export const PipedreamRegistry: React.FC<PipedreamRegistryProps> = ({
onToolsUpdate={(enabledTools) => {
queryClient.invalidateQueries({ queryKey: ['agent', currentAgentId] });
}}
versionData={versionData}
/>
)}
</div>

View File

@ -15,6 +15,12 @@ export interface PipedreamRegistryProps {
showAgentSelector?: boolean;
selectedAgentId?: string;
onAgentChange?: (agentId: string | undefined) => void;
versionData?: {
configured_mcps?: any[];
custom_mcps?: any[];
system_prompt?: string;
agentpress_tools?: any;
};
}
export interface AppCardProps {

View File

@ -3,13 +3,8 @@ import type { PipedreamProfile } from '@/components/agents/pipedream/pipedream-t
import { categoryEmojis, PAGINATION_CONSTANTS } from './constants';
import type { ConnectedApp } from './types';
export const getCategoriesFromApps = (apps: PipedreamApp[]) => {
const categorySet = new Set<string>();
apps.forEach((app) => {
app.categories.forEach(cat => categorySet.add(cat));
});
const sortedCategories = Array.from(categorySet).sort();
return ['All', ...sortedCategories];
export const getSimplifiedCategories = () => {
return ['All', 'Popular'];
};
export const getPopularApps = (apps: PipedreamApp[]) => {
@ -74,11 +69,18 @@ export const createConnectedAppsFromProfiles = (
export const getAgentPipedreamProfiles = (
agent: any,
profiles: PipedreamProfile[],
currentAgentId?: string
currentAgentId?: string,
versionData?: {
configured_mcps?: any[];
custom_mcps?: any[];
system_prompt?: string;
agentpress_tools?: any;
}
) => {
if (!agent || !profiles || !currentAgentId) return [];
const customMcps = agent.custom_mcps || [];
// Use version data if available, otherwise use agent data
const customMcps = versionData?.custom_mcps || agent.custom_mcps || [];
const pipedreamMcps = customMcps.filter((mcp: any) =>
mcp.config?.profile_id && mcp.config?.url?.includes('pipedream')
);

View File

@ -1563,7 +1563,7 @@ export const StylePicker = ({
currentColor,
onStyleChange
}: {
children: React.ReactNode;
children?: React.ReactNode;
agentId: string;
currentEmoji?: string;
currentColor?: string;
@ -1639,30 +1639,9 @@ export const StylePicker = ({
/>
))}
</div>
<div className="flex items-center gap-2">
<input
type="color"
value={selectedColor}
onChange={(e) => setSelectedColor(e.target.value)}
className="w-8 h-8 rounded border cursor-pointer"
/>
<input
type="text"
value={selectedColor}
onChange={(e) => setSelectedColor(e.target.value)}
className="flex-1 px-2 py-1 text-sm border rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="#000000"
/>
</div>
</div>
<Separator />
<div className="space-y-3">
<div className="flex items-center gap-2">
<span className="text-xl">{selectedEmoji}</span>
<span className="font-medium">Emoji</span>
</div>
{!searchTerm && (
<Tabs value={activeCategory} onValueChange={setActiveCategory} className="w-full">
<TabsList className="grid w-full grid-cols-4 h-auto p-1">
@ -1700,20 +1679,18 @@ export const StylePicker = ({
))}
</div>
</ScrollArea>
{searchTerm && filteredEmojis.length === 0 && (
<div className="text-center text-muted-foreground py-4">No emojis found for "{searchTerm}"</div>
)}
</div>
<Separator />
</CardContent>
<CardFooter className="flex justify-end gap-2">
<Button variant="outline" onClick={handleReset}>
Reset
</Button>
<Button onClick={handleSubmit}>
Save
</Button>
<Button variant="outline" onClick={handleReset}>
Reset
</Button>
<Button onClick={handleSubmit}>
Save
</Button>
</CardFooter>
</Card>
</PopoverContent>

View File

@ -0,0 +1,290 @@
'use client';
import React, { useState, useMemo } from 'react';
import { Loader2, GitBranch, ArrowLeftRight, Check, X, Plus, Minus, RefreshCw } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Badge } from '@/components/ui/badge';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { useAgentVersions } from '@/hooks/react-query/agents/use-agent-versions';
import { AgentVersion } from '@/hooks/react-query/agents/utils';
import { Textarea } from '@/components/ui/textarea';
interface VersionComparisonProps {
agentId: string;
onClose?: () => void;
onActivateVersion?: (versionId: string) => void;
}
interface VersionDifference {
field: string;
type: 'added' | 'removed' | 'modified';
oldValue?: any;
newValue?: any;
}
export function VersionComparison({ agentId, onClose, onActivateVersion }: VersionComparisonProps) {
const { data: versions = [], isLoading } = useAgentVersions(agentId);
const [version1Id, setVersion1Id] = useState<string>('');
const [version2Id, setVersion2Id] = useState<string>('');
const version1 = versions.find(v => v.version_id === version1Id);
const version2 = versions.find(v => v.version_id === version2Id);
const differences = useMemo(() => {
if (!version1 || !version2) return [];
const diffs: VersionDifference[] = [];
// Compare system prompts
if (version1.system_prompt !== version2.system_prompt) {
diffs.push({
field: 'System Prompt',
type: 'modified',
oldValue: version1.system_prompt,
newValue: version2.system_prompt
});
}
// Compare tools
const tools1 = new Set(Object.keys(version1.agentpress_tools || {}));
const tools2 = new Set(Object.keys(version2.agentpress_tools || {}));
// Added tools
for (const tool of tools2) {
if (!tools1.has(tool)) {
diffs.push({
field: `Tool: ${tool}`,
type: 'added',
newValue: version2.agentpress_tools[tool]
});
}
}
// Removed tools
for (const tool of tools1) {
if (!tools2.has(tool)) {
diffs.push({
field: `Tool: ${tool}`,
type: 'removed',
oldValue: version1.agentpress_tools[tool]
});
}
}
// Modified tools
for (const tool of tools1) {
if (tools2.has(tool) &&
JSON.stringify(version1.agentpress_tools[tool]) !== JSON.stringify(version2.agentpress_tools[tool])) {
diffs.push({
field: `Tool: ${tool}`,
type: 'modified',
oldValue: version1.agentpress_tools[tool],
newValue: version2.agentpress_tools[tool]
});
}
}
// Compare MCPs
const mcps1Count = (version1.configured_mcps?.length || 0) + (version1.custom_mcps?.length || 0);
const mcps2Count = (version2.configured_mcps?.length || 0) + (version2.custom_mcps?.length || 0);
if (mcps1Count !== mcps2Count) {
diffs.push({
field: 'MCP Integrations',
type: 'modified',
oldValue: `${mcps1Count} integrations`,
newValue: `${mcps2Count} integrations`
});
}
return diffs;
}, [version1, version2]);
if (isLoading) {
return (
<div className="flex items-center justify-center h-96">
<Loader2 className="h-8 w-8 animate-spin" />
</div>
);
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<GitBranch className="h-5 w-5" />
<h2 className="text-2xl font-semibold">Compare Versions</h2>
</div>
{onClose && (
<Button variant="ghost" size="sm" onClick={onClose}>
<X className="h-4 w-4" />
</Button>
)}
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label>Version 1</Label>
<Select value={version1Id} onValueChange={setVersion1Id}>
<SelectTrigger>
<SelectValue placeholder="Select version" />
</SelectTrigger>
<SelectContent>
{versions.map((version) => (
<SelectItem key={version.version_id} value={version.version_id}>
{version.version_name} - {new Date(version.created_at).toLocaleDateString()}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label>Version 2</Label>
<Select value={version2Id} onValueChange={setVersion2Id}>
<SelectTrigger>
<SelectValue placeholder="Select version" />
</SelectTrigger>
<SelectContent>
{versions.map((version) => (
<SelectItem key={version.version_id} value={version.version_id}>
{version.version_name} - {new Date(version.created_at).toLocaleDateString()}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{version1 && version2 && (
<div className="space-y-4">
{differences.length === 0 ? (
<Alert>
<AlertDescription>
These versions are identical.
</AlertDescription>
</Alert>
) : (
<Tabs defaultValue="summary">
<TabsList>
<TabsTrigger value="summary">Summary</TabsTrigger>
<TabsTrigger value="system-prompt">System Prompt</TabsTrigger>
<TabsTrigger value="tools">Tools</TabsTrigger>
</TabsList>
<TabsContent value="summary">
<Card>
<CardHeader>
<CardTitle>Differences Summary</CardTitle>
<CardDescription>
{differences.length} difference{differences.length !== 1 ? 's' : ''} found
</CardDescription>
</CardHeader>
<CardContent>
<ScrollArea className="h-96">
<div className="space-y-2">
{differences.map((diff, index) => (
<div key={index} className="flex items-center gap-2 p-2 rounded-lg hover:bg-muted">
{diff.type === 'added' && <Plus className="h-4 w-4 text-green-500" />}
{diff.type === 'removed' && <Minus className="h-4 w-4 text-red-500" />}
{diff.type === 'modified' && <RefreshCw className="h-4 w-4 text-yellow-500" />}
<span className="font-medium">{diff.field}</span>
{diff.type === 'modified' && (
<span className="text-sm text-muted-foreground">
{diff.oldValue} {diff.newValue}
</span>
)}
</div>
))}
</div>
</ScrollArea>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="system-prompt">
{version1.system_prompt !== version2.system_prompt ? (
<div className="grid grid-cols-2 gap-4">
<div>
<Label className="mb-2">Version 1: {version1.version_name}</Label>
<Textarea
value={version1.system_prompt}
readOnly
className="h-96 font-mono text-sm"
/>
</div>
<div>
<Label className="mb-2">Version 2: {version2.version_name}</Label>
<Textarea
value={version2.system_prompt}
readOnly
className="h-96 font-mono text-sm"
/>
</div>
</div>
) : (
<Alert>
<AlertDescription>
System prompts are identical.
</AlertDescription>
</Alert>
)}
</TabsContent>
<TabsContent value="tools">
<Card>
<CardHeader>
<CardTitle>Tool Differences</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
{differences
.filter(diff => diff.field.startsWith('Tool:'))
.map((diff, index) => (
<div key={index} className="flex items-center justify-between p-3 border rounded-lg">
<div className="flex items-center gap-2">
{diff.type === 'added' && <Plus className="h-4 w-4 text-green-500" />}
{diff.type === 'removed' && <Minus className="h-4 w-4 text-red-500" />}
{diff.type === 'modified' && <RefreshCw className="h-4 w-4 text-yellow-500" />}
<span className="font-medium">{diff.field.replace('Tool: ', '')}</span>
</div>
<Badge variant={diff.type === 'added' ? 'default' : diff.type === 'removed' ? 'destructive' : 'secondary'}>
{diff.type}
</Badge>
</div>
))}
</div>
</CardContent>
</Card>
</TabsContent>
</Tabs>
)}
{onActivateVersion && (
<div className="flex justify-end gap-2">
<Button
variant="outline"
onClick={() => onActivateVersion(version1Id)}
disabled={version1.is_active}
>
Activate {version1.version_name}
</Button>
<Button
variant="outline"
onClick={() => onActivateVersion(version2Id)}
disabled={version2.is_active}
>
Activate {version2.version_name}
</Button>
</div>
)}
</div>
)}
</div>
);
}

View File

@ -133,18 +133,14 @@ export const ChatInput = forwardRef<ChatInputHandles, ChatInputProps>(
clearPendingFiles: () => setPendingFiles([]),
}));
// Load saved agent from localStorage on mount
useEffect(() => {
if (typeof window !== 'undefined' && onAgentSelect && !hasLoadedFromLocalStorage.current) {
// Don't load from localStorage if an agent is already selected
// or if there are URL parameters that might be setting the agent
const urlParams = new URLSearchParams(window.location.search);
const hasAgentIdInUrl = urlParams.has('agent_id');
if (!selectedAgentId && !hasAgentIdInUrl) {
const savedAgentId = localStorage.getItem('lastSelectedAgentId');
if (savedAgentId) {
// Convert 'suna' back to undefined for the default agent
const agentIdToSelect = savedAgentId === 'suna' ? undefined : savedAgentId;
console.log('Loading saved agent from localStorage:', savedAgentId);
onAgentSelect(agentIdToSelect);
@ -367,7 +363,7 @@ export const ChatInput = forwardRef<ChatInputHandles, ChatInputProps>(
{enableAdvancedConfig && selectedAgentId && (
<div className="w-full border-t border-border/30 bg-muted/20 px-4 py-1.5 rounded-b-3xl border-l border-r border-b border-border">
<div className="flex items-center justify-center">
<div className="flex items-center justify-between">
<div className="flex items-center gap-1 sm:gap-2 overflow-x-auto scrollbar-none">
<button
onClick={() => setRegistryDialogOpen(true)}

View File

@ -12,6 +12,7 @@ interface EditableTextProps {
placeholder?: string;
multiline?: boolean;
minHeight?: string;
disabled?: boolean;
}
export const EditableText: React.FC<EditableTextProps> = ({
@ -20,7 +21,8 @@ export const EditableText: React.FC<EditableTextProps> = ({
className = '',
placeholder = 'Click to edit...',
multiline = false,
minHeight = 'auto'
minHeight = 'auto',
disabled = false
}) => {
const [isEditing, setIsEditing] = useState(false);
const [editValue, setEditValue] = useState(value);
@ -71,6 +73,7 @@ export const EditableText: React.FC<EditableTextProps> = ({
lineHeight: 'inherit',
...(multiline && minHeight ? { minHeight } : {})
}}
disabled={disabled}
/>
</div>
);

View File

@ -14,6 +14,7 @@ interface ExpandableMarkdownEditorProps {
className?: string;
placeholder?: string;
title?: string;
disabled?: boolean;
}
export const ExpandableMarkdownEditor: React.FC<ExpandableMarkdownEditorProps> = ({
@ -21,7 +22,8 @@ export const ExpandableMarkdownEditor: React.FC<ExpandableMarkdownEditorProps> =
onSave,
className = '',
placeholder = 'Click to edit...',
title = 'Edit Instructions'
title = 'Edit Instructions',
disabled = false
}) => {
const [isDialogOpen, setIsDialogOpen] = useState(false);
const [isEditing, setIsEditing] = useState(false);
@ -175,6 +177,7 @@ export const ExpandableMarkdownEditor: React.FC<ExpandableMarkdownEditorProps> =
onKeyDown={handleKeyDown}
className="w-full h-[500px] rounded-xl bg-muted/30 p-4 resize-none"
style={{ minHeight: '300px' }}
disabled={disabled}
/>
</ScrollArea>
<div className="text-xs text-muted-foreground/50 flex-shrink-0">

View File

@ -7,6 +7,7 @@ import {
AgentVersion,
AgentVersionCreateRequest
} from './utils';
import { agentKeys } from './keys';
import { toast } from 'sonner';
export const useAgentVersions = (agentId: string) => {
@ -17,10 +18,10 @@ export const useAgentVersions = (agentId: string) => {
});
};
export const useAgentVersion = (agentId: string, versionId: string) => {
export const useAgentVersion = (agentId: string, versionId: string | null | undefined) => {
return useQuery({
queryKey: ['agent-version', agentId, versionId],
queryFn: () => getAgentVersion(agentId, versionId),
queryFn: () => getAgentVersion(agentId, versionId!),
enabled: !!agentId && !!versionId,
});
};
@ -34,6 +35,7 @@ export const useCreateAgentVersion = () => {
onSuccess: (newVersion, { agentId }) => {
queryClient.invalidateQueries({ queryKey: ['agent-versions', agentId] });
queryClient.invalidateQueries({ queryKey: ['agent', agentId] });
queryClient.invalidateQueries({ queryKey: ['agents'] });
toast.success(`Created version ${newVersion.version_name}`);
},
onError: (error: Error) => {
@ -51,6 +53,7 @@ export const useActivateAgentVersion = () => {
onSuccess: (_, { agentId }) => {
queryClient.invalidateQueries({ queryKey: ['agent', agentId] });
queryClient.invalidateQueries({ queryKey: ['agent-versions', agentId] });
queryClient.invalidateQueries({ queryKey: ['agents'] });
toast.success('Version activated successfully');
},
onError: (error: Error) => {

View File

@ -29,6 +29,9 @@ export type Agent = {
updated_at: string;
avatar?: string;
avatar_color?: string;
current_version_id?: string | null;
version_count?: number;
current_version?: AgentVersion | null;
};
export type PaginationInfo = {
@ -92,6 +95,8 @@ export type AgentVersionCreateRequest = {
enabledTools: string[];
}>;
agentpress_tools?: Record<string, any>;
version_name?: string;
description?: string;
};
export type AgentVersion = {
@ -107,6 +112,7 @@ export type AgentVersion = {
created_at: string;
updated_at: string;
created_by?: string;
change_description?: string;
};
export type AgentUpdateRequest = {
@ -125,6 +131,8 @@ export type AgentUpdateRequest = {
}>;
agentpress_tools?: Record<string, any>;
is_default?: boolean;
avatar?: string;
avatar_color?: string;
};
export const getAgents = async (params: AgentsParams = {}): Promise<AgentsResponse> => {

View File

@ -8,6 +8,7 @@ export const pipedreamKeys = {
workflowRuns: (workflowId: string) => [...pipedreamKeys.all, 'workflow-runs', workflowId] as const,
apps: (page: number, search?: string, category?: string) => [...pipedreamKeys.all, 'apps', page, search || '', category || ''] as const,
appsSearch: (query: string, page: number, category?: string) => [...pipedreamKeys.all, 'apps', 'search', query, page, category || ''] as const,
popularApps: () => [...pipedreamKeys.all, 'apps', 'popular'] as const,
availableTools: () => [...pipedreamKeys.all, 'available-tools'] as const,
mcpDiscovery: (options?: { app_slug?: string; oauth_app_id?: string; custom?: boolean }) =>
[...pipedreamKeys.all, 'mcp-discovery', options?.app_slug, options?.oauth_app_id, options?.custom] as const,

View File

@ -62,6 +62,17 @@ export const usePipedreamApps = (after?: string, search?: string) => {
});
};
export const usePipedreamPopularApps = () => {
return useQuery({
queryKey: pipedreamKeys.popularApps(),
queryFn: async (): Promise<PipedreamAppResponse> => {
return await pipedreamApi.getPopularApps();
},
staleTime: 30 * 60 * 1000,
retry: 2,
});
};
export const usePipedreamAvailableTools = createQueryHook(
pipedreamKeys.availableTools(),
async (forceRefresh: boolean = false): Promise<PipedreamToolsResponse> => {

View File

@ -209,6 +209,24 @@ export const pipedreamApi = {
return data;
},
async getPopularApps(): Promise<PipedreamAppResponse> {
const result = await backendApi.get<PipedreamAppResponse>(
'/pipedream/apps/popular',
{
errorContext: { operation: 'load popular apps', resource: 'Pipedream popular apps' },
}
);
if (!result.success) {
throw new Error(result.error?.message || 'Failed to get popular apps');
}
const data = result.data!;
if (!data.success && data.error) {
throw new Error(data.error);
}
return data;
},
async getAvailableTools(): Promise<PipedreamToolsResponse> {
const result = await backendApi.get<PipedreamToolsResponse>(
'/pipedream/mcp/available-tools',

View File

@ -0,0 +1,139 @@
import { useMemo, useEffect } from 'react';
import { useSearchParams } from 'next/navigation';
import { useAgent } from '@/hooks/react-query/agents/use-agents';
import { useAgentVersion } from '@/hooks/react-query/agents/use-agent-versions';
import { useAgentVersionStore } from '@/lib/stores/agent-version-store';
interface NormalizedMCP {
name: string;
type: string;
customType: string;
config: Record<string, any>;
enabledTools: string[];
}
interface NormalizedVersionData {
version_id: string;
agent_id: string;
version_number: number;
version_name: string;
system_prompt: string;
configured_mcps: any[];
custom_mcps: NormalizedMCP[];
agentpress_tools: Record<string, any>;
is_active: boolean;
created_at: string;
updated_at: string;
created_by?: string;
change_description?: string;
}
interface UseAgentVersionDataProps {
agentId: string;
}
interface UseAgentVersionDataReturn {
agent: any;
versionData: NormalizedVersionData | null;
isViewingOldVersion: boolean;
isLoading: boolean;
error: Error | null;
}
function normalizeCustomMcps(mcps: any): NormalizedMCP[] {
if (!mcps || !Array.isArray(mcps)) {
return [];
}
return mcps.map(mcp => {
if (!mcp || typeof mcp !== 'object') {
return {
name: 'Unknown MCP',
type: 'sse',
customType: 'sse',
config: {},
enabledTools: []
};
}
return {
name: mcp.name || 'Unnamed MCP',
type: mcp.type || mcp.customType || 'sse',
customType: mcp.customType || mcp.type || 'sse',
config: mcp.config || {},
enabledTools: mcp.enabledTools || mcp.enabled_tools || []
};
});
}
function normalizeVersionData(version: any): NormalizedVersionData | null {
if (!version) return null;
return {
version_id: version.version_id,
agent_id: version.agent_id,
version_number: version.version_number,
version_name: version.version_name,
system_prompt: version.system_prompt || '',
configured_mcps: Array.isArray(version.configured_mcps) ? version.configured_mcps : [],
custom_mcps: normalizeCustomMcps(version.custom_mcps),
agentpress_tools: version.agentpress_tools && typeof version.agentpress_tools === 'object'
? version.agentpress_tools
: {},
is_active: version.is_active ?? true,
created_at: version.created_at,
updated_at: version.updated_at || version.created_at,
created_by: version.created_by,
change_description: version.change_description
};
}
export function useAgentVersionData({ agentId }: UseAgentVersionDataProps): UseAgentVersionDataReturn {
const searchParams = useSearchParams();
const versionParam = searchParams.get('version');
const { data: agent, isLoading: agentLoading, error: agentError } = useAgent(agentId);
// Load version data if we have a version param OR if we need the current version
const shouldLoadVersion = versionParam || agent?.current_version_id;
const versionToLoad = versionParam || agent?.current_version_id || '';
const { data: rawVersionData, isLoading: versionLoading, error: versionError } = useAgentVersion(
agentId,
shouldLoadVersion ? versionToLoad : null
);
const { setCurrentVersion, clearVersionState } = useAgentVersionStore();
const versionData = useMemo(() => {
return normalizeVersionData(rawVersionData);
}, [rawVersionData]);
const isViewingOldVersion = useMemo(() => {
return Boolean(versionParam && versionParam !== agent?.current_version_id);
}, [versionParam, agent?.current_version_id]);
useEffect(() => {
if (versionData) {
setCurrentVersion(versionData);
} else if (!versionParam) {
clearVersionState();
}
return () => {
clearVersionState();
};
}, [versionData, versionParam, setCurrentVersion, clearVersionState]);
const isLoading = agentLoading || (shouldLoadVersion ? versionLoading : false);
const error = agentError || versionError;
return {
agent,
versionData,
isViewingOldVersion,
isLoading,
error
};
}

View File

@ -0,0 +1,85 @@
import { create } from 'zustand';
import { devtools } from 'zustand/middleware';
import { AgentVersion } from '@/hooks/react-query/agents/utils';
interface VersionState {
// Current version being viewed/edited
currentVersion: AgentVersion | null;
// Version being compared to
compareVersion: AgentVersion | null;
// UI state
isViewingVersion: boolean;
isComparingVersions: boolean;
hasUnsavedChanges: boolean;
// Actions
setCurrentVersion: (version: AgentVersion | null) => void;
setCompareVersion: (version: AgentVersion | null) => void;
setIsViewingVersion: (viewing: boolean) => void;
setIsComparingVersions: (comparing: boolean) => void;
setHasUnsavedChanges: (hasChanges: boolean) => void;
// Helper actions
clearVersionState: () => void;
isViewingOldVersion: (currentVersionId?: string) => boolean;
}
export const useAgentVersionStore = create<VersionState>()(
devtools(
(set, get) => ({
// Initial state
currentVersion: null,
compareVersion: null,
isViewingVersion: false,
isComparingVersions: false,
hasUnsavedChanges: false,
// Actions
setCurrentVersion: (version) => set({
currentVersion: version,
isViewingVersion: version !== null
}),
setCompareVersion: (version) => set({
compareVersion: version,
isComparingVersions: version !== null
}),
setIsViewingVersion: (viewing) => set({ isViewingVersion: viewing }),
setIsComparingVersions: (comparing) => set({
isComparingVersions: comparing,
compareVersion: comparing ? get().compareVersion : null
}),
setHasUnsavedChanges: (hasChanges) => set({ hasUnsavedChanges: hasChanges }),
// Helper actions
clearVersionState: () => set({
currentVersion: null,
compareVersion: null,
isViewingVersion: false,
isComparingVersions: false,
hasUnsavedChanges: false
}),
isViewingOldVersion: (currentVersionId?: string) => {
const state = get();
return state.isViewingVersion &&
state.currentVersion !== null &&
state.currentVersion.version_id !== currentVersionId;
}
}),
{
name: 'agent-version-store'
}
)
);
// Selectors for common use cases
export const selectCurrentVersion = (state: VersionState) => state.currentVersion;
export const selectIsViewingOldVersion = (state: VersionState) => state.isViewingVersion && state.currentVersion !== null;
export const selectHasUnsavedChanges = (state: VersionState) => state.hasUnsavedChanges;
export const selectIsComparingVersions = (state: VersionState) => state.isComparingVersions;