diff --git a/backend/app/api/api.py b/backend/app/api/api.py index 681ad538..5a09c45b 100644 --- a/backend/app/api/api.py +++ b/backend/app/api/api.py @@ -3,7 +3,7 @@ # SPDX-License-Identifier: Apache-2.0 from app.api.endpoints import auth, users, repository, oidc, quota, admin -from app.api.endpoints.adapter import models, agents, bots, teams, tasks, executors +from app.api.endpoints.adapter import models, agents, bots, teams, tasks, executors, dify from app.api.endpoints.kind import k_router from app.api.router import api_router @@ -19,4 +19,5 @@ api_router.include_router(repository.router, prefix="/git", tags=["repository"]) api_router.include_router(executors.router, prefix="/executors", tags=["executors"]) api_router.include_router(quota.router, prefix="/quota", tags=["quota"]) +api_router.include_router(dify.router, prefix="/dify", tags=["dify"]) api_router.include_router(k_router) \ No newline at end of file diff --git a/backend/app/api/endpoints/adapter/dify.py b/backend/app/api/endpoints/adapter/dify.py new file mode 100644 index 00000000..0dce1973 --- /dev/null +++ b/backend/app/api/endpoints/adapter/dify.py @@ -0,0 +1,155 @@ +# SPDX-FileCopyrightText: 2025 Weibo, Inc. +# +# SPDX-License-Identifier: Apache-2.0 + +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session +from typing import Dict, Any +from pydantic import BaseModel +import requests + +from app.api.dependencies import get_db +from app.core import security +from app.models.user import User +from shared.logger import setup_logger +from shared.utils.crypto import decrypt_sensitive_data, is_data_encrypted + +logger = setup_logger("dify_api") + +router = APIRouter() + + +class DifyAppInfoRequest(BaseModel): + """Request to get Dify app info""" + api_key: str + base_url: str = "https://api.dify.ai" + + +@router.post("/app/info") +def get_dify_app_info( + request: DifyAppInfoRequest, + db: Session = Depends(get_db), + current_user: User = Depends(security.get_current_user) +) -> Dict[str, Any]: + """ + Get Dify application information using API key + + Uses Dify's /v1/info endpoint to retrieve basic app information. + This can be used to validate the API key and get app details. + + Args: + request: Contains api_key and base_url + + Returns: + App information including name, description, mode, etc. + """ + + try: + # Decrypt API key if it's encrypted + api_key = request.api_key + if api_key and is_data_encrypted(api_key): + api_key = decrypt_sensitive_data(api_key) or api_key + logger.info("Decrypted API key for Dify app info request") + + api_url = f"{request.base_url}/v1/info" + headers = { + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json" + } + + logger.info(f"Fetching Dify app info from: {api_url}") + + response = requests.get( + api_url, + headers=headers, + timeout=10 + ) + + response.raise_for_status() + data = response.json() + + logger.info(f"Successfully fetched Dify app info: {data.get('name', 'Unknown')}") + return data + + except requests.exceptions.HTTPError as e: + error_msg = f"Dify API HTTP error: {e}" + if e.response is not None: + try: + error_data = e.response.json() + error_msg = f"Dify API error: {error_data.get('message', str(e))}" + except: + pass + logger.error(error_msg) + raise HTTPException(status_code=502, detail=error_msg) + + except requests.exceptions.RequestException as e: + error_msg = f"Failed to connect to Dify API: {str(e)}" + logger.error(error_msg) + raise HTTPException(status_code=502, detail=error_msg) + + except Exception as e: + error_msg = f"Unexpected error: {str(e)}" + logger.error(error_msg) + raise HTTPException(status_code=500, detail=error_msg) + + +@router.post("/app/parameters") +def get_dify_app_parameters( + request: DifyAppInfoRequest, + db: Session = Depends(get_db), + current_user: User = Depends(security.get_current_user) +) -> Dict[str, Any]: + """ + Get parameters schema for a Dify application + + Uses Dify's /v1/parameters endpoint to retrieve app input parameters schema. + + Args: + request: Contains api_key and base_url + + Returns: + Parameters schema with user_input_form and system_parameters + """ + + try: + # Decrypt API key if it's encrypted + api_key = request.api_key + if api_key and is_data_encrypted(api_key): + api_key = decrypt_sensitive_data(api_key) or api_key + logger.info("Decrypted API key for Dify app parameters request") + + api_url = f"{request.base_url}/v1/parameters" + headers = { + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json" + } + + logger.info(f"Fetching Dify app parameters from: {api_url}") + + response = requests.get( + api_url, + headers=headers, + timeout=10 + ) + + response.raise_for_status() + data = response.json() + + logger.info("Successfully fetched Dify app parameters") + return data + + except requests.exceptions.HTTPError as e: + error_msg = f"Dify API HTTP error: {e}" + if e.response is not None: + try: + error_data = e.response.json() + error_msg = f"Dify API error: {error_data.get('message', str(e))}" + except: + pass + logger.error(error_msg) + raise HTTPException(status_code=502, detail=error_msg) + + except Exception as e: + error_msg = f"Failed to fetch app parameters: {str(e)}" + logger.error(error_msg) + raise HTTPException(status_code=500, detail=error_msg) diff --git a/backend/app/api/endpoints/adapter/teams.py b/backend/app/api/endpoints/adapter/teams.py index 41a4d1d6..4bb7fb32 100644 --- a/backend/app/api/endpoints/adapter/teams.py +++ b/backend/app/api/endpoints/adapter/teams.py @@ -92,6 +92,19 @@ def share_team( user_id=current_user.id, ) +@router.get("/{team_id}/input-parameters") +def get_team_input_parameters( + team_id: int, + current_user: User = Depends(security.get_current_user), + db: Session = Depends(get_db) +): + """Get input parameters required by the team's external API bots""" + return team_kinds_service.get_team_input_parameters( + db=db, + team_id=team_id, + user_id=current_user.id + ) + @router.get("/share/info") def get_share_info( share_token: str = Query(..., description="Share token"), diff --git a/backend/app/schemas/team.py b/backend/app/schemas/team.py index c2f363cf..94a82de9 100644 --- a/backend/app/schemas/team.py +++ b/backend/app/schemas/team.py @@ -48,6 +48,7 @@ class TeamInDB(TeamBase): updated_at: datetime user: Optional[dict[str, Any]] = None share_status: int = 0 # 0-private, 1-sharing, 2-shared from others + agent_type: Optional[str] = None # agno, claude, dify, etc. class Config: from_attributes = True diff --git a/backend/app/services/adapters/bot_kinds.py b/backend/app/services/adapters/bot_kinds.py index 4656b931..b73c3e9a 100644 --- a/backend/app/services/adapters/bot_kinds.py +++ b/backend/app/services/adapters/bot_kinds.py @@ -5,6 +5,7 @@ from datetime import datetime from typing import List, Optional, Dict, Any import json +import copy from fastapi import HTTPException from sqlalchemy.orm import Session @@ -17,6 +18,8 @@ from app.schemas.bot import BotCreate, BotUpdate, BotInDB, BotDetail from app.schemas.kind import Ghost, Bot, Shell, Model, Team from app.services.base import BaseService +from app.services.adapters.shell_utils import get_shell_type +from shared.utils.crypto import encrypt_sensitive_data, is_data_encrypted class BotKindsService(BaseService[Kind, BotCreate, BotUpdate]): @@ -24,6 +27,36 @@ class BotKindsService(BaseService[Kind, BotCreate, BotUpdate]): Bot service class using kinds table """ + # List of sensitive keys that should be encrypted in agent_config + SENSITIVE_CONFIG_KEYS = [ + "DIFY_API_KEY", + # Add more sensitive keys here as needed + ] + + def _encrypt_agent_config(self, agent_config: Dict[str, Any]) -> Dict[str, Any]: + """ + Encrypt sensitive data in agent_config before storing + + Args: + agent_config: Original agent config dictionary + + Returns: + Agent config with encrypted sensitive fields + """ + # Create a deep copy to avoid modifying the original + encrypted_config = copy.deepcopy(agent_config) + + # Encrypt sensitive keys in env section + if "env" in encrypted_config: + for key in self.SENSITIVE_CONFIG_KEYS: + if key in encrypted_config["env"]: + value = encrypted_config["env"][key] + # Only encrypt if not already encrypted + if value and not is_data_encrypted(str(value)): + encrypted_config["env"][key] = encrypt_sensitive_data(str(value)) + + return encrypted_config + def create_with_user( self, db: Session, *, obj_in: BotCreate, user_id: int ) -> Dict[str, Any]: @@ -48,6 +81,9 @@ def create_with_user( if obj_in.skills: self._validate_skills(db, obj_in.skills, user_id) + # Encrypt sensitive data in agent_config before storing + encrypted_agent_config = self._encrypt_agent_config(obj_in.agent_config) + # Create Ghost ghost_spec = { "systemPrompt": obj_in.system_prompt or "", @@ -83,7 +119,7 @@ def create_with_user( model_json = { "kind": "Model", "spec": { - "modelConfig": obj_in.agent_config + "modelConfig": encrypted_agent_config }, "status": { "state": "Available" @@ -104,8 +140,8 @@ def create_with_user( is_active=True ) db.add(model) - support_model = [] + shell_type = "local_engine" # Default shell type if obj_in.agent_name: public_shell = db.query(PublicShell).filter( PublicShell.name == obj_in.agent_name, @@ -115,6 +151,9 @@ def create_with_user( if public_shell and isinstance(public_shell.json, dict): shell_crd = Shell.model_validate(public_shell.json) support_model = shell_crd.spec.supportModel or [] + # Get shell type from metadata.labels + if shell_crd.metadata.labels and "type" in shell_crd.metadata.labels: + shell_type = shell_crd.metadata.labels["type"] shell_json = { "kind": "Shell", @@ -122,12 +161,15 @@ def create_with_user( "runtime": obj_in.agent_name, "supportModel": support_model }, - "status": { - "state": "Available" - }, "metadata": { "name": f"{obj_in.name}-shell", - "namespace": "default" + "namespace": "default", + "labels": { + "type": shell_type + } + }, + "status": { + "state": "Available" }, "apiVersion": "agent.wecode.io/v1" } @@ -305,8 +347,9 @@ def update_with_user( flag_modified(bot, "json") # Mark JSON field as modified if "agent_name" in update_data and shell: - # Query public_shells table to get supportModel based on new agent_name + # Query public_shells table to get supportModel and shell type based on new agent_name support_model = [] + shell_type = "local_engine" # Default shell type new_agent_name = update_data["agent_name"] if new_agent_name: public_shell = db.query(PublicShell).filter( @@ -315,18 +358,27 @@ def update_with_user( ).first() if public_shell and isinstance(public_shell.json, dict): - shell_crd = Shell.model_validate(public_shell.json) - support_model = shell_crd.spec.supportModel or [] + public_shell_crd = Shell.model_validate(public_shell.json) + support_model = public_shell_crd.spec.supportModel or [] + # Get shell type from metadata.labels + if public_shell_crd.metadata.labels and "type" in public_shell_crd.metadata.labels: + shell_type = public_shell_crd.metadata.labels["type"] shell_crd = Shell.model_validate(shell.json) shell_crd.spec.runtime = new_agent_name shell_crd.spec.supportModel = support_model + # Update shell type in metadata.labels + if not shell_crd.metadata.labels: + shell_crd.metadata.labels = {} + shell_crd.metadata.labels["type"] = shell_type shell.json = shell_crd.model_dump() flag_modified(shell, "json") # Mark JSON field as modified if "agent_config" in update_data and model: model_crd = Model.model_validate(model.json) - model_crd.spec.modelConfig = update_data["agent_config"] + # Encrypt sensitive data before updating + encrypted_agent_config = self._encrypt_agent_config(update_data["agent_config"]) + model_crd.spec.modelConfig = encrypted_agent_config model.json = model_crd.model_dump() flag_modified(model, "json") # Mark JSON field as modified diff --git a/backend/app/services/adapters/shell_utils.py b/backend/app/services/adapters/shell_utils.py new file mode 100644 index 00000000..eb35705e --- /dev/null +++ b/backend/app/services/adapters/shell_utils.py @@ -0,0 +1,82 @@ +# SPDX-FileCopyrightText: 2025 Weibo, Inc. +# +# SPDX-License-Identifier: Apache-2.0 + +""" +Utility functions for Shell type detection and classification +""" + +from typing import Optional +from sqlalchemy.orm import Session + +from app.models.kind import Kind +from app.schemas.kind import Shell + + +def get_shell_type(db: Session, shell_name: str, shell_namespace: str, user_id: int) -> Optional[str]: + """ + Get the shell type (local_engine or external_api) for a given shell + + Shell type is stored in metadata.labels.type + + Args: + db: Database session + shell_name: Name of the shell + shell_namespace: Namespace of the shell + user_id: User ID + + Returns: + "local_engine", "external_api", or None if shell not found + """ + shell = db.query(Kind).filter( + Kind.user_id == user_id, + Kind.kind == "Shell", + Kind.name == shell_name, + Kind.namespace == shell_namespace, + Kind.is_active == True + ).first() + + if not shell: + return None + + shell_crd = Shell.model_validate(shell.json) + + # Get type from metadata.labels, default to local_engine + if shell_crd.metadata.labels and "type" in shell_crd.metadata.labels: + return shell_crd.metadata.labels["type"] + + return "local_engine" + + +def is_external_api_shell(db: Session, shell_name: str, shell_namespace: str, user_id: int) -> bool: + """ + Check if a shell is an external API type + + Args: + db: Database session + shell_name: Name of the shell + shell_namespace: Namespace of the shell + user_id: User ID + + Returns: + True if the shell is an external API type, False otherwise + """ + shell_type = get_shell_type(db, shell_name, shell_namespace, user_id) + return shell_type == "external_api" + + +def is_local_engine_shell(db: Session, shell_name: str, shell_namespace: str, user_id: int) -> bool: + """ + Check if a shell is a local engine type + + Args: + db: Database session + shell_name: Name of the shell + shell_namespace: Namespace of the shell + user_id: User ID + + Returns: + True if the shell is a local engine type, False otherwise + """ + shell_type = get_shell_type(db, shell_name, shell_namespace, user_id) + return shell_type == "local_engine" diff --git a/backend/app/services/adapters/team_kinds.py b/backend/app/services/adapters/team_kinds.py index 4c1748aa..f3d970eb 100644 --- a/backend/app/services/adapters/team_kinds.py +++ b/backend/app/services/adapters/team_kinds.py @@ -5,6 +5,7 @@ from datetime import datetime from typing import List, Optional, Dict, Any import json +import copy from fastapi import HTTPException from sqlalchemy.orm import Session @@ -17,12 +18,46 @@ from app.schemas.team import TeamCreate, TeamUpdate, TeamInDB, TeamDetail, BotInfo from app.schemas.kind import Team, Bot, Ghost, Shell, Model, Task from app.services.base import BaseService +from app.services.adapters.shell_utils import get_shell_type +from shared.utils.crypto import decrypt_sensitive_data, is_data_encrypted class TeamKindsService(BaseService[Kind, TeamCreate, TeamUpdate]): """ Team service class using kinds table """ + # List of sensitive keys that should be decrypted when reading + SENSITIVE_CONFIG_KEYS = [ + "DIFY_API_KEY", + # Add more sensitive keys here as needed + ] + + def _decrypt_agent_config(self, agent_config: Dict[str, Any]) -> Dict[str, Any]: + """ + Decrypt sensitive data in agent_config when reading + + Args: + agent_config: Agent config with potentially encrypted fields + + Returns: + Agent config with decrypted sensitive fields + """ + # Create a deep copy to avoid modifying the original + decrypted_config = copy.deepcopy(agent_config) + + # Decrypt sensitive keys in env section + if "env" in decrypted_config: + for key in self.SENSITIVE_CONFIG_KEYS: + if key in decrypted_config["env"]: + value = decrypted_config["env"][key] + # Only decrypt if it appears to be encrypted + if value and is_data_encrypted(str(value)): + decrypted_value = decrypt_sensitive_data(str(value)) + if decrypted_value: + decrypted_config["env"][key] = decrypted_value + + return decrypted_config + def create_with_user( self, db: Session, *, obj_in: TeamCreate, user_id: int ) -> Dict[str, Any]: @@ -518,13 +553,14 @@ def count_user_teams(self, db: Session, *, user_id: int) -> int: def _validate_bots(self, db: Session, bots: List[BotInfo], user_id: int) -> None: """ Validate bots and check if bots belong to user and are active + Also validates Dify runtime constraint: Dify Teams must have exactly one bot """ if not bots: raise HTTPException( status_code=400, detail="bots cannot be empty" ) - + bot_id_list = [] for bot in bots: if hasattr(bot, 'bot_id'): @@ -536,7 +572,7 @@ def _validate_bots(self, db: Session, bots: List[BotInfo], user_id: int) -> None status_code=400, detail="Invalid bot format: missing bot_id" ) - + # Check if all bots exist, belong to user, and are active in kinds table bots_in_db = db.query(Kind).filter( Kind.id.in_(bot_id_list), @@ -544,16 +580,47 @@ def _validate_bots(self, db: Session, bots: List[BotInfo], user_id: int) -> None Kind.kind == "Bot", Kind.is_active == True ).all() - + found_bot_ids = {bot.id for bot in bots_in_db} missing_bot_ids = set(bot_id_list) - found_bot_ids - + if missing_bot_ids: raise HTTPException( status_code=400, detail=f"Invalid or inactive bot_ids: {', '.join(map(str, missing_bot_ids))}" ) - + + # Validate external API shell constraint: must have exactly one bot + for bot in bots_in_db: + bot_crd = Bot.model_validate(bot.json) + + # Get shell type using utility function + shell_type = get_shell_type( + db, + bot_crd.spec.shellRef.name, + bot_crd.spec.shellRef.namespace, + user_id + ) + + if shell_type == "external_api": + # Get shell for error message + shell = db.query(Kind).filter( + Kind.user_id == user_id, + Kind.kind == "Shell", + Kind.name == bot_crd.spec.shellRef.name, + Kind.namespace == bot_crd.spec.shellRef.namespace, + Kind.is_active == True + ).first() + + if shell: + shell_crd = Shell.model_validate(shell.json) + # External API shells (like Dify) can only have one bot per team + if len(bots) > 1: + raise HTTPException( + status_code=400, + detail=f"Teams using external API shells ({shell_crd.spec.runtime}) must have exactly one bot. Found {len(bots)} bots." + ) + def get_team_by_id(self, db: Session, *, team_id: int, user_id: int) -> Optional[Kind]: """ Get team by id, checking both user's own teams and shared teams @@ -643,6 +710,8 @@ def _convert_to_team_dict(self, team: Kind, db: Session, user_id: int) -> Dict[s # Convert members to bots format bots = [] + agent_type = None # Will store the type of the first bot + for member in team_crd.spec.members: # Find bot in kinds table bot = db.query(Kind).filter( @@ -660,6 +729,30 @@ def _convert_to_team_dict(self, team: Kind, db: Session, user_id: int) -> Dict[s "role": member.role or "" } bots.append(bot_info) + + # Get agent_type from the first bot's shell + if agent_type is None: + bot_crd = Bot.model_validate(bot.json) + shell = db.query(Kind).filter( + Kind.user_id == user_id, + Kind.kind == "Shell", + Kind.name == bot_crd.spec.shellRef.name, + Kind.namespace == bot_crd.spec.shellRef.namespace, + Kind.is_active == True + ).first() + + if shell: + shell_crd = Shell.model_validate(shell.json) + runtime = shell_crd.spec.runtime + # Map runtime to agent type + if runtime == "AgnoShell": + agent_type = "agno" + elif runtime == "ClaudeCodeShell": + agent_type = "claude" + elif runtime == "DifyShell": + agent_type = "dify" + else: + agent_type = runtime.lower().replace("shell", "") # Convert collaboration model to workflow format workflow = {"mode": team_crd.spec.collaborationModel} @@ -673,6 +766,7 @@ def _convert_to_team_dict(self, team: Kind, db: Session, user_id: int) -> Dict[s "is_active": team.is_active, "created_at": team.created_at, "updated_at": team.updated_at, + "agent_type": agent_type, # Add agent_type field } def _convert_bot_to_dict(self, bot: Kind, db: Session, user_id: int) -> Dict[str, Any]: @@ -767,5 +861,214 @@ def _update_team_references_in_tasks(self, db: Session, old_name: str, old_names task.updated_at = datetime.now() flag_modified(task, "json") + def get_team_input_parameters( + self, db: Session, *, team_id: int, user_id: int + ) -> Dict[str, Any]: + """ + Get input parameters required by the team's external API bots + Returns parameter schema if team has external API bots, otherwise empty + """ + # Get team details + team = db.query(Kind).filter( + Kind.id == team_id, + Kind.kind == "Team", + Kind.is_active == True + ).first() + + if not team: + raise HTTPException( + status_code=404, + detail="Team not found" + ) + + # Check if user has access to this team + is_author = team.user_id == user_id + shared_team = None + + if not is_author: + shared_team = db.query(SharedTeam).filter( + SharedTeam.user_id == user_id, + SharedTeam.team_id == team_id, + SharedTeam.is_active == True + ).first() + if not shared_team: + raise HTTPException( + status_code=403, + detail="Access denied to this team" + ) + + # Get original user context + original_user_id = team.user_id if is_author else shared_team.original_user_id + team_dict = self._convert_to_team_dict(team, db, original_user_id) + + # Check if team has any external API bots (like Dify) + has_external_api_bot = False + external_api_bot = None + + for bot_info in team_dict["bots"]: + bot_id = bot_info["bot_id"] + bot = db.query(Kind).filter( + Kind.id == bot_id, + Kind.user_id == original_user_id, + Kind.kind == "Bot", + Kind.is_active == True + ).first() + + if bot: + bot_crd = Bot.model_validate(bot.json) + # Check if bot uses external API shell + shell_name = bot_crd.spec.shellRef.name + shell_namespace = bot_crd.spec.shellRef.namespace + + shell = db.query(Kind).filter( + Kind.name == shell_name, + Kind.namespace == shell_namespace, + Kind.user_id == original_user_id, + Kind.kind == "Shell", + Kind.is_active == True + ).first() + + if shell: + # Use utility function to get shell type + shell_type = get_shell_type( + db, + shell_name, + shell_namespace, + original_user_id + ) + + print(f"[DEBUG] Bot {bot.name} uses shell {shell_name} with type: {shell_type}") + + if shell_type == "external_api": + has_external_api_bot = True + external_api_bot = bot_crd + print(f"[DEBUG] Found external API bot: {bot.name}") + break + + if not has_external_api_bot: + print("[DEBUG] No external API bot found in team") + return { + "has_parameters": False, + "parameters": [] + } + + # Get bot's model to extract API credentials + model_ref = external_api_bot.spec.modelRef + model = db.query(Kind).filter( + Kind.user_id == original_user_id, + Kind.kind == "Model", + Kind.name == model_ref.name, + Kind.namespace == model_ref.namespace, + Kind.is_active == True + ).first() + + if not model: + return { + "has_parameters": False, + "parameters": [] + } + + model_crd = Model.model_validate(model.json) + agent_config = model_crd.spec.modelConfig or {} + + # Decrypt sensitive data before using + decrypted_agent_config = self._decrypt_agent_config(agent_config) + env = decrypted_agent_config.get("env", {}) + + print(f"[DEBUG] Model config env keys: {list(env.keys())}") + + # For Dify bots, we need to call Dify API to get parameters + api_key = env.get("DIFY_API_KEY", "") + base_url = env.get("DIFY_BASE_URL", "https://api.dify.ai") + + print(f"[DEBUG] API Key present: {bool(api_key)}, Base URL: {base_url}") + + if not api_key: + print("[DEBUG] No DIFY_API_KEY found in model config") + return { + "has_parameters": False, + "parameters": [] + } + + # Call external API to get parameter schema and app info + try: + import requests + + # Get app info to retrieve mode + app_mode = None + try: + info_response = requests.get( + f"{base_url}/v1/info", + headers={ + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json" + }, + timeout=10 + ) + if info_response.status_code == 200: + info_data = info_response.json() + app_mode = info_data.get("mode") + print(f"[DEBUG] Retrieved Dify app mode: {app_mode}") + except Exception as e: + print(f"[DEBUG] Failed to fetch app info: {e}") + + # Get app parameters + response = requests.get( + f"{base_url}/v1/parameters", + headers={ + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json" + }, + timeout=10 + ) + if response.status_code == 200: + data = response.json() + user_input_form = data.get("user_input_form", []) + + # Transform Dify's nested format to flat format expected by frontend + # Dify format: [{"text-input": {"variable": "x", ...}}, ...] + # Frontend expects: [{"variable": "x", "type": "text-input", ...}, ...] + transformed_params = [] + for item in user_input_form: + if isinstance(item, dict): + # Each item is like {"text-input": {...}} or {"select": {...}} + for param_type, param_data in item.items(): + if isinstance(param_data, dict): + # Add type field and flatten + transformed_param = {**param_data, "type": param_type} + transformed_params.append(transformed_param) + + print(f"[DEBUG] Successfully fetched and transformed {len(transformed_params)} parameters from Dify API") + # has_parameters is true as long as the API request succeeds (external API bot exists) + result = { + "has_parameters": True, + "parameters": transformed_params + } + + # Add app_mode if available + if app_mode: + result["app_mode"] = app_mode + + return result + else: + print(f"[DEBUG] Dify API returned status {response.status_code}: {response.text}") + # has_parameters is true as long as external API bot exists, even if parameters API fails + result = { + "has_parameters": True, + "parameters": [] + } + if app_mode: + result["app_mode"] = app_mode + return result + except Exception as e: + print(f"[DEBUG] Failed to fetch parameters from external API: {e}") + import traceback + traceback.print_exc() + # has_parameters is true as long as external API bot exists, even if API call fails + return { + "has_parameters": True, + "parameters": [] + } + team_kinds_service = TeamKindsService(Kind) diff --git a/backend/init_data/02-public-shells.yaml b/backend/init_data/02-public-shells.yaml index 4af5a5f9..d48ecf7c 100644 --- a/backend/init_data/02-public-shells.yaml +++ b/backend/init_data/02-public-shells.yaml @@ -9,6 +9,8 @@ kind: Shell metadata: name: ClaudeCode namespace: default + labels: + type: local_engine spec: runtime: ClaudeCode supportModel: [] @@ -20,8 +22,23 @@ kind: Shell metadata: name: Agno namespace: default + labels: + type: local_engine spec: runtime: Agno supportModel: [] status: state: Available +--- +apiVersion: agent.wecode.io/v1 +kind: Shell +metadata: + name: Dify + namespace: default + labels: + type: external_api +spec: + runtime: Dify + supportModel: [] +status: + state: Available diff --git a/executor/agents/dify/__init__.py b/executor/agents/dify/__init__.py new file mode 100644 index 00000000..21458f3f --- /dev/null +++ b/executor/agents/dify/__init__.py @@ -0,0 +1,11 @@ +#!/usr/bin/env python + +# SPDX-FileCopyrightText: 2025 Weibo, Inc. +# +# SPDX-License-Identifier: Apache-2.0 + +# -*- coding: utf-8 -*- + +from .dify_agent import DifyAgent + +__all__ = ["DifyAgent"] diff --git a/executor/agents/dify/dify_agent.py b/executor/agents/dify/dify_agent.py new file mode 100644 index 00000000..34bcc0cc --- /dev/null +++ b/executor/agents/dify/dify_agent.py @@ -0,0 +1,579 @@ +#!/usr/bin/env python + +# SPDX-FileCopyrightText: 2025 Weibo, Inc. +# +# SPDX-License-Identifier: Apache-2.0 + +# -*- coding: utf-8 -*- + +import json +import requests +from typing import Dict, Any, Optional + +from executor.agents.base import Agent +from shared.logger import setup_logger +from shared.status import TaskStatus +from shared.models.task import ExecutionResult +from shared.utils.crypto import decrypt_sensitive_data, is_data_encrypted + +logger = setup_logger("dify_agent") + + +class DifyAgent(Agent): + """ + Dify Agent - External API Reference Type + + Unlike local execution engines (ClaudeCode, Agno), this agent acts as + a lightweight proxy to Dify's external API service. It doesn't execute + code locally but delegates all computation to Dify's cloud service. + + Supports Dify chatbot, workflow, agent, and chatflow applications. + """ + + # Agent type classification + AGENT_TYPE = "external_api" + + # Static dictionary for storing conversation IDs per task + _conversations: Dict[str, str] = {} + + def get_name(self) -> str: + return "Dify" + + def __init__(self, task_data: Dict[str, Any]): + """ + Initialize the Dify Agent + + Args: + task_data: The task data dictionary + """ + super().__init__(task_data) + + self.prompt = task_data.get("prompt", "") + self.bot_prompt = task_data.get("bot_prompt", "") + + # Extract Dify configuration from Model environment variables + self.dify_config = self._extract_dify_config(task_data) + + # Extract params from prompt (highest priority - task-specific parameters) + self.prompt, prompt_params = self._extract_params_from_prompt(self.prompt) + + # Parse bot_prompt to get difyAppId and params + self.dify_app_id, self.params = self._parse_bot_prompt(self.bot_prompt) + + # Merge params from agent_config (takes priority over bot_prompt) + config_params = self.dify_config.get("params", {}) + if config_params: + self.params.update(config_params) + + # Merge params from prompt (highest priority - overwrites all previous) + if prompt_params: + self.params.update(prompt_params) + + # If no app_id from bot_prompt, use default from config + if not self.dify_app_id: + self.dify_app_id = self.dify_config.get("app_id", "") + + # Get application info to determine the app mode + self.app_mode = self._get_app_mode() + + # Get or create conversation ID for this task (only for chat/chatflow) + self.conversation_id = self._get_conversation_id() if self.app_mode in ["chat", "chatflow", "agent-chat"] else None + + logger.info( + f"DifyAgent initialized for task {self.task_id}, " + f"app_mode={self.app_mode}, conversation_id={self.conversation_id}" + ) + + def _extract_dify_config(self, task_data: Dict[str, Any]) -> Dict[str, str]: + """ + Extract Dify configuration from task_data + + Args: + task_data: The task data dictionary + + Returns: + Dict containing Dify configuration (api_key, base_url, app_id, params) + """ + config = { + "api_key": "", + "base_url": "", + "app_id": "", + "params": {} + } + + # Try to extract from bot -> agent_config -> env + # Note: task_data uses "bot" key, not "team_members" + bots = task_data.get("bot", []) + if bots and len(bots) > 0: + bot = bots[0] + agent_config = bot.get("agent_config", {}) + + # agent_config structure: {"env": {"DIFY_API_KEY": "xxx", "DIFY_BASE_URL": "xxx"}} + env = agent_config.get("env", {}) + + # Extract and decrypt API key + api_key = env.get("DIFY_API_KEY", "") + if api_key and is_data_encrypted(api_key): + api_key = decrypt_sensitive_data(api_key) or "" + + config["api_key"] = api_key + config["base_url"] = env.get("DIFY_BASE_URL", "https://api.dify.ai") # Default base URL + config["app_id"] = env.get("DIFY_APP_ID", "") + + # Extract params if exists + if env.get("DIFY_PARAMS"): + try: + params_str = env.get("DIFY_PARAMS", "{}") + config["params"] = json.loads(params_str) if isinstance(params_str, str) else params_str + except json.JSONDecodeError as e: + logger.warning(f"Failed to parse DIFY_PARAMS: {e}, using empty params") + config["params"] = {} + + return config + + def _extract_params_from_prompt(self, prompt: str) -> tuple[str, Dict[str, Any]]: + """ + Extract external API parameters from prompt using special markers + + Format: [EXTERNAL_API_PARAMS]{"param1": "value1"}[/EXTERNAL_API_PARAMS] + The actual user prompt follows after the marker + + Args: + prompt: The full prompt text + + Returns: + Tuple of (cleaned_prompt, params_dict) + """ + import re + + # Pattern to match [EXTERNAL_API_PARAMS]...json...[/EXTERNAL_API_PARAMS] + pattern = r'\[EXTERNAL_API_PARAMS\](.*?)\[/EXTERNAL_API_PARAMS\]' + match = re.search(pattern, prompt, re.DOTALL) + + if not match: + return prompt, {} + + try: + # Extract JSON string + params_json = match.group(1).strip() + params = json.loads(params_json) + + # Remove the marker block from prompt + cleaned_prompt = re.sub(pattern, '', prompt, flags=re.DOTALL).strip() + + logger.info(f"Extracted external API params from prompt: {params}") + return cleaned_prompt, params + except json.JSONDecodeError as e: + logger.warning(f"Failed to parse external API params from prompt: {e}") + # Return original prompt if parsing fails + return prompt, {} + + def _parse_bot_prompt(self, bot_prompt: str) -> tuple[Optional[str], Dict[str, Any]]: + """ + Parse bot_prompt JSON to extract difyAppId and params + + Args: + bot_prompt: JSON string containing difyAppId and params + + Returns: + Tuple of (dify_app_id, params) + """ + if not bot_prompt: + return None, {} + + try: + prompt_data = json.loads(bot_prompt) + dify_app_id = prompt_data.get("difyAppId") + params = prompt_data.get("params", {}) + return dify_app_id, params + except json.JSONDecodeError as e: + logger.warning(f"Failed to parse bot_prompt as JSON: {e}, using empty params") + return None, {} + + def _get_app_mode(self) -> str: + """ + Get Dify application mode by calling /v1/info endpoint + + Returns: + Application mode: "chat", "chatflow", "workflow", "agent-chat", "completion" + Returns "chat" as default if unable to determine + """ + if not self.dify_config.get("api_key") or not self.dify_config.get("base_url"): + logger.warning("Cannot get app mode: API key or base URL not configured") + return "chat" # Default to chat mode + + try: + api_url = f"{self.dify_config['base_url']}/v1/info" + headers = { + "Authorization": f"Bearer {self.dify_config['api_key']}", + "Content-Type": "application/json" + } + + logger.info(f"Fetching app info from: {api_url}") + response = requests.get(api_url, headers=headers, timeout=10) + response.raise_for_status() + + data = response.json() + app_mode = data.get("mode", "chat") + logger.info(f"Detected Dify app mode: {app_mode}") + return app_mode + + except Exception as e: + logger.warning(f"Failed to get app mode from Dify API, defaulting to 'chat': {e}") + return "chat" + + def _get_conversation_id(self) -> str: + """ + Get or create conversation ID for this task + + Returns: + Conversation ID + """ + task_key = str(self.task_id) + if task_key in self._conversations: + return self._conversations[task_key] + return "" + + def _save_conversation_id(self, conversation_id: str) -> None: + """ + Save conversation ID for this task + + Args: + conversation_id: The conversation ID to save + """ + task_key = str(self.task_id) + self._conversations[task_key] = conversation_id + logger.info(f"Saved conversation_id {conversation_id} for task {self.task_id}") + + def _validate_config(self) -> bool: + """ + Validate Dify configuration + + Returns: + True if configuration is valid, False otherwise + """ + if not self.dify_config.get("api_key"): + logger.error("DIFY_API_KEY is not configured") + return False + + if not self.dify_config.get("base_url"): + logger.error("DIFY_BASE_URL is not configured") + return False + + # DIFY_APP_ID is no longer required since each API key corresponds to one app + # Keeping the check for backward compatibility with bot_prompt + # if not self.dify_app_id: + # logger.error("DIFY_APP_ID is not configured (neither in Model env nor in bot_prompt)") + # return False + + return True + + def _call_dify_api(self, query: str) -> Dict[str, Any]: + """ + Call Dify API - automatically selects endpoint based on app mode + + Args: + query: The user message to send + + Returns: + API response data + + Raises: + Exception: If API call fails + """ + # Route to appropriate API based on app mode + if self.app_mode == "workflow": + return self._call_workflow_api(query) + else: + # chat, chatflow, agent-chat, completion all use chat-messages endpoint + return self._call_chat_api(query) + + def _call_chat_api(self, query: str) -> Dict[str, Any]: + """ + Call Dify Chat/Chatflow API (for chat, chatflow, agent-chat modes) + + Args: + query: The user message to send + + Returns: + API response data with answer and conversation_id + """ + api_url = f"{self.dify_config['base_url']}/v1/chat-messages" + + headers = { + "Authorization": f"Bearer {self.dify_config['api_key']}", + "Content-Type": "application/json" + } + + payload = { + "inputs": self.params, # For chatflow, inputs are workflow variables + "query": query, + "response_mode": "streaming", + "user": f"task-{self.task_id}", + "auto_generate_name": True + } + + # Add conversation_id if exists (for multi-turn conversations) + if self.conversation_id: + payload["conversation_id"] = self.conversation_id + + logger.info(f"Calling Dify Chat API ({self.app_mode}): {api_url}") + logger.debug(f"Payload: {json.dumps(payload, ensure_ascii=False)}") + + try: + response = requests.post( + api_url, + headers=headers, + json=payload, + stream=True, + timeout=300 # 5 minutes timeout + ) + + response.raise_for_status() + + # Process streaming response + result_text = "" + conversation_id = "" + + for line in response.iter_lines(): + if line: + line_str = line.decode('utf-8') + if line_str.startswith('data: '): + data_str = line_str[6:] # Remove 'data: ' prefix + try: + data = json.loads(data_str) + + # Extract conversation_id + if "conversation_id" in data and not conversation_id: + conversation_id = data["conversation_id"] + + # Extract message content + if data.get("event") == "message": + result_text += data.get("answer", "") + elif data.get("event") == "agent_message": + result_text += data.get("answer", "") + elif data.get("event") == "message_end": + # Final message, may contain complete answer + pass + elif data.get("event") == "error": + error_msg = data.get("message", "Unknown error") + raise Exception(f"Dify API error: {error_msg}") + except json.JSONDecodeError: + logger.warning(f"Failed to parse streaming data: {data_str}") + continue + + # Save conversation_id for next message + if conversation_id: + self._save_conversation_id(conversation_id) + + return { + "answer": result_text, + "conversation_id": conversation_id + } + + except requests.exceptions.HTTPError as e: + error_msg = f"Dify Chat API HTTP error: {e}" + if e.response is not None: + try: + error_data = e.response.json() + error_msg = f"Dify Chat API error: {error_data.get('message', str(e))}" + except: + pass + logger.error(error_msg) + raise Exception(error_msg) + + except requests.exceptions.RequestException as e: + error_msg = f"Failed to connect to Dify Chat API: {str(e)}" + logger.error(error_msg) + raise Exception(error_msg) + + def _call_workflow_api(self, query: str) -> Dict[str, Any]: + """ + Call Dify Workflow API (for workflow mode) + + Args: + query: The user message (will be added to inputs) + + Returns: + API response data with outputs + """ + api_url = f"{self.dify_config['base_url']}/v1/workflows/run" + + headers = { + "Authorization": f"Bearer {self.dify_config['api_key']}", + "Content-Type": "application/json" + } + + # For workflow, combine query with params as inputs + inputs = dict(self.params) + # Add query as a common input variable if not already present + if "query" not in inputs and "user_query" not in inputs: + inputs["query"] = query + + payload = { + "inputs": inputs, + "response_mode": "streaming", # Can also be "blocking" + "user": f"task-{self.task_id}" + } + + logger.info(f"Calling Dify Workflow API: {api_url}") + logger.debug(f"Payload: {json.dumps(payload, ensure_ascii=False)}") + + try: + response = requests.post( + api_url, + headers=headers, + json=payload, + stream=True, + timeout=300 # 5 minutes timeout + ) + + response.raise_for_status() + + # Process streaming response + result_outputs = {} + workflow_run_id = "" + + for line in response.iter_lines(): + if line: + line_str = line.decode('utf-8') + if line_str.startswith('data: '): + data_str = line_str[6:] # Remove 'data: ' prefix + try: + data = json.loads(data_str) + + # Extract workflow_run_id + if "workflow_run_id" in data and not workflow_run_id: + workflow_run_id = data["workflow_run_id"] + + # Extract outputs from workflow events + if data.get("event") == "workflow_finished": + result_outputs = data.get("data", {}).get("outputs", {}) + elif data.get("event") == "node_finished": + # Optionally log node completion + node_title = data.get("data", {}).get("title", "") + logger.debug(f"Workflow node finished: {node_title}") + elif data.get("event") == "error": + error_msg = data.get("message", "Unknown error") + raise Exception(f"Dify Workflow error: {error_msg}") + except json.JSONDecodeError: + logger.warning(f"Failed to parse streaming data: {data_str}") + continue + + # Format workflow output as answer text + answer_text = json.dumps(result_outputs, ensure_ascii=False, indent=2) + + return { + "answer": answer_text, + "workflow_run_id": workflow_run_id, + "outputs": result_outputs + } + + except requests.exceptions.HTTPError as e: + error_msg = f"Dify Workflow API HTTP error: {e}" + if e.response is not None: + try: + error_data = e.response.json() + error_msg = f"Dify Workflow API error: {error_data.get('message', str(e))}" + except: + pass + logger.error(error_msg) + raise Exception(error_msg) + + except requests.exceptions.RequestException as e: + error_msg = f"Failed to connect to Dify Workflow API: {str(e)}" + logger.error(error_msg) + raise Exception(error_msg) + + def pre_execute(self) -> TaskStatus: + """ + For external API agents, pre_execute is minimal. + No need to download code or setup local environment since + all execution happens on Dify's cloud service. + + Returns: + TaskStatus: Pre-execution status + """ + logger.info( + f"DifyAgent[{self.task_id}] is an external API type, " + "skipping code download and environment setup" + ) + return TaskStatus.SUCCESS + + def execute(self) -> TaskStatus: + """ + Execute the Dify Agent task + + Returns: + TaskStatus: Execution status + """ + try: + # Validate configuration + if not self._validate_config(): + self.report_progress( + 100, + TaskStatus.FAILED.value, + "Dify configuration is incomplete or invalid" + ) + return TaskStatus.FAILED + + # Report starting progress + self.report_progress( + 10, + TaskStatus.RUNNING.value, + "Starting Dify Agent execution" + ) + + # Call Dify API + logger.info(f"Sending query to Dify: {self.prompt[:100]}...") + self.report_progress( + 30, + TaskStatus.RUNNING.value, + "Sending message to Dify application" + ) + + result = self._call_dify_api(self.prompt) + + # Extract answer + answer = result.get("answer", "") + + if answer: + logger.info(f"Received response from Dify, length: {len(answer)}") + self.report_progress( + 100, + TaskStatus.COMPLETED.value, + "Dify Agent execution completed", + result=ExecutionResult(value=answer).dict() + ) + return TaskStatus.COMPLETED + else: + logger.warning("No answer received from Dify API") + self.report_progress( + 100, + TaskStatus.FAILED.value, + "No answer received from Dify application" + ) + return TaskStatus.FAILED + + except Exception as e: + error_message = str(e) + logger.exception(f"Error in Dify Agent execution: {error_message}") + self.report_progress( + 100, + TaskStatus.FAILED.value, + f"Dify Agent execution failed: {error_message}" + ) + return TaskStatus.FAILED + + @classmethod + def clear_conversation(cls, task_id: int) -> None: + """ + Clear conversation ID for a specific task + + Args: + task_id: The task ID + """ + task_key = str(task_id) + if task_key in cls._conversations: + del cls._conversations[task_key] + logger.info(f"Cleared conversation for task {task_id}") diff --git a/executor/agents/factory.py b/executor/agents/factory.py index 16ffb14a..e301480b 100644 --- a/executor/agents/factory.py +++ b/executor/agents/factory.py @@ -12,6 +12,7 @@ from executor.agents.base import Agent from executor.agents.claude_code.claude_code_agent import ClaudeCodeAgent from executor.agents.agno.agno_agent import AgnoAgent +from executor.agents.dify.dify_agent import DifyAgent logger = setup_logger("agent_factory") @@ -19,9 +20,13 @@ class AgentFactory: """ Factory class for creating agent instances based on agent_type + + Agents are classified into two types: + - local_engine: Agents that execute code locally (ClaudeCode, Agno) + - external_api: Agents that delegate execution to external services (Dify) """ - _agents = {"claudecode": ClaudeCodeAgent, "agno": AgnoAgent} + _agents = {"claudecode": ClaudeCodeAgent, "agno": AgnoAgent, "dify": DifyAgent} @classmethod def get_agent(cls, agent_type: str, task_data: Dict[str, Any]) -> Optional[Agent]: @@ -41,3 +46,37 @@ def get_agent(cls, agent_type: str, task_data: Dict[str, Any]) -> Optional[Agent else: logger.error(f"Unsupported agent type: {agent_type}") return None + + @classmethod + def is_external_api_agent(cls, agent_type: str) -> bool: + """ + Check if an agent type is an external API type + + Args: + agent_type: The type of agent to check + + Returns: + True if the agent is an external API type, False otherwise + """ + agent_class = cls._agents.get(agent_type.lower()) + if agent_class and hasattr(agent_class, 'AGENT_TYPE'): + return agent_class.AGENT_TYPE == "external_api" + return False + + @classmethod + def get_agent_type(cls, agent_type: str) -> Optional[str]: + """ + Get the agent type classification (local_engine or external_api) + + Args: + agent_type: The type of agent to check + + Returns: + "local_engine", "external_api", or None if agent type not found + """ + agent_class = cls._agents.get(agent_type.lower()) + if agent_class: + if hasattr(agent_class, 'AGENT_TYPE'): + return agent_class.AGENT_TYPE + return "local_engine" # Default for older agents without AGENT_TYPE + return None diff --git a/executor/callback/callback_handler.py b/executor/callback/callback_handler.py index 26905ed8..e7690d46 100644 --- a/executor/callback/callback_handler.py +++ b/executor/callback/callback_handler.py @@ -112,7 +112,9 @@ def send_task_started_callback( def send_task_completed_callback( task_id: int, + subtask_id: int, task_title: str, + subtask_title: Optional[str] = None, message: str = "Task executed successfully", executor_name: Optional[str] = None, executor_namespace: Optional[str] = None, @@ -122,7 +124,9 @@ def send_task_completed_callback( Args: task_id (str): Task ID + subtask_id (int): Subtask ID task_title (str): Task title + subtask_title (str, optional): Subtask title message (str, optional): Message. Defaults to "Task executed successfully". executor_name (str, optional): Executor name executor_namespace (str, optional): Executor namespace @@ -132,7 +136,9 @@ def send_task_completed_callback( """ return send_status_callback( task_id=task_id, + subtask_id=subtask_id, task_title=task_title, + subtask_title=subtask_title, status=TaskStatus.COMPLETED.value, message=message, progress=100, diff --git a/executor/tests/agents/test_dify_agent.py b/executor/tests/agents/test_dify_agent.py new file mode 100644 index 00000000..0ce9b294 --- /dev/null +++ b/executor/tests/agents/test_dify_agent.py @@ -0,0 +1,257 @@ +# SPDX-FileCopyrightText: 2025 Weibo, Inc. +# +# SPDX-License-Identifier: Apache-2.0 + +import pytest +import json +from unittest.mock import Mock, patch, MagicMock +from executor.agents.dify.dify_agent import DifyAgent +from shared.status import TaskStatus + + +class TestDifyAgent: + """Test cases for DifyAgent""" + + @pytest.fixture + def task_data(self): + """Sample task data for testing""" + return { + "task_id": 123, + "subtask_id": 456, + "task_title": "Test Task", + "subtask_title": "Test Subtask", + "prompt": "Hello Dify", + "bot_prompt": json.dumps({ + "difyAppId": "app-test-123", + "params": { + "customer_name": "John Doe", + "language": "en-US" + } + }), + "team_members": [{ + "agent_config": { + "env": { + "DIFY_API_KEY": "app-test-api-key", + "DIFY_BASE_URL": "https://api.dify.ai", + "DIFY_APP_ID": "app-default-123" + } + } + }], + "user": { + "user_name": "testuser" + } + } + + def test_init(self, task_data): + """Test DifyAgent initialization""" + agent = DifyAgent(task_data) + + assert agent is not None + assert agent.task_id == 123 + assert agent.prompt == "Hello Dify" + assert agent.dify_app_id == "app-test-123" + assert agent.params == {"customer_name": "John Doe", "language": "en-US"} + assert agent.dify_config["api_key"] == "app-test-api-key" + assert agent.dify_config["base_url"] == "https://api.dify.ai" + + def test_init_without_bot_prompt(self, task_data): + """Test DifyAgent initialization without bot_prompt""" + task_data["bot_prompt"] = "" + agent = DifyAgent(task_data) + + assert agent.dify_app_id == "app-default-123" # Should use default from config + assert agent.params == {} + + def test_parse_bot_prompt_valid(self, task_data): + """Test parsing valid bot_prompt""" + agent = DifyAgent(task_data) + + app_id, params = agent._parse_bot_prompt(task_data["bot_prompt"]) + + assert app_id == "app-test-123" + assert params == {"customer_name": "John Doe", "language": "en-US"} + + def test_parse_bot_prompt_invalid_json(self, task_data): + """Test parsing invalid JSON bot_prompt""" + agent = DifyAgent(task_data) + + app_id, params = agent._parse_bot_prompt("invalid json") + + assert app_id is None + assert params == {} + + def test_parse_bot_prompt_empty(self, task_data): + """Test parsing empty bot_prompt""" + agent = DifyAgent(task_data) + + app_id, params = agent._parse_bot_prompt("") + + assert app_id is None + assert params == {} + + def test_validate_config_success(self, task_data): + """Test config validation with valid config""" + agent = DifyAgent(task_data) + + result = agent._validate_config() + + assert result is True + + def test_validate_config_missing_api_key(self, task_data): + """Test config validation with missing API key""" + task_data["team_members"][0]["agent_config"]["env"]["DIFY_API_KEY"] = "" + agent = DifyAgent(task_data) + + result = agent._validate_config() + + assert result is False + + def test_validate_config_missing_base_url(self, task_data): + """Test config validation with missing base URL""" + task_data["team_members"][0]["agent_config"]["env"]["DIFY_BASE_URL"] = "" + agent = DifyAgent(task_data) + + result = agent._validate_config() + + assert result is False + + def test_validate_config_missing_app_id(self, task_data): + """Test config validation with missing app ID""" + task_data["team_members"][0]["agent_config"]["env"]["DIFY_APP_ID"] = "" + task_data["bot_prompt"] = "" + agent = DifyAgent(task_data) + + result = agent._validate_config() + + assert result is False + + @patch('executor.agents.dify.dify_agent.requests.post') + def test_call_dify_api_success(self, mock_post, task_data): + """Test successful Dify API call""" + # Mock streaming response + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.iter_lines.return_value = [ + b'data: {"event": "message", "answer": "Hello", "conversation_id": "conv-123"}', + b'data: {"event": "message", "answer": " World"}', + b'data: {"event": "message_end"}' + ] + mock_post.return_value = mock_response + + agent = DifyAgent(task_data) + result = agent._call_dify_api("Test query") + + assert result["answer"] == "Hello World" + assert result["conversation_id"] == "conv-123" + assert mock_post.called + + @patch('executor.agents.dify.dify_agent.requests.post') + def test_call_dify_api_error_response(self, mock_post, task_data): + """Test Dify API call with error response""" + # Mock error response + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.iter_lines.return_value = [ + b'data: {"event": "error", "message": "Invalid app ID"}' + ] + mock_post.return_value = mock_response + + agent = DifyAgent(task_data) + + with pytest.raises(Exception) as exc_info: + agent._call_dify_api("Test query") + + assert "Dify API error" in str(exc_info.value) + + @patch('executor.agents.dify.dify_agent.requests.post') + def test_call_dify_api_http_error(self, mock_post, task_data): + """Test Dify API call with HTTP error""" + # Mock HTTP error + mock_response = MagicMock() + mock_response.status_code = 404 + mock_response.raise_for_status.side_effect = Exception("404 Not Found") + mock_post.return_value = mock_response + + agent = DifyAgent(task_data) + + with pytest.raises(Exception): + agent._call_dify_api("Test query") + + @patch.object(DifyAgent, '_call_dify_api') + @patch.object(DifyAgent, '_validate_config') + def test_execute_success(self, mock_validate, mock_call_api, task_data): + """Test successful execution""" + mock_validate.return_value = True + mock_call_api.return_value = { + "answer": "This is the answer from Dify", + "conversation_id": "conv-123" + } + + agent = DifyAgent(task_data) + result = agent.execute() + + assert result == TaskStatus.COMPLETED + assert mock_validate.called + assert mock_call_api.called + + @patch.object(DifyAgent, '_validate_config') + def test_execute_invalid_config(self, mock_validate, task_data): + """Test execution with invalid config""" + mock_validate.return_value = False + + agent = DifyAgent(task_data) + result = agent.execute() + + assert result == TaskStatus.FAILED + + @patch.object(DifyAgent, '_call_dify_api') + @patch.object(DifyAgent, '_validate_config') + def test_execute_no_answer(self, mock_validate, mock_call_api, task_data): + """Test execution with no answer from API""" + mock_validate.return_value = True + mock_call_api.return_value = { + "answer": "", + "conversation_id": "conv-123" + } + + agent = DifyAgent(task_data) + result = agent.execute() + + assert result == TaskStatus.FAILED + + @patch.object(DifyAgent, '_call_dify_api') + @patch.object(DifyAgent, '_validate_config') + def test_execute_api_exception(self, mock_validate, mock_call_api, task_data): + """Test execution with API exception""" + mock_validate.return_value = True + mock_call_api.side_effect = Exception("API call failed") + + agent = DifyAgent(task_data) + result = agent.execute() + + assert result == TaskStatus.FAILED + + def test_conversation_id_management(self, task_data): + """Test conversation ID management""" + agent = DifyAgent(task_data) + + # Initially empty + assert agent.conversation_id == "" + + # Save conversation ID + agent._save_conversation_id("conv-test-123") + + # Create new agent instance for same task + agent2 = DifyAgent(task_data) + assert agent2.conversation_id == "conv-test-123" + + # Clear conversation + DifyAgent.clear_conversation(task_data["task_id"]) + agent3 = DifyAgent(task_data) + assert agent3.conversation_id == "" + + def test_get_name(self, task_data): + """Test get_name method""" + agent = DifyAgent(task_data) + + assert agent.get_name() == "Dify" diff --git a/executor/tests/agents/test_factory.py b/executor/tests/agents/test_factory.py index 6084ba84..3ae37147 100644 --- a/executor/tests/agents/test_factory.py +++ b/executor/tests/agents/test_factory.py @@ -6,6 +6,7 @@ from executor.agents.factory import AgentFactory from executor.agents.claude_code.claude_code_agent import ClaudeCodeAgent from executor.agents.agno.agno_agent import AgnoAgent +from executor.agents.dify.dify_agent import DifyAgent class TestAgentFactory: @@ -31,7 +32,7 @@ def task_data(self): def test_get_claudecode_agent(self, task_data): """Test creating ClaudeCode agent""" agent = AgentFactory.get_agent("claudecode", task_data) - + assert agent is not None assert isinstance(agent, ClaudeCodeAgent) assert agent.task_id == task_data["task_id"] @@ -39,14 +40,14 @@ def test_get_claudecode_agent(self, task_data): def test_get_claudecode_agent_case_insensitive(self, task_data): """Test creating ClaudeCode agent with different case""" agent = AgentFactory.get_agent("ClaudeCode", task_data) - + assert agent is not None assert isinstance(agent, ClaudeCodeAgent) def test_get_agno_agent(self, task_data): """Test creating Agno agent""" agent = AgentFactory.get_agent("agno", task_data) - + assert agent is not None assert isinstance(agent, AgnoAgent) assert agent.task_id == task_data["task_id"] @@ -54,25 +55,42 @@ def test_get_agno_agent(self, task_data): def test_get_agno_agent_case_insensitive(self, task_data): """Test creating Agno agent with different case""" agent = AgentFactory.get_agent("AGNO", task_data) - + assert agent is not None assert isinstance(agent, AgnoAgent) + def test_get_dify_agent(self, task_data): + """Test creating Dify agent""" + agent = AgentFactory.get_agent("dify", task_data) + + assert agent is not None + assert isinstance(agent, DifyAgent) + assert agent.task_id == task_data["task_id"] + + def test_get_dify_agent_case_insensitive(self, task_data): + """Test creating Dify agent with different case""" + agent = AgentFactory.get_agent("DIFY", task_data) + + assert agent is not None + assert isinstance(agent, DifyAgent) + def test_get_unsupported_agent(self, task_data): """Test creating unsupported agent type""" agent = AgentFactory.get_agent("unsupported_type", task_data) - + assert agent is None def test_get_empty_agent_type(self, task_data): """Test creating agent with empty type""" agent = AgentFactory.get_agent("", task_data) - + assert agent is None def test_agents_registry(self): """Test that agents registry contains expected agents""" assert "claudecode" in AgentFactory._agents assert "agno" in AgentFactory._agents + assert "dify" in AgentFactory._agents assert AgentFactory._agents["claudecode"] == ClaudeCodeAgent - assert AgentFactory._agents["agno"] == AgnoAgent \ No newline at end of file + assert AgentFactory._agents["agno"] == AgnoAgent + assert AgentFactory._agents["dify"] == DifyAgent diff --git a/frontend/package-lock.json b/frontend/package-lock.json index fa22d703..cf9defbb 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -11,6 +11,7 @@ "@headlessui/react": "^2.2.0", "@heroicons/react": "^2.2.0", "@hookform/resolvers": "^5.2.2", + "@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", @@ -2945,6 +2946,37 @@ "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==", "license": "MIT" }, + "node_modules/@radix-ui/react-accordion": { + "version": "1.2.12", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-accordion/-/react-accordion-1.2.12.tgz", + "integrity": "sha512-T4nygeh9YE9dLRPhAHSeOZi7HBXo+0kYIPJXayZfvWOWA0+n3dESrZbjfDPUABkUNym6Hd+f2IR113To8D2GPA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collapsible": "1.1.12", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-arrow": { "version": "1.1.7", "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz", @@ -2998,6 +3030,36 @@ } } }, + "node_modules/@radix-ui/react-collapsible": { + "version": "1.1.12", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-collapsible/-/react-collapsible-1.1.12.tgz", + "integrity": "sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-collection": { "version": "1.1.7", "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz", diff --git a/frontend/package.json b/frontend/package.json index 0c1abf80..7bb5503b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -17,6 +17,7 @@ "@headlessui/react": "^2.2.0", "@heroicons/react": "^2.2.0", "@hookform/resolvers": "^5.2.2", + "@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", diff --git a/frontend/src/apis/dify.ts b/frontend/src/apis/dify.ts new file mode 100644 index 00000000..e3d851da --- /dev/null +++ b/frontend/src/apis/dify.ts @@ -0,0 +1,20 @@ +// SPDX-FileCopyrightText: 2025 Weibo, Inc. +// +// SPDX-License-Identifier: Apache-2.0 + +import { apiClient } from './client'; +import { DifyApp, DifyParametersSchema } from '@/types/api'; + +/** + * Get list of Dify applications + */ +export async function getDifyApps(): Promise { + return apiClient.get('/dify/apps'); +} + +/** + * Get parameters schema for a specific Dify application + */ +export async function getDifyAppParameters(appId: string): Promise { + return apiClient.get(`/dify/apps/${appId}/parameters`); +} diff --git a/frontend/src/apis/team.ts b/frontend/src/apis/team.ts index c7924668..5d5e6eac 100644 --- a/frontend/src/apis/team.ts +++ b/frontend/src/apis/team.ts @@ -38,6 +38,23 @@ export interface TeamShareJoinRequest { share_token: string; } +// Team Input Parameters Response Type +export interface TeamInputParametersResponse { + has_parameters: boolean; + parameters: Array<{ + variable: string; + label: string | Record; + required: boolean; + type: string; + options?: string[]; + max_length?: number; + placeholder?: string; + default?: string; + hint?: string; + }>; + app_mode?: string; // Dify app mode: 'chat', 'chatflow', 'workflow', 'completion', 'agent' +} + export const teamApis = { async getTeams(params?: PaginationParams): Promise { const p = params ? params : { page: 1, limit: 100 }; @@ -62,4 +79,7 @@ export const teamApis = { async joinSharedTeam(data: TeamShareJoinRequest): Promise { return apiClient.post('/teams/share/join', data); }, + async getTeamInputParameters(teamId: number): Promise { + return apiClient.get(`/teams/${teamId}/input-parameters`); + }, }; diff --git a/frontend/src/components/ui/accordion.tsx b/frontend/src/components/ui/accordion.tsx new file mode 100644 index 00000000..c4e41dce --- /dev/null +++ b/frontend/src/components/ui/accordion.tsx @@ -0,0 +1,58 @@ +// SPDX-FileCopyrightText: 2025 Weibo, Inc. +// +// SPDX-License-Identifier: Apache-2.0 + +'use client'; + +import * as React from 'react'; +import * as AccordionPrimitive from '@radix-ui/react-accordion'; +import { ChevronDown } from 'lucide-react'; + +import { cn } from '@/lib/utils'; + +const Accordion = AccordionPrimitive.Root; + +const AccordionItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AccordionItem.displayName = 'AccordionItem'; + +const AccordionTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + svg]:rotate-180', + className + )} + {...props} + > + {children} + + + +)); +AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName; + +const AccordionContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + +
{children}
+
+)); + +AccordionContent.displayName = AccordionPrimitive.Content.displayName; + +export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }; diff --git a/frontend/src/components/ui/searchable-select.tsx b/frontend/src/components/ui/searchable-select.tsx index 0167e99f..f074abc8 100644 --- a/frontend/src/components/ui/searchable-select.tsx +++ b/frontend/src/components/ui/searchable-select.tsx @@ -98,6 +98,7 @@ export function SearchableSelect({ type="button" role="combobox" aria-expanded={isOpen} + aria-controls="searchable-select-popover" disabled={disabled} className={cn( 'flex h-9 w-full min-w-0 items-center justify-between rounded-lg border text-left', diff --git a/frontend/src/features/settings/components/BotEdit.tsx b/frontend/src/features/settings/components/BotEdit.tsx index 09040eef..c47fd00b 100644 --- a/frontend/src/features/settings/components/BotEdit.tsx +++ b/frontend/src/features/settings/components/BotEdit.tsx @@ -15,6 +15,7 @@ import { } from '@/components/ui/select'; import McpConfigImportModal from './McpConfigImportModal'; import SkillManagementModal from './skills/SkillManagementModal'; +import DifyBotConfig from './DifyBotConfig'; import { Bot } from '@/types/api'; import { botApis, CreateBotRequest, UpdateBotRequest } from '@/apis/bots'; @@ -83,6 +84,9 @@ const BotEdit: React.FC = ({ const [templateSectionExpanded, setTemplateSectionExpanded] = useState(false); const [skillManagementModalOpen, setSkillManagementModalOpen] = useState(false); + // Check if current agent is Dify + const isDifyAgent = useMemo(() => agentName === 'Dify', [agentName]); + const prettifyAgentConfig = useCallback(() => { setAgentConfig(prev => { const trimmed = prev.trim(); @@ -211,13 +215,13 @@ const BotEdit: React.FC = ({ const lang = i18n.language === 'zh-CN' ? 'zh' : 'en'; const docsUrl = `/docs/${lang}/guides/user/configuring-models.md`; window.open(docsUrl, '_blank'); - }, [t]); + }, [i18n.language]); const handleOpenShellDocs = useCallback(() => { const lang = i18n.language === 'zh-CN' ? 'zh' : 'en'; const docsUrl = `/docs/${lang}/guides/user/configuring-shells.md`; window.open(docsUrl, '_blank'); - }, [t]); + }, [i18n.language]); // Get agents list useEffect(() => { @@ -344,7 +348,6 @@ const BotEdit: React.FC = ({ return () => window.removeEventListener('keydown', handleEsc); }, [handleBack]); - // Save logic // Save logic const handleSave = async () => { if (!botName.trim() || !agentName.trim()) { @@ -354,8 +357,33 @@ const BotEdit: React.FC = ({ }); return; } + let parsedAgentConfig: unknown = undefined; - if (isCustomModel) { + + // For Dify agent, always use custom model configuration + if (isDifyAgent) { + const trimmedConfig = agentConfig.trim(); + if (!trimmedConfig) { + setAgentConfigError(true); + toast({ + variant: 'destructive', + title: t('bot.errors.agent_config_json'), + }); + return; + } + try { + parsedAgentConfig = JSON.parse(trimmedConfig); + setAgentConfigError(false); + } catch { + setAgentConfigError(true); + toast({ + variant: 'destructive', + title: t('bot.errors.agent_config_json'), + }); + return; + } + } else if (isCustomModel) { + // Non-Dify custom model configuration const trimmedConfig = agentConfig.trim(); if (!trimmedConfig) { setAgentConfigError(true); @@ -381,7 +409,9 @@ const BotEdit: React.FC = ({ } let parsedMcpConfig: Record | null = null; - if (mcpConfig.trim()) { + + // Skip MCP config for Dify agent + if (!isDifyAgent && mcpConfig.trim()) { try { parsedMcpConfig = JSON.parse(mcpConfig); // Adapt MCP config types based on selected agent @@ -404,13 +434,14 @@ const BotEdit: React.FC = ({ } else { setMcpConfigError(false); } + setBotSaving(true); try { const botReq: CreateBotRequest = { name: botName.trim(), agent_name: agentName.trim(), agent_config: parsedAgentConfig as Record, - system_prompt: prompt.trim() || '', + system_prompt: isDifyAgent ? '' : prompt.trim() || '', // Clear system_prompt for Dify mcp_servers: parsedMcpConfig ?? {}, skills: selectedSkills.length > 0 ? selectedSkills : [], }; @@ -462,7 +493,7 @@ const BotEdit: React.FC = ({ {/* Main content area - using responsive layout */}
-
+
{/* Bot Name */}
@@ -553,109 +584,122 @@ const BotEdit: React.FC = ({
- {/* Agent Config */} -
-
-
- - {/* Help Icon */} -
+ ) : ( + /* Normal Mode: Show standard configuration options */ + <> + {/* Agent Config */} +
+
+
+ + {/* Help Icon */} + + {/* Template Button - Only show when Custom Model is enabled */} + {isCustomModel && ( + + )} +
+
+ {t('bot.use_custom_model')} + { + setIsCustomModel(checked); + if (checked) { + setAgentConfig(''); + setAgentConfigError(false); + } + if (!checked) { + setAgentConfigError(false); + setTemplateSectionExpanded(false); + } + }} /> - - - {/* Template Button - Only show when Custom Model is enabled */} - {isCustomModel && ( - +
+
+ + {/* Template Expanded Content - Only show when expanded */} + {isCustomModel && templateSectionExpanded && ( +
+
+ + +
+

⚠️ {t('bot.template_hint')}

+
)} -
-
- {t('bot.use_custom_model')} - { - setIsCustomModel(checked); - if (checked) { - setAgentConfig(''); - setAgentConfigError(false); - } - if (!checked) { - setAgentConfigError(false); - setTemplateSectionExpanded(false); - } - }} - /> -
-
- {/* Template Expanded Content - Only show when expanded */} - {isCustomModel && templateSectionExpanded && ( -
-
- - -
-

⚠️ {t('bot.template_hint')}

-
- )} - - {isCustomModel ? ( -