diff --git a/api/controllers/common/agent_app_parameters.py b/api/controllers/common/agent_app_parameters.py new file mode 100644 index 00000000000..32e338957ea --- /dev/null +++ b/api/controllers/common/agent_app_parameters.py @@ -0,0 +1,52 @@ +from typing import Any + +from sqlalchemy import select + +from core.app.apps.agent_app.app_feature_projection import merge_agent_app_features +from core.app.apps.agent_app.app_variable_projection import agent_app_variables_to_user_input_form +from core.app.apps.agent_app.errors import AgentAppGeneratorError, AgentAppNotPublishedError +from extensions.ext_database import db +from models.agent import Agent, AgentConfigSnapshot, AgentStatus +from models.agent_config_entities import AgentSoulConfig +from models.model import App + + +def get_published_agent_app_feature_dict_and_user_input_form( + app_model: App, +) -> tuple[dict[str, Any], list[dict[str, Any]]]: + """Return public Agent App parameters backed by the published Agent Soul.""" + app_model_config = app_model.app_model_config + + agent_id = app_model.bound_agent_id + if not agent_id: + raise AgentAppGeneratorError("Agent App has no bound Agent") + + agent = db.session.scalar( + select(Agent) + .where( + Agent.tenant_id == app_model.tenant_id, + Agent.id == agent_id, + Agent.status == AgentStatus.ACTIVE, + ) + .limit(1) + ) + if agent is None: + raise AgentAppGeneratorError("Agent App has no bound Agent") + if not agent.active_config_snapshot_id or not agent.active_config_is_published: + raise AgentAppNotPublishedError("Agent has not been published") + + snapshot = db.session.scalar( + select(AgentConfigSnapshot) + .where( + AgentConfigSnapshot.tenant_id == app_model.tenant_id, + AgentConfigSnapshot.agent_id == agent.id, + AgentConfigSnapshot.id == agent.active_config_snapshot_id, + ) + .limit(1) + ) + if snapshot is None: + raise AgentAppGeneratorError("Agent published version not found") + + agent_soul = AgentSoulConfig.model_validate(snapshot.config_snapshot_dict) + features_dict = merge_agent_app_features(agent_soul=agent_soul, app_model_config=app_model_config) + return features_dict, agent_app_variables_to_user_input_form(agent_soul.app_variables) diff --git a/api/controllers/console/app/agent_app_sandbox.py b/api/controllers/console/app/agent_app_sandbox.py index 50cf4ca6b04..4324f425a08 100644 --- a/api/controllers/console/app/agent_app_sandbox.py +++ b/api/controllers/console/app/agent_app_sandbox.py @@ -108,14 +108,8 @@ class SandboxReadResponse(ResponseModel): text: str | None = None -class SandboxToolFileResponse(ResponseModel): - transfer_method: Literal["tool_file"] = "tool_file" - reference: str - - class SandboxUploadResponse(ResponseModel): - path: str - file: SandboxToolFileResponse + url: str register_schema_models( @@ -225,7 +219,7 @@ class AgentAppSandboxReadResource(Resource): @console_ns.route("/agent//sandbox/files/upload") class AgentAppSandboxUploadResource(Resource): @console_ns.doc("upload_agent_app_sandbox_file") - @console_ns.doc(description="Upload one Agent App sandbox file as a Dify ToolFile mapping") + @console_ns.doc(description="Upload one Agent App sandbox file and return a signed download URL") @console_ns.expect(console_ns.models[AgentSandboxUploadPayload.__name__]) @console_ns.response(200, "Uploaded", console_ns.models[SandboxUploadResponse.__name__]) @setup_required @@ -322,7 +316,7 @@ class WorkflowAgentSandboxReadResource(Resource): ) class WorkflowAgentSandboxUploadResource(Resource): @console_ns.doc("upload_workflow_agent_sandbox_file") - @console_ns.doc(description="Upload one workflow Agent sandbox file as a Dify ToolFile mapping") + @console_ns.doc(description="Upload one workflow Agent sandbox file and return a signed download URL") @console_ns.expect(console_ns.models[WorkflowAgentSandboxUploadPayload.__name__]) @console_ns.response(200, "Uploaded", console_ns.models[SandboxUploadResponse.__name__]) @setup_required diff --git a/api/controllers/console/app/completion.py b/api/controllers/console/app/completion.py index 1b54f9f9c65..4a8caff9c40 100644 --- a/api/controllers/console/app/completion.py +++ b/api/controllers/console/app/completion.py @@ -124,6 +124,7 @@ Use only the current Build chat message history to identify changes that need to validate old config unless the message history already shows that the old config is invalid. Only update the build-draft config note when the current Build chat contains durable context that later runs need. +Write the config note in the language used by the message history. Do not create, update, delete, inspect, or fill gaps in other Agent config resources, including config files, config skills, config env, tools, models, knowledge, or prompt settings. diff --git a/api/controllers/openapi/_errors.py b/api/controllers/openapi/_errors.py index 5e82c2614de..31d577665a3 100644 --- a/api/controllers/openapi/_errors.py +++ b/api/controllers/openapi/_errors.py @@ -49,6 +49,7 @@ class OpenApiErrorCode(StrEnum): # domain codes (must match the error_code attribute of the exception # classes raised on the openapi surface) APP_UNAVAILABLE = "app_unavailable" + AGENT_NOT_PUBLISHED = "agent_not_published" CONVERSATION_COMPLETED = "conversation_completed" PROVIDER_NOT_INITIALIZE = "provider_not_initialize" PROVIDER_QUOTA_EXCEEDED = "provider_quota_exceeded" diff --git a/api/controllers/service_api/app/app.py b/api/controllers/service_api/app/app.py index 932ec71c769..3ac44b12c66 100644 --- a/api/controllers/service_api/app/app.py +++ b/api/controllers/service_api/app/app.py @@ -2,19 +2,16 @@ from typing import Any, cast from flask_restx import Resource from pydantic import Field -from sqlalchemy import select +from controllers.common.agent_app_parameters import get_published_agent_app_feature_dict_and_user_input_form from controllers.common.fields import Parameters from controllers.common.schema import register_response_schema_models from controllers.service_api import service_api_ns -from controllers.service_api.app.error import AppUnavailableError +from controllers.service_api.app.error import AgentNotPublishedError, AppUnavailableError from controllers.service_api.wraps import validate_app_token from core.app.app_config.common.parameters_mapping import get_parameters_from_feature_dict -from core.app.apps.agent_app.app_variable_projection import agent_app_variables_to_user_input_form -from extensions.ext_database import db +from core.app.apps.agent_app.errors import AgentAppGeneratorError, AgentAppNotPublishedError from fields.base import ResponseModel -from models.agent import Agent, AgentConfigSnapshot, AgentScope, AgentSource, AgentStatus -from models.agent_config_entities import AgentSoulConfig from models.model import App, AppMode from services.app_service import AppService @@ -35,38 +32,13 @@ register_response_schema_models(service_api_ns, Parameters, AppMetaResponse, App def _get_agent_app_feature_dict_and_user_input_form(app_model: App) -> tuple[dict[str, Any], list[dict[str, Any]]]: - app_model_config = app_model.app_model_config - features_dict = cast(dict[str, Any], app_model_config.to_dict()) if app_model_config is not None else {} - - agent = db.session.scalar( - select(Agent) - .where( - Agent.tenant_id == app_model.tenant_id, - Agent.app_id == app_model.id, - Agent.scope == AgentScope.ROSTER, - Agent.source == AgentSource.AGENT_APP, - Agent.status == AgentStatus.ACTIVE, - ) - .limit(1) - ) - if agent is None or not agent.active_config_snapshot_id: + try: + return get_published_agent_app_feature_dict_and_user_input_form(app_model) + except AgentAppNotPublishedError: + raise AgentNotPublishedError() + except AgentAppGeneratorError: raise AppUnavailableError() - snapshot = db.session.scalar( - select(AgentConfigSnapshot) - .where( - AgentConfigSnapshot.tenant_id == app_model.tenant_id, - AgentConfigSnapshot.agent_id == agent.id, - AgentConfigSnapshot.id == agent.active_config_snapshot_id, - ) - .limit(1) - ) - if snapshot is None: - raise AppUnavailableError() - - agent_soul = AgentSoulConfig.model_validate(snapshot.config_snapshot_dict) - return features_dict, agent_app_variables_to_user_input_form(agent_soul.app_variables) - @service_api_ns.route("/parameters") class AppParameterApi(Resource): diff --git a/api/controllers/service_api/app/completion.py b/api/controllers/service_api/app/completion.py index 900d46a0f0f..c240c7d85af 100644 --- a/api/controllers/service_api/app/completion.py +++ b/api/controllers/service_api/app/completion.py @@ -15,6 +15,7 @@ from controllers.common.schema import register_response_schema_models, register_ from controllers.console.app.wraps import with_session from controllers.service_api import service_api_ns from controllers.service_api.app.error import ( + AgentNotPublishedError, AppUnavailableError, CompletionRequestError, ConversationCompletedError, @@ -31,6 +32,7 @@ from controllers.service_api.schema import ( ) from controllers.service_api.wraps import FetchUserArg, WhereisUserArg, validate_app_token from controllers.web.error import InvokeRateLimitError as InvokeRateLimitHttpError +from core.app.apps.agent_app.errors import AgentAppNotPublishedError from core.app.entities.app_invoke_entities import InvokeFrom from core.errors.error import ( ModelCurrentlyNotSupportError, @@ -248,6 +250,8 @@ class CompletionApi(Resource): except services.errors.app_model_config.AppModelConfigBrokenError: logger.exception("App model config broken.") raise AppUnavailableError() + except AgentAppNotPublishedError: + raise AgentNotPublishedError() except ProviderTokenNotInitError as ex: raise ProviderNotInitializeError(ex.description) except QuotaExceededError: @@ -403,6 +407,8 @@ class ChatApi(Resource): except services.errors.app_model_config.AppModelConfigBrokenError: logger.exception("App model config broken.") raise AppUnavailableError() + except AgentAppNotPublishedError: + raise AgentNotPublishedError() except ProviderTokenNotInitError as ex: raise ProviderNotInitializeError(ex.description) except QuotaExceededError: diff --git a/api/controllers/service_api/app/error.py b/api/controllers/service_api/app/error.py index 0e04a04cb24..710fc7878fb 100644 --- a/api/controllers/service_api/app/error.py +++ b/api/controllers/service_api/app/error.py @@ -7,6 +7,12 @@ class AppUnavailableError(BaseHTTPException): code = 400 +class AgentNotPublishedError(BaseHTTPException): + error_code = "agent_not_published" + description = "Agent has not been published. Please publish the Agent before using the API." + code = 400 + + class NotCompletionAppError(BaseHTTPException): error_code = "not_completion_app" description = "Please check if your Completion app mode matches the right API route." diff --git a/api/controllers/web/app.py b/api/controllers/web/app.py index d5722faf00d..17ff05f7137 100644 --- a/api/controllers/web/app.py +++ b/api/controllers/web/app.py @@ -8,8 +8,10 @@ from werkzeug.exceptions import Unauthorized from constants import HEADER_NAME_APP_CODE from controllers.common import fields +from controllers.common.agent_app_parameters import get_published_agent_app_feature_dict_and_user_input_form from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models from core.app.app_config.common.parameters_mapping import get_parameters_from_feature_dict +from core.app.apps.agent_app.errors import AgentAppGeneratorError, AgentAppNotPublishedError from libs.passport import PassportService from libs.token import extract_webapp_passport from models.model import App, AppMode, EndUser @@ -19,7 +21,7 @@ from services.feature_service import FeatureService from services.webapp_auth_service import WebAppAuthService from . import web_ns -from .error import AppUnavailableError +from .error import AgentNotPublishedError, AppUnavailableError from .wraps import WebApiResource logger = logging.getLogger(__name__) @@ -74,12 +76,21 @@ class AppParameterApi(WebApiResource): @web_ns.response(200, "Success", web_ns.models[fields.Parameters.__name__]) def get(self, app_model: App, end_user: EndUser): """Retrieve app parameters.""" - if app_model.mode in {AppMode.ADVANCED_CHAT, AppMode.WORKFLOW}: + features_dict: dict[str, Any] + user_input_form: list[dict[str, Any]] + if app_model.mode == AppMode.AGENT: + try: + features_dict, user_input_form = get_published_agent_app_feature_dict_and_user_input_form(app_model) + except AgentAppNotPublishedError: + raise AgentNotPublishedError() + except AgentAppGeneratorError: + raise AppUnavailableError() + elif app_model.mode in {AppMode.ADVANCED_CHAT, AppMode.WORKFLOW}: workflow = app_model.workflow if workflow is None: raise AppUnavailableError() - features_dict: dict[str, Any] = workflow.features_dict + features_dict = workflow.features_dict user_input_form = workflow.user_input_form(to_old_structure=True) else: app_model_config = app_model.app_model_config diff --git a/api/controllers/web/completion.py b/api/controllers/web/completion.py index 2c852e208a5..343afd68f9a 100644 --- a/api/controllers/web/completion.py +++ b/api/controllers/web/completion.py @@ -11,6 +11,7 @@ from controllers.common.schema import register_response_schema_models, register_ from controllers.console.app.wraps import with_session from controllers.web import web_ns from controllers.web.error import ( + AgentNotPublishedError, AppUnavailableError, CompletionRequestError, ConversationCompletedError, @@ -22,6 +23,7 @@ from controllers.web.error import ( ) from controllers.web.error import InvokeRateLimitError as InvokeRateLimitHttpError from controllers.web.wraps import WebApiResource +from core.app.apps.agent_app.errors import AgentAppNotPublishedError from core.app.entities.app_invoke_entities import InvokeFrom from core.errors.error import ( ModelCurrentlyNotSupportError, @@ -138,6 +140,8 @@ class CompletionApi(WebApiResource): except services.errors.app_model_config.AppModelConfigBrokenError: logger.exception("App model config broken.") raise AppUnavailableError() + except AgentAppNotPublishedError: + raise AgentNotPublishedError() except ProviderTokenNotInitError as ex: raise ProviderNotInitializeError(ex.description) except QuotaExceededError: @@ -235,6 +239,8 @@ class ChatApi(WebApiResource): except services.errors.app_model_config.AppModelConfigBrokenError: logger.exception("App model config broken.") raise AppUnavailableError() + except AgentAppNotPublishedError: + raise AgentNotPublishedError() except ProviderTokenNotInitError as ex: raise ProviderNotInitializeError(ex.description) except QuotaExceededError: diff --git a/api/controllers/web/error.py b/api/controllers/web/error.py index 789c0fabcc1..077b4726e47 100644 --- a/api/controllers/web/error.py +++ b/api/controllers/web/error.py @@ -7,6 +7,12 @@ class AppUnavailableError(BaseHTTPException): code = 400 +class AgentNotPublishedError(BaseHTTPException): + error_code = "agent_not_published" + description = "Agent has not been published. Please publish the Agent before using the web app." + code = 400 + + class NotCompletionAppError(BaseHTTPException): error_code = "not_completion_app" description = "Please check if your Completion app mode matches the right API route." diff --git a/api/core/app/apps/agent_app/app_config_manager.py b/api/core/app/apps/agent_app/app_config_manager.py index 0dc04735cc0..71224534133 100644 --- a/api/core/app/apps/agent_app/app_config_manager.py +++ b/api/core/app/apps/agent_app/app_config_manager.py @@ -3,9 +3,9 @@ An Agent App has no legacy ``app_model_config``: its model / prompt live in the bound Agent Soul snapshot. To ride the existing chat message + SSE pipeline we synthesize an ``app_model_config``-shaped dict from the Soul (model + system -prompt) plus any app-level feature flags (opening statement, follow-up, …) -stored on ``app_model_config`` when present, then reuse the same sub-managers -the chat app type uses. +prompt) plus app-level feature flags from Agent Soul, while preserving any +legacy ``app_model_config`` feature flags when present. Then we reuse the same +sub-managers the chat app type uses. """ from typing import Any, cast @@ -21,6 +21,7 @@ from core.app.app_config.entities import ( EasyUIBasedAppModelConfigFrom, PromptTemplateEntity, ) +from core.app.apps.agent_app.app_feature_projection import merge_agent_app_features from core.app.apps.agent_app.app_variable_projection import agent_app_variables_to_user_input_form from models.agent_config_entities import AgentSoulConfig from models.model import App, AppMode, AppModelConfig, AppModelConfigDict, Conversation @@ -79,12 +80,11 @@ class AgentAppConfigManager(BaseAppConfigManager): ) -> dict[str, Any]: """Shape a Soul + feature flags into an ``app_model_config``-style dict. - Feature flags (opening statement / follow-up / tts / stt / citations / - moderation / annotation) come from ``app_model_config`` when present - (Q3: stored there), otherwise defaults; model + prompt always come from + Feature flags come from Agent Soul and fill gaps in the legacy + ``app_model_config`` when one exists; model + prompt always come from the Agent Soul (the single source of truth for those). """ - base: dict[str, Any] = dict(app_model_config.to_dict()) if app_model_config else {} + base = merge_agent_app_features(agent_soul=agent_soul, app_model_config=app_model_config) model = agent_soul.model if model is not None: diff --git a/api/core/app/apps/agent_app/app_feature_projection.py b/api/core/app/apps/agent_app/app_feature_projection.py new file mode 100644 index 00000000000..cb8efd9f290 --- /dev/null +++ b/api/core/app/apps/agent_app/app_feature_projection.py @@ -0,0 +1,23 @@ +from typing import Any + +from models.agent_config_entities import AgentSoulConfig + + +def merge_agent_app_features( + *, + agent_soul: AgentSoulConfig, + app_model_config: Any | None, +) -> dict[str, Any]: + """Project public Agent App features from legacy config plus Agent Soul. + + The hidden backing app may still carry legacy presentation fields such as + opening statements. Agent Soul is the source of truth for Agent-owned + features like file upload, so Soul fields override same-named legacy keys. + """ + features: dict[str, Any] = dict(app_model_config.to_dict()) if app_model_config else {} + soul_features = agent_soul.app_features.model_dump(mode="json", exclude_none=True) + features.update(soul_features) + return features + + +__all__ = ["merge_agent_app_features"] diff --git a/api/core/app/apps/agent_app/app_generator.py b/api/core/app/apps/agent_app/app_generator.py index 9d45ac71389..a940ccf6ceb 100644 --- a/api/core/app/apps/agent_app/app_generator.py +++ b/api/core/app/apps/agent_app/app_generator.py @@ -32,6 +32,7 @@ from constants import UUID_NIL from core.app.app_config.easy_ui_based_app.model_config.converter import ModelConfigConverter from core.app.apps.agent_app.app_config_manager import AgentAppConfigManager from core.app.apps.agent_app.app_runner import AgentAppRunner +from core.app.apps.agent_app.errors import AgentAppGeneratorError, AgentAppNotPublishedError from core.app.apps.agent_app.generate_response_converter import AgentAppGenerateResponseConverter from core.app.apps.agent_app.runtime_request_builder import AgentAppRuntimeRequestBuilder from core.app.apps.agent_app.session_store import AgentAppRuntimeSessionStore @@ -64,10 +65,6 @@ from services.conversation_service import ConversationService logger = logging.getLogger(__name__) -class AgentAppGeneratorError(ValueError): - """Raised when an Agent App turn cannot be set up.""" - - def _append_prompt_file_mappings(query: str, prompt_file_mappings: Sequence[JsonValue]) -> str: """Append raw request file references to the backend user prompt.""" if not prompt_file_mappings: @@ -614,6 +611,8 @@ class AgentAppGenerator(MessageBasedAppGenerator): "build_draft" if draft.draft_type == AgentConfigDraftType.DEBUG_BUILD else "draft" ) return agent, draft.id, config_version_kind, agent_soul + if not agent.active_config_snapshot_id or not agent.active_config_is_published: + raise AgentAppNotPublishedError("Agent has not been published") _, snapshot, agent_soul = self._resolve_agent_by_id( tenant_id=app_model.tenant_id, agent_id=agent.id, @@ -709,4 +708,4 @@ class AgentAppGenerator(MessageBasedAppGenerator): return agent, draft, agent_soul -__all__ = ["AgentAppGenerator", "AgentAppGeneratorError"] +__all__ = ["AgentAppGenerator", "AgentAppGeneratorError", "AgentAppNotPublishedError"] diff --git a/api/core/app/apps/agent_app/app_runner.py b/api/core/app/apps/agent_app/app_runner.py index 57dac07f761..8451ccebd4d 100644 --- a/api/core/app/apps/agent_app/app_runner.py +++ b/api/core/app/apps/agent_app/app_runner.py @@ -372,14 +372,14 @@ class _AgentProcessRecorder: row = MessageAgentThought( message_id=self._message_id, message_chain_id=None, - thought=thought, - tool=tool, + thought=thought or "", + tool=tool or "", tool_labels_str=_tool_labels(tool), tool_meta_str="{}", - tool_input=tool_input, - observation=None, + tool_input=tool_input or "", + observation="", tool_process_data=None, - message=None, + message="", message_token=0, message_unit_price=Decimal(0), message_price_unit=Decimal("0.001"), diff --git a/api/core/app/apps/agent_app/errors.py b/api/core/app/apps/agent_app/errors.py new file mode 100644 index 00000000000..51b4e77116a --- /dev/null +++ b/api/core/app/apps/agent_app/errors.py @@ -0,0 +1,6 @@ +class AgentAppGeneratorError(ValueError): + """Raised when an Agent App turn cannot be set up.""" + + +class AgentAppNotPublishedError(AgentAppGeneratorError): + """Raised when a public Agent App runtime is requested before publish.""" diff --git a/api/core/app/entities/task_entities.py b/api/core/app/entities/task_entities.py index eb96063a6e6..ba87ff7cb46 100644 --- a/api/core/app/entities/task_entities.py +++ b/api/core/app/entities/task_entities.py @@ -122,7 +122,7 @@ class MessageStreamResponse(StreamResponse): event: StreamEvent = StreamEvent.MESSAGE id: str answer: str - from_variable_selector: list[str] | None = None + from_variable_selector: list[str] = Field(default_factory=list) class MessageAudioStreamResponse(StreamResponse): @@ -151,7 +151,7 @@ class MessageEndStreamResponse(StreamResponse): event: StreamEvent = StreamEvent.MESSAGE_END id: str metadata: Mapping[str, object] = Field(default_factory=dict) - files: Sequence[Mapping[str, Any]] | None = None + files: Sequence[Mapping[str, Any]] = Field(default_factory=list) class MessageFileStreamResponse(StreamResponse): diff --git a/api/core/app/task_pipeline/easy_ui_based_generate_task_pipeline.py b/api/core/app/task_pipeline/easy_ui_based_generate_task_pipeline.py index a728069eede..b7b3c8b0005 100644 --- a/api/core/app/task_pipeline/easy_ui_based_generate_task_pipeline.py +++ b/api/core/app/task_pipeline/easy_ui_based_generate_task_pipeline.py @@ -1,6 +1,6 @@ import logging import time -from collections.abc import Generator +from collections.abc import Generator, Mapping, Sequence from threading import Thread from typing import Any, cast @@ -44,7 +44,7 @@ from core.app.entities.task_entities import ( ) from core.app.task_pipeline.based_generate_task_pipeline import BasedGenerateTaskPipeline from core.app.task_pipeline.message_cycle_manager import MessageCycleManager -from core.app.task_pipeline.message_file_utils import prepare_file_dict +from core.app.task_pipeline.message_file_utils import MessageFileInfoDict, prepare_file_dict from core.base.tts import AppGeneratorTTSPublisher, AudioTrunk from core.model_manager import ModelInstance from core.ops.entities.trace_entity import TraceTaskName @@ -466,10 +466,10 @@ class EasyUIBasedGenerateTaskPipeline(BasedGenerateTaskPipeline[EasyUIAppGenerat :return: """ self._task_state.metadata.usage = self._task_state.llm_result.usage - metadata_dict = self._task_state.metadata.model_dump() + metadata_dict = self._task_state.metadata.model_dump(exclude_none=True) # Fetch files associated with this message - files = None + files: list[MessageFileInfoDict] = [] with Session(db.engine, expire_on_commit=False) as session: message_files = session.scalars(select(MessageFile).where(MessageFile.message_id == self._message_id)).all() @@ -492,13 +492,13 @@ class EasyUIBasedGenerateTaskPipeline(BasedGenerateTaskPipeline[EasyUIAppGenerat file_dict = prepare_file_dict(message_file, upload_files_map) files_list.append(file_dict) - files = files_list or None + files = files_list return MessageEndStreamResponse( task_id=self._application_generate_entity.task_id, id=self._message_id, metadata=metadata_dict, - files=files, + files=cast(Sequence[Mapping[str, Any]], files), ) def _agent_message_to_stream_response(self, answer: str, message_id: str) -> AgentMessageStreamResponse: @@ -528,11 +528,11 @@ class EasyUIBasedGenerateTaskPipeline(BasedGenerateTaskPipeline[EasyUIAppGenerat task_id=self._application_generate_entity.task_id, id=agent_thought.id, position=agent_thought.position, - thought=agent_thought.thought, - observation=agent_thought.observation, - tool=agent_thought.tool, + thought=agent_thought.thought or "", + observation=agent_thought.observation or "", + tool=agent_thought.tool or "", tool_labels=agent_thought.tool_labels, - tool_input=agent_thought.tool_input, + tool_input=agent_thought.tool_input or "", message_files=agent_thought.files, ) diff --git a/api/core/app/task_pipeline/message_cycle_manager.py b/api/core/app/task_pipeline/message_cycle_manager.py index 62f27060b4e..6b6437adac3 100644 --- a/api/core/app/task_pipeline/message_cycle_manager.py +++ b/api/core/app/task_pipeline/message_cycle_manager.py @@ -257,7 +257,7 @@ class MessageCycleManager: task_id=self._application_generate_entity.task_id, id=message_id, answer=answer, - from_variable_selector=from_variable_selector, + from_variable_selector=from_variable_selector or [], event=event_type or StreamEvent.MESSAGE, ) diff --git a/api/core/workflow/nodes/agent_v2/runtime_request_builder.py b/api/core/workflow/nodes/agent_v2/runtime_request_builder.py index 1a4529b5d97..5301da9805d 100644 --- a/api/core/workflow/nodes/agent_v2/runtime_request_builder.py +++ b/api/core/workflow/nodes/agent_v2/runtime_request_builder.py @@ -48,7 +48,7 @@ from clients.agent_backend import ( ) from configs import dify_config from core.app.entities.app_invoke_entities import DifyRunContext, InvokeFrom -from core.workflow.system_variables import SystemVariableKey, get_system_text +from core.workflow.system_variables import SystemVariableKey, get_system_text, get_system_value from graphon.file import File, FileTransferMethod from graphon.variables.segments import Segment from models.agent import Agent, AgentConfigSnapshot, WorkflowAgentNodeBinding @@ -354,17 +354,22 @@ class WorkflowAgentRuntimeRequestBuilder: ) -> str: lines: list[str] = [] query = get_system_text(context.variable_pool, SystemVariableKey.QUERY) + uploaded_files = self._summarize_uploaded_workflow_files(context.variable_pool) resolved_outputs = self._resolve_previous_node_outputs( context.variable_pool, node_job.previous_node_output_refs, ) - if not query and not resolved_outputs: + if not query and uploaded_files is None and not resolved_outputs: return "" lines.append("Workflow context loaded for this run:") if query: lines.append(f"- User query: {query}") + if uploaded_files is not None: + lines.append("- Uploaded workflow files:") + lines.append(f" - sys.files: {uploaded_files}") + if resolved_outputs: lines.append("- Previous node outputs:") for item in resolved_outputs: @@ -373,6 +378,14 @@ class WorkflowAgentRuntimeRequestBuilder: lines.append("The above workflow context is run-specific. Do not treat it as Agent Soul or persistent memory.") return "\n".join(lines) + def _summarize_uploaded_workflow_files(self, variable_pool: VariablePoolReader) -> str | None: + files = get_system_value(variable_pool, SystemVariableKey.FILES) + if files is None: + return None + if isinstance(files, list | tuple) and not files: + return None + return self._summarize_value(files) + def _build_workflow_task_prompt( self, context: WorkflowAgentRuntimeBuildContext, diff --git a/api/models/agent_config_entities.py b/api/models/agent_config_entities.py index 64fe85930c6..a78f7d90dd5 100644 --- a/api/models/agent_config_entities.py +++ b/api/models/agent_config_entities.py @@ -8,7 +8,7 @@ from pydantic import BaseModel, ConfigDict, Field, WithJsonSchema, field_validat from core.rag.entities.metadata_entities import ConditionValue, SupportedComparisonOperator from core.workflow.file_reference import is_canonical_file_reference -from graphon.file import FileTransferMethod +from graphon.file import FileTransferMethod, FileType class AgentKnowledgeQueryMode(StrEnum): @@ -314,8 +314,9 @@ class AgentKnowledgeQueryConfig(BaseModel): Agent v2 stores knowledge as explicit ``knowledge.sets`` rather than the legacy flat ``datasets`` / ``query_mode`` / ``query_config`` shape. Each - set owns its own query policy, so ``user_query`` must carry an explicit - ``value`` while ``generated_query`` leaves that value empty. + set owns its own query policy. Mode-dependent completeness, such as + requiring ``value`` for ``user_query``, is enforced by composer publish + validation so draft saves can persist partially configured knowledge sets. """ model_config = ConfigDict(extra="forbid") @@ -323,12 +324,6 @@ class AgentKnowledgeQueryConfig(BaseModel): mode: AgentKnowledgeQueryMode value: str | None = None - @model_validator(mode="after") - def validate_query(self) -> Self: - if self.mode == AgentKnowledgeQueryMode.USER_QUERY and not (self.value or "").strip(): - raise ValueError("knowledge query.value is required for user_query mode") - return self - class AgentKnowledgeModelConfig(BaseModel): model_config = ConfigDict(extra="forbid") @@ -356,8 +351,9 @@ class AgentKnowledgeRetrievalConfig(BaseModel): """Per-set retrieval policy for Agent v2 knowledge retrieval. Retrieval settings now live on each knowledge set instead of one shared - flat config. A set may use either ``multiple`` retrieval with ``top_k`` or - ``single`` retrieval with a required model config. + flat config. Mode-dependent completeness, such as requiring ``top_k`` for + ``multiple`` or a model for ``single``, is enforced by composer publish + validation so draft saves can persist partially configured knowledge sets. """ model_config = ConfigDict(extra="forbid") @@ -371,14 +367,6 @@ class AgentKnowledgeRetrievalConfig(BaseModel): weights: AgentKnowledgeWeightedScoreConfig | None = None model: AgentKnowledgeModelConfig | None = None - @model_validator(mode="after") - def validate_mode_fields(self) -> Self: - if self.mode == "multiple" and self.top_k is None: - raise ValueError("knowledge retrieval.top_k is required for multiple mode") - if self.mode == "single" and self.model is None: - raise ValueError("knowledge retrieval.model is required for single mode") - return self - class AgentKnowledgeMetadataCondition(BaseModel): model_config = ConfigDict(extra="forbid") @@ -401,6 +389,8 @@ class AgentKnowledgeMetadataFilteringConfig(BaseModel): The Python attribute uses ``metadata_model_config`` for clarity because the model belongs to metadata filtering specifically, while the external API and generated schema keep the historical ``model_config`` field name via alias. + Mode-dependent completeness is enforced by composer publish validation so + draft saves can persist partially configured metadata filters. """ model_config = ConfigDict(extra="forbid", populate_by_name=True) @@ -410,14 +400,6 @@ class AgentKnowledgeMetadataFilteringConfig(BaseModel): metadata_model_config: AgentKnowledgeModelConfig | None = Field(default=None, alias="model_config") conditions: AgentKnowledgeMetadataConditions | None = None - @model_validator(mode="after") - def validate_mode_fields(self) -> Self: - if self.mode == "automatic" and self.metadata_model_config is None: - raise ValueError("metadata_filtering.model_config is required for automatic mode") - if self.mode == "manual" and (self.conditions is None or not self.conditions.conditions): - raise ValueError("metadata_filtering.conditions is required for manual mode") - return self - class AgentKnowledgeSetConfig(BaseModel): """One explicit knowledge set in Agent v2. @@ -547,6 +529,23 @@ class AgentSensitiveWordAvoidanceFeatureConfig(AgentFeatureToggleConfig): config: AgentModerationProviderConfig | None = None +class AgentFileUploadImageFeatureConfig(AgentFeatureToggleConfig): + enabled: bool = True + + +class AgentFileUploadFeatureConfig(AgentFeatureToggleConfig): + enabled: bool = True + allowed_file_extensions: list[str] = Field(default_factory=lambda: ["JPG", "JPEG", "PNG", "GIF", "WEBP", "SVG"]) + allowed_file_types: list[FileType] = Field( + default_factory=lambda: [FileType.DOCUMENT, FileType.IMAGE, FileType.AUDIO, FileType.VIDEO] + ) + allowed_file_upload_methods: list[FileTransferMethod] = Field( + default_factory=lambda: [FileTransferMethod.LOCAL_FILE, FileTransferMethod.REMOTE_URL] + ) + image: AgentFileUploadImageFeatureConfig = Field(default_factory=AgentFileUploadImageFeatureConfig) + number_limits: int = 3 + + class AgentSoulAppFeaturesConfig(AgentFlexibleConfig): opening_statement: str | None = None suggested_questions: list[str] | None = None @@ -555,6 +554,7 @@ class AgentSoulAppFeaturesConfig(AgentFlexibleConfig): text_to_speech: AgentTextToSpeechFeatureConfig | None = None retriever_resource: AgentFeatureToggleConfig | None = None sensitive_word_avoidance: AgentSensitiveWordAvoidanceFeatureConfig | None = None + file_upload: AgentFileUploadFeatureConfig = Field(default_factory=AgentFileUploadFeatureConfig) class WorkflowPreviousNodeOutputRef(AgentFlexibleConfig): diff --git a/api/openapi/markdown/console-openapi.md b/api/openapi/markdown/console-openapi.md index 83cd134a758..372baf8114c 100644 --- a/api/openapi/markdown/console-openapi.md +++ b/api/openapi/markdown/console-openapi.md @@ -1268,7 +1268,7 @@ Read a text/binary preview file in an Agent App conversation sandbox | 200 | Preview returned | **application/json**: [SandboxReadResponse](#sandboxreadresponse)
| ### [POST] /agent/{agent_id}/sandbox/files/upload -Upload one Agent App sandbox file as a Dify ToolFile mapping +Upload one Agent App sandbox file and return a signed download URL #### Parameters @@ -3777,7 +3777,7 @@ Read a text/binary preview file in a workflow Agent node sandbox | 200 | Preview returned | **application/json**: [SandboxReadResponse](#sandboxreadresponse)
| ### [POST] /apps/{app_id}/workflow-runs/{workflow_run_id}/agent-nodes/{node_id}/sandbox/files/upload -Upload one workflow Agent sandbox file as a Dify ToolFile mapping +Upload one workflow Agent sandbox file and return a signed download URL #### Parameters @@ -13754,6 +13754,23 @@ Stable Agent Soul reference to one normalized skill archive. | upload_file_id | string | | No | | url | string | | No | +#### AgentFileUploadFeatureConfig + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| allowed_file_extensions | [ string ] | | No | +| allowed_file_types | [ [FileType](#filetype) ] | | No | +| allowed_file_upload_methods | [ [FileTransferMethod](#filetransfermethod) ] | | No | +| enabled | boolean,
**Default:** true | | No | +| image | [AgentFileUploadImageFeatureConfig](#agentfileuploadimagefeatureconfig) | | No | +| number_limits | integer,
**Default:** 3 | | No | + +#### AgentFileUploadImageFeatureConfig + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| enabled | boolean,
**Default:** true | | No | + #### AgentHumanContactConfig | Name | Type | Description | Required | @@ -13897,6 +13914,8 @@ Per-set metadata filtering policy. The Python attribute uses ``metadata_model_config`` for clarity because the model belongs to metadata filtering specifically, while the external API and generated schema keep the historical ``model_config`` field name via alias. +Mode-dependent completeness is enforced by composer publish validation so +draft saves can persist partially configured metadata filters. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | @@ -13919,8 +13938,9 @@ Per-set query policy for Agent v2 knowledge retrieval. Agent v2 stores knowledge as explicit ``knowledge.sets`` rather than the legacy flat ``datasets`` / ``query_mode`` / ``query_config`` shape. Each -set owns its own query policy, so ``user_query`` must carry an explicit -``value`` while ``generated_query`` leaves that value empty. +set owns its own query policy. Mode-dependent completeness, such as +requiring ``value`` for ``user_query``, is enforced by composer publish +validation so draft saves can persist partially configured knowledge sets. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | @@ -13945,8 +13965,9 @@ set owns its own query policy, so ``user_query`` must carry an explicit Per-set retrieval policy for Agent v2 knowledge retrieval. Retrieval settings now live on each knowledge set instead of one shared -flat config. A set may use either ``multiple`` retrieval with ``top_k`` or -``single`` retrieval with a required model config. +flat config. Mode-dependent completeness, such as requiring ``top_k`` for +``multiple`` or a model for ``single``, is enforced by composer publish +validation so draft saves can persist partially configured knowledge sets. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | @@ -14345,6 +14366,7 @@ Visibility and lifecycle scope of an Agent record. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | +| file_upload | [AgentFileUploadFeatureConfig](#agentfileuploadfeatureconfig) | | No | | opening_statement | string | | No | | retriever_resource | [AgentFeatureToggleConfig](#agentfeaturetoggleconfig) | | No | | sensitive_word_avoidance | [AgentSensitiveWordAvoidanceFeatureConfig](#agentsensitivewordavoidancefeatureconfig) | | No | @@ -20523,19 +20545,11 @@ Whitelist scopes accepted by RBAC app and dataset access config APIs. | text | string | | No | | truncated | boolean | | Yes | -#### SandboxToolFileResponse - -| Name | Type | Description | Required | -| ---- | ---- | ----------- | -------- | -| reference | string | | Yes | -| transfer_method | string,
**Default:** tool_file | | No | - #### SandboxUploadResponse | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| file | [SandboxToolFileResponse](#sandboxtoolfileresponse) | | Yes | -| path | string | | Yes | +| url | string | | Yes | #### SavedMessageCreatePayload diff --git a/api/services/agent/composer_service.py b/api/services/agent/composer_service.py index 99817eb6441..28b86916b1d 100644 --- a/api/services/agent/composer_service.py +++ b/api/services/agent/composer_service.py @@ -33,6 +33,7 @@ from models.workflow import Workflow from services.agent.agent_soul_state import agent_soul_has_model from services.agent.composer_validator import ComposerConfigValidator from services.agent.errors import ( + AgentModelNotConfiguredError, AgentNameConflictError, AgentNotFoundError, AgentVersionConflictError, @@ -168,7 +169,8 @@ class AgentComposerService: _backfill_cli_tool_ids(payload.agent_soul) _validate_composer_payload_for_strategy(payload) - cls.validate_knowledge_datasets(tenant_id=tenant_id, agent_soul=payload.agent_soul) + if payload.save_strategy in _PUBLISH_SAVE_STRATEGIES: + cls.validate_knowledge_datasets(tenant_id=tenant_id, agent_soul=payload.agent_soul) workflow = cls._get_draft_workflow(tenant_id=tenant_id, app_id=app_id) binding = cls._get_workflow_binding(tenant_id=tenant_id, workflow_id=workflow.id, node_id=node_id) @@ -357,7 +359,6 @@ class AgentComposerService: raise ValueError("agent_soul is required") _backfill_cli_tool_ids(payload.agent_soul) _validate_composer_payload_for_strategy(payload) - cls.validate_knowledge_datasets(tenant_id=tenant_id, agent_soul=payload.agent_soul) agent = cls._get_agent_app_agent(tenant_id=tenant_id, app_id=app_id) if not agent: @@ -401,7 +402,6 @@ class AgentComposerService: raise ValueError("agent_soul is required") _backfill_cli_tool_ids(payload.agent_soul) _validate_composer_payload_for_strategy(payload) - cls.validate_knowledge_datasets(tenant_id=tenant_id, agent_soul=payload.agent_soul) agent = cls._require_agent(tenant_id=tenant_id, agent_id=agent_id) return cls._save_agent_composer_for_agent( tenant_id=tenant_id, @@ -511,6 +511,8 @@ class AgentComposerService: version_note=version_note, ) ) + if not agent_soul_has_model(agent_soul): + raise AgentModelNotConfiguredError() cls.validate_knowledge_datasets(tenant_id=tenant_id, agent_soul=agent_soul) version = cls._create_config_version( tenant_id=tenant_id, @@ -591,7 +593,6 @@ class AgentComposerService: raise ValueError("agent_soul is required") _backfill_cli_tool_ids(payload.agent_soul) ComposerConfigValidator.validate_draft_save_payload(payload) - cls.validate_knowledge_datasets(tenant_id=tenant_id, agent_soul=payload.agent_soul) agent = cls._require_agent(tenant_id=tenant_id, agent_id=agent_id) build_draft = cls._save_agent_draft( tenant_id=tenant_id, diff --git a/api/services/agent/composer_validator.py b/api/services/agent/composer_validator.py index 4e7d4ff3c63..a227978b764 100644 --- a/api/services/agent/composer_validator.py +++ b/api/services/agent/composer_validator.py @@ -3,6 +3,7 @@ from typing import Any from pydantic import ValidationError +from models.agent_config_entities import AgentKnowledgeQueryMode from services.agent.errors import AgentSoulLockedError, InvalidComposerConfigError, PlaintextSecretNotAllowedError from services.agent.prompt_mentions import ( MAX_MENTIONS_PER_PROMPT, @@ -228,9 +229,40 @@ class ComposerConfigValidator: @classmethod def validate_agent_soul(cls, agent_soul: AgentSoulConfig) -> None: dumped = agent_soul.model_dump(mode="json") + cls._validate_knowledge_runtime_config(agent_soul) cls._reject_plaintext_secrets(dumped, path="agent_soul") cls._validate_shell_config(dumped) + @classmethod + def _validate_knowledge_runtime_config(cls, agent_soul: AgentSoulConfig) -> None: + """Validate knowledge settings that are required only for publish/run. + + Draft composer saves must be able to persist partially configured + knowledge sets while a user is still editing the panel. These checks + stay in the publish validator so invalid runtime configs are still + blocked before a version can be published or executed. + """ + for knowledge_set in agent_soul.knowledge.sets: + if ( + knowledge_set.query.mode == AgentKnowledgeQueryMode.USER_QUERY + and not (knowledge_set.query.value or "").strip() + ): + raise InvalidComposerConfigError("knowledge query.value is required for user_query mode") + + retrieval = knowledge_set.retrieval + if retrieval.mode == "multiple" and retrieval.top_k is None: + raise InvalidComposerConfigError("knowledge retrieval.top_k is required for multiple mode") + if retrieval.mode == "single" and retrieval.model is None: + raise InvalidComposerConfigError("knowledge retrieval.model is required for single mode") + + metadata_filtering = knowledge_set.metadata_filtering + if metadata_filtering.mode == "automatic" and metadata_filtering.metadata_model_config is None: + raise InvalidComposerConfigError("metadata_filtering.model_config is required for automatic mode") + if metadata_filtering.mode == "manual" and ( + metadata_filtering.conditions is None or not metadata_filtering.conditions.conditions + ): + raise InvalidComposerConfigError("metadata_filtering.conditions is required for manual mode") + @classmethod def validate_node_job(cls, node_job: WorkflowNodeJobConfig) -> None: cls._reject_plaintext_secrets(node_job.model_dump(mode="json"), path="node_job") diff --git a/api/services/agent/errors.py b/api/services/agent/errors.py index 6a1dc6fb628..163687815d8 100644 --- a/api/services/agent/errors.py +++ b/api/services/agent/errors.py @@ -1,5 +1,7 @@ from werkzeug.exceptions import BadRequest, Conflict, NotFound +from libs.exception import BaseHTTPException + class AgentNotFoundError(NotFound): description = "Agent not found." @@ -21,6 +23,12 @@ class AgentVersionConflictError(Conflict): description = "Agent config version changed. Please reload and try again." +class AgentModelNotConfiguredError(BaseHTTPException): + error_code = "agent_model_not_configured" + description = "Agent App requires the Agent Soul model to be configured." + code = 400 + + class AgentSoulLockedError(BadRequest): description = "Agent Soul is locked for this workflow node." diff --git a/api/services/agent_app_sandbox_service.py b/api/services/agent_app_sandbox_service.py index be301f5cd14..b1652d628e9 100644 --- a/api/services/agent_app_sandbox_service.py +++ b/api/services/agent_app_sandbox_service.py @@ -3,12 +3,16 @@ These services keep product-facing locators (conversation, workflow run, node) on the API boundary and translate them into the agent backend's ``SandboxLocator`` using persisted non-sensitive runtime layer specs plus the -saved Agenton session snapshot. +saved Agenton session snapshot. Upload responses stay console-facing here: the +agent backend still returns a canonical ToolFile mapping, while this API layer +re-resolves that mapping into a signed browser download URL. """ from __future__ import annotations +import urllib.parse from collections.abc import Callable +from typing import Any from agenton.compositor import CompositorSessionSnapshot from dify_agent.client import Client @@ -18,7 +22,10 @@ from sqlalchemy import select from configs import dify_config from core.app.apps.agent_app.session_store import AgentAppRuntimeSessionStore +from core.app.file_access import DatabaseFileAccessController +from core.app.workflow.file_runtime import DifyWorkflowFileRuntime from core.db.session_factory import session_factory +from factories import file_factory from models.agent import AgentRuntimeSessionOwnerType, WorkflowAgentRuntimeSession, WorkflowAgentRuntimeSessionStatus _RUNTIME_LAYER_SPECS_ADAPTER: TypeAdapter[list[RuntimeLayerSpec]] = TypeAdapter(list[RuntimeLayerSpec]) @@ -45,6 +52,12 @@ class AgentSandboxInfo(BaseModel): workspace_cwd: str +class AgentSandboxUploadDownload(BaseModel): + """Signed browser download URL for one sandbox upload result.""" + + url: str + + class AgentAppSandboxService: """Inspect and proxy file access for an Agent App conversation sandbox.""" @@ -77,9 +90,15 @@ class AgentAppSandboxService: locator = self._resolve_locator(tenant_id=tenant_id, app_id=app_id, conversation_id=conversation_id) return self._client_factory().read_sandbox_file_sync(locator, path) - def upload_file(self, *, tenant_id: str, app_id: str, conversation_id: str, path: str): + def upload_file( + self, *, tenant_id: str, app_id: str, conversation_id: str, path: str + ) -> AgentSandboxUploadDownload: locator = self._resolve_locator(tenant_id=tenant_id, app_id=app_id, conversation_id=conversation_id) - return self._client_factory().upload_sandbox_file_sync(locator, path) + uploaded = self._client_factory().upload_sandbox_file_sync(locator, path) + return _upload_download_response( + tenant_id=tenant_id, + file_mapping=uploaded.file.model_dump(mode="python"), + ) def _resolve_locator(self, *, tenant_id: str, app_id: str, conversation_id: str) -> SandboxLocator: stored = self._session_store.load_active_session_for_conversation( @@ -153,7 +172,7 @@ class WorkflowAgentSandboxService: node_id: str, node_execution_id: str | None, path: str, - ): + ) -> AgentSandboxUploadDownload: locator = self._resolve_locator( tenant_id=tenant_id, app_id=app_id, @@ -161,7 +180,11 @@ class WorkflowAgentSandboxService: node_id=node_id, node_execution_id=node_execution_id, ) - return self._client_factory().upload_sandbox_file_sync(locator, path) + uploaded = self._client_factory().upload_sandbox_file_sync(locator, path) + return _upload_download_response( + tenant_id=tenant_id, + file_mapping=uploaded.file.model_dump(mode="python"), + ) def _resolve_locator( self, @@ -246,6 +269,41 @@ def _deserialize_runtime_layer_specs(value: str | None) -> list[RuntimeLayerSpec return _RUNTIME_LAYER_SPECS_ADAPTER.validate_json(value) +def _upload_download_response(*, tenant_id: str, file_mapping: dict[str, Any]) -> AgentSandboxUploadDownload: + """Resolve one uploaded ToolFile mapping into a signed external download URL.""" + + controller = DatabaseFileAccessController() + runtime = DifyWorkflowFileRuntime(file_access_controller=controller) + try: + file = file_factory.build_from_mapping( + mapping=file_mapping, + tenant_id=tenant_id, + access_controller=controller, + ) + url = runtime.resolve_file_url(file=file, for_external=True) + except ValueError as exc: + raise AgentSandboxInspectorError( + "sandbox_upload_download_unavailable", + "uploaded sandbox file could not be converted to a download URL", + status_code=502, + ) from exc + + if not url: + raise AgentSandboxInspectorError( + "sandbox_upload_download_unavailable", + "uploaded sandbox file does not support download URL generation", + status_code=502, + ) + return AgentSandboxUploadDownload(url=_with_as_attachment(url)) + + +def _with_as_attachment(url: str) -> str: + parsed = urllib.parse.urlsplit(url) + query = urllib.parse.parse_qsl(parsed.query, keep_blank_values=True) + query.append(("as_attachment", "true")) + return urllib.parse.urlunsplit(parsed._replace(query=urllib.parse.urlencode(query))) + + def _default_client_factory() -> Client: base_url = dify_config.AGENT_BACKEND_BASE_URL if not base_url: @@ -257,4 +315,10 @@ def _default_client_factory() -> Client: return Client(base_url=base_url) -__all__ = ["AgentAppSandboxService", "AgentSandboxInfo", "AgentSandboxInspectorError", "WorkflowAgentSandboxService"] +__all__ = [ + "AgentAppSandboxService", + "AgentSandboxInfo", + "AgentSandboxInspectorError", + "AgentSandboxUploadDownload", + "WorkflowAgentSandboxService", +] diff --git a/api/tests/unit_tests/controllers/common/test_agent_app_parameters.py b/api/tests/unit_tests/controllers/common/test_agent_app_parameters.py new file mode 100644 index 00000000000..f963729c8a2 --- /dev/null +++ b/api/tests/unit_tests/controllers/common/test_agent_app_parameters.py @@ -0,0 +1,156 @@ +from types import SimpleNamespace + +import pytest + +from controllers.common import agent_app_parameters +from controllers.common.agent_app_parameters import get_published_agent_app_feature_dict_and_user_input_form +from core.app.app_config.common.parameters_mapping import get_parameters_from_feature_dict +from core.app.apps.agent_app.errors import AgentAppGeneratorError, AgentAppNotPublishedError + + +def test_published_agent_app_parameters_use_soul_file_upload(monkeypatch): + app_model_config = SimpleNamespace( + to_dict=lambda: { + "opening_statement": "Hi from legacy presentation config", + "file_upload": { + "enabled": False, + "image": {"enabled": False}, + }, + } + ) + app_model = SimpleNamespace( + tenant_id="tenant-1", + bound_agent_id="agent-1", + app_model_config=app_model_config, + ) + agent = SimpleNamespace( + id="agent-1", + active_config_snapshot_id="snapshot-1", + active_config_is_published=True, + ) + snapshot = SimpleNamespace( + config_snapshot_dict={ + "app_features": { + "file_upload": { + "enabled": True, + "allowed_file_extensions": ["PNG"], + "allowed_file_types": ["image"], + "allowed_file_upload_methods": ["local_file"], + "image": {"enabled": True}, + "number_limits": 2, + } + }, + "app_variables": [{"name": "topic", "type": "string", "required": True}], + } + ) + query_results = iter([agent, snapshot]) + monkeypatch.setattr(agent_app_parameters.db.session, "scalar", lambda _: next(query_results)) + + features_dict, user_input_form = get_published_agent_app_feature_dict_and_user_input_form(app_model) + parameters = get_parameters_from_feature_dict(features_dict=features_dict, user_input_form=user_input_form) + + assert parameters["opening_statement"] == "Hi from legacy presentation config" + assert parameters["file_upload"] == { + "enabled": True, + "allowed_file_extensions": ["PNG"], + "allowed_file_types": ["image"], + "allowed_file_upload_methods": ["local_file"], + "image": {"enabled": True}, + "number_limits": 2, + } + assert parameters["user_input_form"] == [{"text-input": {"label": "topic", "variable": "topic", "required": True}}] + + +def test_published_agent_app_parameters_requires_bound_agent(): + app_model = SimpleNamespace( + tenant_id="tenant-1", + bound_agent_id=None, + app_model_config=None, + ) + + with pytest.raises(AgentAppGeneratorError, match="no bound Agent"): + get_published_agent_app_feature_dict_and_user_input_form(app_model) + + +def test_published_agent_app_parameters_requires_existing_active_agent(monkeypatch): + app_model = SimpleNamespace( + tenant_id="tenant-1", + bound_agent_id="agent-1", + app_model_config=None, + ) + monkeypatch.setattr(agent_app_parameters.db.session, "scalar", lambda _: None) + + with pytest.raises(AgentAppGeneratorError, match="no bound Agent"): + get_published_agent_app_feature_dict_and_user_input_form(app_model) + + +@pytest.mark.parametrize( + ("active_config_snapshot_id", "active_config_is_published"), + [ + (None, True), + ("snapshot-1", False), + ], +) +def test_published_agent_app_parameters_requires_published_agent( + monkeypatch, active_config_snapshot_id, active_config_is_published +): + app_model = SimpleNamespace( + tenant_id="tenant-1", + bound_agent_id="agent-1", + app_model_config=None, + ) + agent = SimpleNamespace( + id="agent-1", + active_config_snapshot_id=active_config_snapshot_id, + active_config_is_published=active_config_is_published, + ) + monkeypatch.setattr(agent_app_parameters.db.session, "scalar", lambda _: agent) + + with pytest.raises(AgentAppNotPublishedError, match="not been published"): + get_published_agent_app_feature_dict_and_user_input_form(app_model) + + +def test_published_agent_app_parameters_requires_published_snapshot(monkeypatch): + app_model = SimpleNamespace( + tenant_id="tenant-1", + bound_agent_id="agent-1", + app_model_config=None, + ) + agent = SimpleNamespace( + id="agent-1", + active_config_snapshot_id="snapshot-1", + active_config_is_published=True, + ) + query_results = iter([agent, None]) + monkeypatch.setattr(agent_app_parameters.db.session, "scalar", lambda _: next(query_results)) + + with pytest.raises(AgentAppGeneratorError, match="published version not found"): + get_published_agent_app_feature_dict_and_user_input_form(app_model) + + +def test_published_agent_app_parameters_allows_missing_legacy_app_model_config(monkeypatch): + app_model = SimpleNamespace( + tenant_id="tenant-1", + bound_agent_id="agent-1", + app_model_config=None, + ) + agent = SimpleNamespace( + id="agent-1", + active_config_snapshot_id="snapshot-1", + active_config_is_published=True, + ) + snapshot = SimpleNamespace(config_snapshot_dict={}) + query_results = iter([agent, snapshot]) + monkeypatch.setattr(agent_app_parameters.db.session, "scalar", lambda _: next(query_results)) + + features_dict, user_input_form = get_published_agent_app_feature_dict_and_user_input_form(app_model) + + assert features_dict["file_upload"] == { + "allowed_file_extensions": ["JPG", "JPEG", "PNG", "GIF", "WEBP", "SVG"], + "allowed_file_types": ["document", "image", "audio", "video"], + "allowed_file_upload_methods": ["local_file", "remote_url"], + "enabled": True, + "image": {"enabled": True}, + "number_limits": 3, + } + assert user_input_form == [] diff --git a/api/tests/unit_tests/controllers/console/app/test_agent_app_sandbox.py b/api/tests/unit_tests/controllers/console/app/test_agent_app_sandbox.py index fdb534fc092..8086f578956 100644 --- a/api/tests/unit_tests/controllers/console/app/test_agent_app_sandbox.py +++ b/api/tests/unit_tests/controllers/console/app/test_agent_app_sandbox.py @@ -5,11 +5,11 @@ from types import SimpleNamespace import pytest from dify_agent.client import DifyAgentClientError, DifyAgentHTTPError, DifyAgentTimeoutError -from dify_agent.protocol import SandboxListResponse, SandboxReadResponse, SandboxUploadResponse +from dify_agent.protocol import SandboxListResponse, SandboxReadResponse from controllers.console import agent_app_sandbox as module from models.model import App, AppMode, IconType -from services.agent_app_sandbox_service import AgentSandboxInfo, AgentSandboxInspectorError +from services.agent_app_sandbox_service import AgentSandboxInfo, AgentSandboxInspectorError, AgentSandboxUploadDownload class _AgentAppService: @@ -28,11 +28,11 @@ class _AgentAppService: self.calls.append(("read", tenant_id, app_id, conversation_id, path)) return SandboxReadResponse(path=path, size=5, truncated=False, binary=False, text="hello") - def upload_file(self, *, tenant_id: str, app_id: str, conversation_id: str, path: str) -> SandboxUploadResponse: + def upload_file( + self, *, tenant_id: str, app_id: str, conversation_id: str, path: str + ) -> AgentSandboxUploadDownload: self.calls.append(("upload", tenant_id, app_id, conversation_id, path)) - return SandboxUploadResponse( - path=path, file={"transfer_method": "tool_file", "reference": "dify-file-ref:file-1"} - ) + return AgentSandboxUploadDownload(url="https://files.example/report.txt") class _WorkflowService: @@ -74,11 +74,9 @@ class _WorkflowService: node_id: str, node_execution_id: str | None, path: str, - ) -> SandboxUploadResponse: + ) -> AgentSandboxUploadDownload: self.calls.append(("upload", tenant_id, app_id, workflow_run_id, node_id, node_execution_id, path)) - return SandboxUploadResponse( - path=path, file={"transfer_method": "tool_file", "reference": "dify-file-ref:file-1"} - ) + return AgentSandboxUploadDownload(url="https://files.example/upload.txt") def _app_model(app_id: str = "app-1") -> App: @@ -143,7 +141,7 @@ def test_agent_app_sandbox_resources_proxy_service(monkeypatch: pytest.MonkeyPat assert info == {"session_id": "abc1234", "workspace_cwd": "~/workspace/abc1234"} assert listing["path"] == "sub/report.txt" assert preview["text"] == "hello" - assert upload["file"]["reference"] == "dify-file-ref:file-1" + assert upload == {"url": "https://files.example/report.txt"} assert service.calls == [ ("info", "tenant-1", "app-1", "conv-1", ""), ("list", "tenant-1", "app-1", "conv-1", "sub/report.txt"), @@ -203,7 +201,7 @@ def test_workflow_agent_sandbox_resources_proxy_service(monkeypatch: pytest.Monk assert listing["path"] == "out.txt" assert preview["text"] == "hello" - assert upload["file"]["reference"] == "dify-file-ref:file-1" + assert upload == {"url": "https://files.example/upload.txt"} assert service.calls == [ ("list", "tenant-1", "app-1", "run-1", "agent-node", "exec-1", "out.txt"), ("read", "tenant-1", "app-1", "run-1", "agent-node", "exec-1", "out.txt"), diff --git a/api/tests/unit_tests/controllers/openapi/test_error_contract.py b/api/tests/unit_tests/controllers/openapi/test_error_contract.py index 788a7215ed2..6a30637b7ae 100644 --- a/api/tests/unit_tests/controllers/openapi/test_error_contract.py +++ b/api/tests/unit_tests/controllers/openapi/test_error_contract.py @@ -35,6 +35,7 @@ from controllers.openapi._errors import ( RecipientSurfaceMismatch, ) from controllers.service_api.app.error import ( + AgentNotPublishedError, AppUnavailableError, CompletionRequestError, ConversationCompletedError, @@ -306,6 +307,7 @@ ERROR_MATRIX = [ (InternalServerError(), 500, "internal_server_error"), (BadGateway("x"), 502, "bad_gateway"), (AppUnavailableError(), 400, "app_unavailable"), + (AgentNotPublishedError(), 400, "agent_not_published"), (ConversationCompletedError(), 400, "conversation_completed"), (ProviderNotInitializeError(), 400, "provider_not_initialize"), (ProviderQuotaExceededError(), 400, "provider_quota_exceeded"), diff --git a/api/tests/unit_tests/controllers/service_api/app/test_app.py b/api/tests/unit_tests/controllers/service_api/app/test_app.py index 9bc020b8b8f..30979a26980 100644 --- a/api/tests/unit_tests/controllers/service_api/app/test_app.py +++ b/api/tests/unit_tests/controllers/service_api/app/test_app.py @@ -9,7 +9,8 @@ import pytest from flask import Flask from controllers.service_api.app.app import AppInfoApi, AppMetaApi, AppParameterApi -from controllers.service_api.app.error import AppUnavailableError +from controllers.service_api.app.error import AgentNotPublishedError, AppUnavailableError +from core.app.apps.agent_app.errors import AgentAppNotPublishedError from models.account import TenantStatus from models.model import App, AppMode from tests.unit_tests.conftest import setup_mock_tenant_owner_execute_result @@ -185,6 +186,41 @@ class TestAppParameterApi: ] mock_get_agent_parameters.assert_called_once_with(mock_app_model) + @patch("controllers.service_api.wraps.user_logged_in") + @patch("controllers.service_api.wraps.current_app") + @patch("controllers.service_api.wraps.validate_and_get_api_token") + @patch("controllers.service_api.wraps.db") + @patch( + "controllers.service_api.app.app.get_published_agent_app_feature_dict_and_user_input_form", + side_effect=AgentAppNotPublishedError("Agent has not been published"), + ) + def test_get_parameters_for_unpublished_agent_app_raises_friendly_error( + self, + mock_get_agent_parameters, + mock_db, + mock_validate_token, + mock_current_app, + mock_user_logged_in, + app: Flask, + mock_app_model, + ): + _configure_current_app_mock(mock_current_app) + + mock_app_model.mode = AppMode.AGENT + mock_api_token = Mock() + mock_api_token.app_id = mock_app_model.id + mock_api_token.tenant_id = mock_app_model.tenant_id + mock_validate_token.return_value = mock_api_token + + mock_tenant = Mock() + mock_tenant.status = TenantStatus.NORMAL + mock_db.session.get.side_effect = [mock_app_model, mock_tenant] + setup_mock_tenant_owner_execute_result(mock_db, mock_tenant, Mock(current_tenant=mock_tenant)) + + with app.test_request_context("/parameters", method="GET", headers={"Authorization": "Bearer test_token"}): + with pytest.raises(AgentNotPublishedError): + AppParameterApi().get() + @patch("controllers.service_api.wraps.user_logged_in") @patch("controllers.service_api.wraps.current_app") @patch("controllers.service_api.wraps.validate_and_get_api_token") diff --git a/api/tests/unit_tests/controllers/service_api/app/test_completion.py b/api/tests/unit_tests/controllers/service_api/app/test_completion.py index 393bdaf5eda..9f2a2edeff8 100644 --- a/api/tests/unit_tests/controllers/service_api/app/test_completion.py +++ b/api/tests/unit_tests/controllers/service_api/app/test_completion.py @@ -31,10 +31,12 @@ from controllers.service_api.app.completion import ( CompletionStopApi, ) from controllers.service_api.app.error import ( + AgentNotPublishedError, AppUnavailableError, ConversationCompletedError, NotChatAppError, ) +from core.app.apps.agent_app.errors import AgentAppNotPublishedError from core.errors.error import QuotaExceededError from graphon.model_runtime.errors.invoke import InvokeError from models.model import App, AppMode, EndUser @@ -516,6 +518,22 @@ class TestChatApiController: with pytest.raises(BadRequest): handler(api, session=Mock(), app_model=app_model, end_user=end_user) + def test_agent_not_published_error_mapped(self, app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr( + AppGenerateService, + "generate", + lambda *_args, **_kwargs: (_ for _ in ()).throw(AgentAppNotPublishedError("Agent has not been published")), + ) + + api = ChatApi() + handler = unwrap(api.post) + app_model = SimpleNamespace(mode=AppMode.AGENT.value) + end_user = SimpleNamespace() + + with app.test_request_context("/chat-messages", method="POST", json={"inputs": {}, "query": "hi"}): + with pytest.raises(AgentNotPublishedError): + handler(api, session=Mock(), app_model=app_model, end_user=end_user) + class TestChatStopApiController: def test_wrong_mode(self, app: Flask) -> None: diff --git a/api/tests/unit_tests/controllers/web/test_app.py b/api/tests/unit_tests/controllers/web/test_app.py index ce7ae271889..542ee111e1b 100644 --- a/api/tests/unit_tests/controllers/web/test_app.py +++ b/api/tests/unit_tests/controllers/web/test_app.py @@ -9,7 +9,8 @@ import pytest from flask import Flask from controllers.web.app import AppAccessMode, AppMeta, AppParameterApi, AppWebAuthPermission -from controllers.web.error import AppUnavailableError +from controllers.web.error import AgentNotPublishedError, AppUnavailableError +from core.app.apps.agent_app.errors import AgentAppNotPublishedError # --------------------------------------------------------------------------- @@ -80,6 +81,18 @@ class TestAppParameterApi: with pytest.raises(AppUnavailableError): AppParameterApi().get(app_model, SimpleNamespace()) + def test_agent_mode_unpublished_raises_friendly_error(self, app: Flask) -> None: + app_model = SimpleNamespace(mode="agent") + with ( + app.test_request_context("/parameters"), + patch( + "controllers.web.app.get_published_agent_app_feature_dict_and_user_input_form", + side_effect=AgentAppNotPublishedError("Agent has not been published"), + ), + ): + with pytest.raises(AgentNotPublishedError): + AppParameterApi().get(app_model, SimpleNamespace()) + # --------------------------------------------------------------------------- # AppMeta diff --git a/api/tests/unit_tests/controllers/web/test_completion.py b/api/tests/unit_tests/controllers/web/test_completion.py index 4f8d848637d..49a88802470 100644 --- a/api/tests/unit_tests/controllers/web/test_completion.py +++ b/api/tests/unit_tests/controllers/web/test_completion.py @@ -10,6 +10,7 @@ from flask import Flask from controllers.web.completion import ChatApi, ChatStopApi, CompletionApi, CompletionStopApi from controllers.web.error import ( + AgentNotPublishedError, CompletionRequestError, NotChatAppError, NotCompletionAppError, @@ -17,6 +18,7 @@ from controllers.web.error import ( ProviderNotInitializeError, ProviderQuotaExceededError, ) +from core.app.apps.agent_app.errors import AgentAppNotPublishedError from core.errors.error import ModelCurrentlyNotSupportError, ProviderTokenNotInitError, QuotaExceededError from graphon.model_runtime.errors.invoke import InvokeError @@ -142,6 +144,19 @@ class TestChatApi: with pytest.raises(CompletionRequestError): ChatApi().post(_chat_app(), _end_user()) + @patch( + "controllers.web.completion.AppGenerateService.generate", + side_effect=AgentAppNotPublishedError("Agent has not been published"), + ) + @patch("controllers.web.completion.web_ns") + def test_agent_not_published_error_mapped(self, mock_ns: MagicMock, mock_gen: MagicMock, app: Flask) -> None: + mock_ns.payload = {"inputs": {}, "query": "x"} + app_model = SimpleNamespace(id="app-1", mode="agent") + + with app.test_request_context("/chat-messages", method="POST"): + with pytest.raises(AgentNotPublishedError): + ChatApi().post(app_model, _end_user()) + # --------------------------------------------------------------------------- # ChatStopApi diff --git a/api/tests/unit_tests/controllers/web/test_error.py b/api/tests/unit_tests/controllers/web/test_error.py index 2852c4806c9..9bf58ee0c5f 100644 --- a/api/tests/unit_tests/controllers/web/test_error.py +++ b/api/tests/unit_tests/controllers/web/test_error.py @@ -6,6 +6,7 @@ import pytest from controllers.common.errors import InvalidArgumentError, NotFoundError from controllers.web.error import ( + AgentNotPublishedError, AppMoreLikeThisDisabledError, AppSuggestedQuestionsAfterAnswerDisabledError, AppUnavailableError, @@ -29,6 +30,7 @@ from controllers.web.error import ( _ERROR_SPECS: list[tuple[type, str, int]] = [ (AppUnavailableError, "app_unavailable", 400), + (AgentNotPublishedError, "agent_not_published", 400), (NotCompletionAppError, "not_completion_app", 400), (NotChatAppError, "not_chat_app", 400), (NotWorkflowAppError, "not_workflow_app", 400), diff --git a/api/tests/unit_tests/core/app/apps/agent_app/test_app_config_manager.py b/api/tests/unit_tests/core/app/apps/agent_app/test_app_config_manager.py index c2a49d1b3a6..73c53ae98f5 100644 --- a/api/tests/unit_tests/core/app/apps/agent_app/test_app_config_manager.py +++ b/api/tests/unit_tests/core/app/apps/agent_app/test_app_config_manager.py @@ -65,6 +65,36 @@ def test_missing_soul_model_leaves_no_model_key(): d = AgentAppConfigManager._synthesize_config_dict(AgentSoulConfig(), None) assert "model" not in d assert d["pre_prompt"] == "" + assert d["file_upload"] == { + "allowed_file_extensions": ["JPG", "JPEG", "PNG", "GIF", "WEBP", "SVG"], + "allowed_file_types": ["document", "image", "audio", "video"], + "allowed_file_upload_methods": ["local_file", "remote_url"], + "enabled": True, + "image": {"enabled": True}, + "number_limits": 3, + } + + +def test_soul_file_upload_overrides_legacy_app_model_config(): + fake_amc = SimpleNamespace( + to_dict=lambda: { + "file_upload": { + "enabled": False, + "image": {"enabled": False}, + }, + } + ) + + d = AgentAppConfigManager._synthesize_config_dict(AgentSoulConfig(), fake_amc) # type: ignore[arg-type] + + assert d["file_upload"] == { + "allowed_file_extensions": ["JPG", "JPEG", "PNG", "GIF", "WEBP", "SVG"], + "allowed_file_types": ["document", "image", "audio", "video"], + "allowed_file_upload_methods": ["local_file", "remote_url"], + "enabled": True, + "image": {"enabled": True}, + "number_limits": 3, + } def test_prompt_type_defaults_to_simple(): diff --git a/api/tests/unit_tests/core/app/apps/agent_app/test_app_runner.py b/api/tests/unit_tests/core/app/apps/agent_app/test_app_runner.py index 8ebf68c7061..e0a7687de95 100644 --- a/api/tests/unit_tests/core/app/apps/agent_app/test_app_runner.py +++ b/api/tests/unit_tests/core/app/apps/agent_app/test_app_runner.py @@ -568,7 +568,7 @@ def test_successful_turn_persists_thinking_and_tool_process_events(monkeypatch): rows = sorted(fake_session.rows.values(), key=lambda row: row.position) assert rows[0].thought == "I need to inspect the file." - assert rows[0].tool is None + assert rows[0].tool == "" assert rows[1].tool == "bash" assert rows[1].tool_input == '{"cmd": "ls"}' assert rows[1].observation == "ok" @@ -656,9 +656,9 @@ def test_tool_result_without_identity_does_not_attach_to_previous_tool(monkeypat assert len(rows) == 2 assert rows[0].tool == "shell_run" assert rows[0].tool_input == '{"script": "npx skills find browser"}' - assert rows[0].observation is None - assert rows[1].tool is None - assert rows[1].tool_input is None + assert rows[0].observation == "" + assert rows[1].tool == "" + assert rows[1].tool_input == "" assert rows[1].observation == "Knowledge base search results: browser skill" diff --git a/api/tests/unit_tests/core/app/apps/agent_app/test_resolve_agent.py b/api/tests/unit_tests/core/app/apps/agent_app/test_resolve_agent.py index 806741bb8fe..293c6676e3c 100644 --- a/api/tests/unit_tests/core/app/apps/agent_app/test_resolve_agent.py +++ b/api/tests/unit_tests/core/app/apps/agent_app/test_resolve_agent.py @@ -13,7 +13,7 @@ from typing import Any import pytest from core.app.apps.agent_app import app_generator as gen_mod -from core.app.apps.agent_app.app_generator import AgentAppGenerator, AgentAppGeneratorError +from core.app.apps.agent_app.app_generator import AgentAppGenerator, AgentAppGeneratorError, AgentAppNotPublishedError from core.app.entities.app_invoke_entities import InvokeFrom _SOUL_DICT = { @@ -78,7 +78,7 @@ class TestResolveAgentById: class TestResolveAgent: def test_success_chains_to_resolve_by_id(self, monkeypatch: pytest.MonkeyPatch): - bound_agent = SimpleNamespace(id="agent-1", active_config_snapshot_id="snap-1") + bound_agent = SimpleNamespace(id="agent-1", active_config_snapshot_id="snap-1", active_config_is_published=True) inner_agent = SimpleNamespace(id="agent-1") snapshot = _snapshot() # scalar order: bound agent (in _resolve_agent), then agent + snapshot (in _resolve_agent_by_id) @@ -97,6 +97,23 @@ class TestResolveAgent: assert config_version_kind == "snapshot" assert soul.model is not None + def test_unpublished_agent_raises_before_model_resolution(self, monkeypatch: pytest.MonkeyPatch): + bound_agent = SimpleNamespace( + id="agent-1", + active_config_snapshot_id="snap-1", + active_config_is_published=False, + ) + _patch_session(monkeypatch, [bound_agent]) + app_model = SimpleNamespace(id="app-1", tenant_id="t1") + + with pytest.raises(AgentAppNotPublishedError, match="not been published"): + AgentAppGenerator()._resolve_agent( + app_model, + invoke_from=InvokeFrom.WEB_APP, + draft_type=None, + user=SimpleNamespace(id="user-1"), + ) # type: ignore[arg-type] + def test_unbound_app_raises(self, monkeypatch: pytest.MonkeyPatch): _patch_session(monkeypatch, [None]) app_model = SimpleNamespace(id="app-1", tenant_id="t1") diff --git a/api/tests/unit_tests/core/app/task_pipeline/test_easy_ui_based_generate_task_pipeline_core.py b/api/tests/unit_tests/core/app/task_pipeline/test_easy_ui_based_generate_task_pipeline_core.py index 18382f053be..54e57d04d99 100644 --- a/api/tests/unit_tests/core/app/task_pipeline/test_easy_ui_based_generate_task_pipeline_core.py +++ b/api/tests/unit_tests/core/app/task_pipeline/test_easy_ui_based_generate_task_pipeline_core.py @@ -721,6 +721,56 @@ class TestEasyUiBasedGenerateTaskPipeline: assert response is not None assert response.id == "thought" + def test_agent_thought_to_stream_response_normalizes_null_display_fields(self, monkeypatch: pytest.MonkeyPatch): + conversation = _make_conversation(AppMode.CHAT) + message = _make_message() + + pipeline = EasyUIBasedGenerateTaskPipeline( + application_generate_entity=_make_entity(ChatAppGenerateEntity, AppMode.CHAT), + queue_manager=_FakeQueueManager(), + conversation=conversation, + message=message, + stream=True, + ) + + agent_thought = _agent_thought() + agent_thought.thought = None + agent_thought.observation = None + agent_thought.tool = None + agent_thought.tool_input = None + agent_thought.message_files = None + + class _Session: + def __init__(self, *args, **kwargs): + pass + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + def scalar(self, *args, **kwargs): + return agent_thought + + monkeypatch.setattr( + "core.app.task_pipeline.easy_ui_based_generate_task_pipeline.Session", + _Session, + ) + monkeypatch.setattr( + "core.app.task_pipeline.easy_ui_based_generate_task_pipeline.db", + _FakeDb(), + ) + + response = pipeline._agent_thought_to_stream_response(QueueAgentThoughtEvent(agent_thought_id="thought")) + + assert response is not None + assert response.thought == "" + assert response.observation == "" + assert response.tool == "" + assert response.tool_input == "" + assert response.model_dump(mode="json")["message_files"] == [] + def test_process_routes_to_stream_and_starts_conversation_name_generation(self): conversation = _make_conversation(AppMode.CHAT) message = _make_message() @@ -1280,7 +1330,7 @@ class TestEasyUiBasedGenerateTaskPipeline: usage_metadata = cast(dict[str, object], response.metadata["usage"]) assert usage_metadata["prompt_tokens"] == 1 - def test_record_files_returns_none_when_message_has_no_files(self, monkeypatch: pytest.MonkeyPatch): + def test_record_files_returns_empty_list_when_message_has_no_files(self, monkeypatch: pytest.MonkeyPatch): conversation = _make_conversation(AppMode.CHAT) message = _make_message() pipeline = EasyUIBasedGenerateTaskPipeline( @@ -1316,7 +1366,7 @@ class TestEasyUiBasedGenerateTaskPipeline: response = pipeline._message_end_to_stream_response() - assert response.files is None + assert response.files == [] def test_record_files_handles_local_fallback_and_tool_url_variants(self, monkeypatch: pytest.MonkeyPatch): conversation = _make_conversation(AppMode.CHAT) diff --git a/api/tests/unit_tests/core/app/task_pipeline/test_easy_ui_message_end_files.py b/api/tests/unit_tests/core/app/task_pipeline/test_easy_ui_message_end_files.py index 595d716666c..b1c06e237a8 100644 --- a/api/tests/unit_tests/core/app/task_pipeline/test_easy_ui_message_end_files.py +++ b/api/tests/unit_tests/core/app/task_pipeline/test_easy_ui_message_end_files.py @@ -6,7 +6,7 @@ SSE event, which is critical for vision/image chat responses to render correctly Test Coverage: - Files array populated when MessageFile records exist -- Files array is None when no MessageFile records exist +- Files array is empty when no MessageFile records exist - Correct signed URL generation for LOCAL_FILE transfer method - Correct URL handling for REMOTE_URL transfer method - Correct URL handling for TOOL_FILE transfer method @@ -90,7 +90,7 @@ class TestMessageEndStreamResponseFiles: return upload_file def test_message_end_with_no_files(self, mock_pipeline): - """Test that files array is None when no MessageFile records exist.""" + """Test that files array is empty when no MessageFile records exist.""" # Arrange with ( patch("core.app.task_pipeline.easy_ui_based_generate_task_pipeline.db") as mock_db, @@ -108,9 +108,10 @@ class TestMessageEndStreamResponseFiles: # Assert assert isinstance(result, MessageEndStreamResponse) - assert result.files is None + assert result.files == [] assert result.id == mock_pipeline._message_id assert result.metadata == {"test": "metadata"} + mock_pipeline._task_state.metadata.model_dump.assert_called_once_with(exclude_none=True) def test_message_end_with_local_file(self, mock_pipeline, mock_message_file_local, mock_upload_file): """Test that files array is populated correctly for LOCAL_FILE transfer method.""" diff --git a/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_runtime_request_builder.py b/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_runtime_request_builder.py index 10f321d3a2a..14210c5e553 100644 --- a/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_runtime_request_builder.py +++ b/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_runtime_request_builder.py @@ -227,6 +227,15 @@ def _previous_node_prompt_payload(result, selector: str) -> object: raise AssertionError(f"missing prompt payload for {selector}") +def _uploaded_workflow_files_prompt_payload(result) -> object: + prefix = " - sys.files: " + user_prompt = _workflow_user_prompt(result) + for line in user_prompt.splitlines(): + if line.startswith(prefix): + return json.loads(line.removeprefix(prefix)) + raise AssertionError("missing prompt payload for sys.files") + + def test_builds_create_run_request_from_agent_soul_and_node_job(): result = WorkflowAgentRuntimeRequestBuilder(credentials_provider=FakeCredentialsProvider()).build(_context()) @@ -1252,6 +1261,48 @@ def test_previous_node_file_array_uses_agent_stub_download_mappings_in_workflow_ ] +def test_uploaded_workflow_files_are_included_without_prompt_marker(): + file_reference = build_file_reference(record_id="uploaded-file-1") + + class UploadedFilesVariablePool(FakeVariablePool): + def get(self, selector): + if list(selector) == ["sys", "files"]: + return ArrayFileSegment( + value=[ + File( + type=FileType.DOCUMENT, + transfer_method=FileTransferMethod.LOCAL_FILE, + reference=file_reference, + remote_url=None, + filename="requirements.pdf", + extension=".pdf", + mime_type="application/pdf", + size=12, + ) + ] + ) + return super().get(selector) + + context = replace(_context(), variable_pool=UploadedFilesVariablePool()) + context.binding.node_job_config = WorkflowNodeJobConfig.model_validate( + { + "workflow_prompt": "Answer the user's question.", + } + ) + + result = WorkflowAgentRuntimeRequestBuilder(credentials_provider=FakeCredentialsProvider()).build(context) + + user_prompt = _workflow_user_prompt(result) + assert "- Uploaded workflow files:" in user_prompt + assert _uploaded_workflow_files_prompt_payload(result) == [ + { + "transfer_method": "local_file", + "reference": file_reference, + } + ] + assert "Previous node outputs:" not in user_prompt + + def test_previous_node_remote_url_file_mapping_is_not_truncated_in_workflow_context(): remote_url = "https://example.com/" + ("a" * 2100) + ".pdf" diff --git a/api/tests/unit_tests/services/agent/test_agent_composer_entities.py b/api/tests/unit_tests/services/agent/test_agent_composer_entities.py index 07f993c1b8e..4aaae11b7dc 100644 --- a/api/tests/unit_tests/services/agent/test_agent_composer_entities.py +++ b/api/tests/unit_tests/services/agent/test_agent_composer_entities.py @@ -14,6 +14,23 @@ from services.entities.agent_entities import ( ) +def test_default_agent_soul_enables_file_upload_feature(): + agent_soul = AgentSoulConfig() + + file_upload = agent_soul.model_dump(mode="json")["app_features"]["file_upload"] + assert file_upload == { + "allowed_file_extensions": ["JPG", "JPEG", "PNG", "GIF", "WEBP", "SVG"], + "allowed_file_types": ["document", "image", "audio", "video"], + "allowed_file_upload_methods": ["local_file", "remote_url"], + "enabled": True, + "image": {"enabled": True}, + "number_limits": 3, + } + # The product default should be visible in API responses, but it must not + # make workflow-only payload validation treat app_features as user-authored. + assert bool(agent_soul.app_features) is False + + def test_workflow_variant_rejects_agent_app_only_fields(): with pytest.raises(ValueError): ComposerSavePayload.model_validate( @@ -257,6 +274,16 @@ def test_knowledge_query_mode_uses_stable_backend_enums(): }, "knowledge set dataset ids must be unique", ), + ], +) +def test_knowledge_sets_contract_rejects_invalid_configs(knowledge_payload, match: str): + with pytest.raises(ValidationError, match=match): + AgentSoulConfig.model_validate({"knowledge": knowledge_payload}) + + +@pytest.mark.parametrize( + ("knowledge_payload", "match"), + [ ( { "sets": [ @@ -317,9 +344,25 @@ def test_knowledge_query_mode_uses_stable_backend_enums(): ), ], ) -def test_knowledge_sets_contract_rejects_invalid_configs(knowledge_payload, match: str): - with pytest.raises(ValidationError, match=match): - AgentSoulConfig.model_validate({"knowledge": knowledge_payload}) +def test_knowledge_runtime_requirements_block_publish_but_not_draft_save(knowledge_payload, match: str): + draft_payload = ComposerSavePayload.model_validate( + { + "variant": ComposerVariant.AGENT_APP, + "save_strategy": ComposerSaveStrategy.SAVE_TO_CURRENT_VERSION, + "agent_soul": {"knowledge": knowledge_payload}, + } + ) + ComposerConfigValidator.validate_draft_save_payload(draft_payload) + + publish_payload = ComposerSavePayload.model_validate( + { + "variant": ComposerVariant.AGENT_APP, + "save_strategy": ComposerSaveStrategy.SAVE_AS_NEW_VERSION, + "agent_soul": {"knowledge": knowledge_payload}, + } + ) + with pytest.raises(InvalidComposerConfigError, match=match): + ComposerConfigValidator.validate_publish_payload(publish_payload) def test_agent_soul_model_config_is_first_class_without_credentials(): diff --git a/api/tests/unit_tests/services/agent/test_agent_services.py b/api/tests/unit_tests/services/agent/test_agent_services.py index cebbbf4f94f..6fee58bb29d 100644 --- a/api/tests/unit_tests/services/agent/test_agent_services.py +++ b/api/tests/unit_tests/services/agent/test_agent_services.py @@ -36,6 +36,7 @@ from services.agent.agent_soul_state import agent_soul_has_model from services.agent.composer_service import AgentComposerService from services.agent.composer_validator import ComposerConfigValidator from services.agent.errors import ( + AgentModelNotConfiguredError, AgentNameConflictError, AgentNotFoundError, AgentVersionConflictError, @@ -576,6 +577,55 @@ def test_save_agent_app_composer_keeps_published_when_draft_matches_active_snaps assert fake_session.commits == 1 +def test_publish_agent_app_draft_rejects_missing_model(monkeypatch: pytest.MonkeyPatch): + agent = Agent( + id="agent-1", + tenant_id="tenant-1", + name="Iris", + description="", + agent_kind=AgentKind.DIFY_AGENT, + scope=AgentScope.ROSTER, + source=AgentSource.AGENT_APP, + status=AgentStatus.ACTIVE, + active_config_snapshot_id="version-1", + active_config_is_published=False, + ) + draft = AgentConfigDraft( + tenant_id="tenant-1", + agent_id="agent-1", + draft_type=AgentConfigDraftType.DRAFT, + draft_owner_key="", + base_snapshot_id="version-1", + config_snapshot=AgentSoulConfig(), + ) + fake_session = FakeSession(scalar=[agent, draft]) + + def fail_create_config_version(**_kwargs): + raise AssertionError("config version must not be created when Agent Soul has no model") + + def fail_validate_knowledge_datasets(**_kwargs): + raise AssertionError("knowledge datasets must not be validated when Agent Soul has no model") + + monkeypatch.setattr(composer_service.db, "session", fake_session) + monkeypatch.setattr(composer_service.ComposerConfigValidator, "validate_publish_payload", lambda payload: None) + monkeypatch.setattr(AgentComposerService, "validate_knowledge_datasets", fail_validate_knowledge_datasets) + monkeypatch.setattr(AgentComposerService, "_create_config_version", fail_create_config_version) + + with pytest.raises(AgentModelNotConfiguredError) as exc_info: + AgentComposerService.publish_agent_app_draft( + tenant_id="tenant-1", + agent_id="agent-1", + account_id="account-1", + version_note="ship it", + ) + + assert exc_info.value.error_code == "agent_model_not_configured" + assert agent.active_config_snapshot_id == "version-1" + assert agent.active_config_is_published is False + assert draft.base_snapshot_id == "version-1" + assert fake_session.commits == 0 + + def test_publish_agent_app_draft_creates_published_snapshot(monkeypatch: pytest.MonkeyPatch): agent = Agent( id="agent-1", @@ -4257,31 +4307,7 @@ def test_dataset_rows_filters_malformed_ids(monkeypatch: pytest.MonkeyPatch): assert captured == {} -@pytest.mark.parametrize( - ("variant", "save_call"), - [ - ( - ComposerVariant.AGENT_APP, - lambda payload: AgentComposerService.save_agent_app_composer( - tenant_id="tenant-1", - app_id="app-1", - account_id="account-1", - payload=payload, - ), - ), - ( - ComposerVariant.WORKFLOW, - lambda payload: AgentComposerService.save_workflow_composer( - tenant_id="tenant-1", - app_id="app-1", - node_id="node-1", - account_id="account-1", - payload=payload, - ), - ), - ], -) -def test_composer_save_rejects_malformed_knowledge_dataset_ids(monkeypatch: pytest.MonkeyPatch, variant, save_call): +def test_validate_knowledge_datasets_rejects_malformed_ids_without_dataset_lookup(monkeypatch: pytest.MonkeyPatch): captured = {"calls": 0} def fake_get_datasets_by_ids(ids, tenant_id): @@ -4294,60 +4320,29 @@ def test_composer_save_rejects_malformed_knowledge_dataset_ids(monkeypatch: pyte monkeypatch.setattr(dataset_service_module.DatasetService, "get_datasets_by_ids", fake_get_datasets_by_ids) - payload = ComposerSavePayload.model_validate( + agent_soul = AgentSoulConfig.model_validate( { - "variant": variant.value, - "save_strategy": ComposerSaveStrategy.SAVE_TO_CURRENT_VERSION.value, - "soul_lock": {"locked": False}, - "agent_soul": { - "knowledge": { - "sets": [ - { - "id": "support", - "name": "Support KB", - "datasets": [{"id": "not-a-uuid"}], - "query": {"mode": "generated_query"}, - "retrieval": {"mode": "multiple", "top_k": 4}, - } - ] - } + "knowledge": { + "sets": [ + { + "id": "support", + "name": "Support KB", + "datasets": [{"id": "not-a-uuid"}], + "query": {"mode": "generated_query"}, + "retrieval": {"mode": "multiple", "top_k": 4}, + } + ] }, } ) with pytest.raises(InvalidComposerConfigError, match="not-a-uuid"): - save_call(payload) + AgentComposerService.validate_knowledge_datasets(tenant_id="tenant-1", agent_soul=agent_soul) assert captured == {"calls": 0} -@pytest.mark.parametrize( - ("variant", "save_call"), - [ - ( - ComposerVariant.AGENT_APP, - lambda payload: AgentComposerService.save_agent_app_composer( - tenant_id="tenant-1", - app_id="app-1", - account_id="account-1", - payload=payload, - ), - ), - ( - ComposerVariant.WORKFLOW, - lambda payload: AgentComposerService.save_workflow_composer( - tenant_id="tenant-1", - app_id="app-1", - node_id="node-1", - account_id="account-1", - payload=payload, - ), - ), - ], -) -def test_composer_save_rejects_missing_or_out_of_scope_knowledge_datasets( - monkeypatch: pytest.MonkeyPatch, variant, save_call -): +def test_validate_knowledge_datasets_rejects_missing_or_out_of_scope_datasets(monkeypatch: pytest.MonkeyPatch): captured = {} missing_dataset_id = "550e8400-e29b-41d4-a716-446655440000" @@ -4360,20 +4355,70 @@ def test_composer_save_rejects_missing_or_out_of_scope_knowledge_datasets( monkeypatch.setattr(dataset_service_module.DatasetService, "get_datasets_by_ids", fake_get_datasets_by_ids) + agent_soul = AgentSoulConfig.model_validate( + { + "knowledge": { + "sets": [ + { + "id": "support", + "name": "Support KB", + "datasets": [{"id": missing_dataset_id}], + "query": {"mode": "generated_query"}, + "retrieval": {"mode": "multiple", "top_k": 4}, + } + ] + }, + } + ) + + with pytest.raises(InvalidComposerConfigError, match=missing_dataset_id): + AgentComposerService.validate_knowledge_datasets(tenant_id="tenant-1", agent_soul=agent_soul) + + assert captured == {"ids": [missing_dataset_id], "tenant_id": "tenant-1"} + + +def test_save_agent_composer_allows_incomplete_knowledge_draft(monkeypatch: pytest.MonkeyPatch): + agent = SimpleNamespace( + id="agent-1", + source=AgentSource.AGENT_APP, + active_config_snapshot_id="version-1", + active_config_is_published=True, + updated_by=None, + ) + active_version = SimpleNamespace(config_snapshot_dict=AgentSoulConfig().model_dump(mode="json")) + fake_session = FakeSession(scalar=[agent]) + saved = {} + + import services.dataset_service as dataset_service_module + + monkeypatch.setattr(composer_service.db, "session", fake_session) + monkeypatch.setattr( + dataset_service_module.DatasetService, + "get_datasets_by_ids", + lambda *_args, **_kwargs: (_ for _ in ()).throw(AssertionError("draft save must skip dataset lookup")), + ) + monkeypatch.setattr( + AgentComposerService, + "_save_agent_draft", + lambda **kwargs: saved.update(kwargs) or SimpleNamespace(id="draft-1"), + ) + monkeypatch.setattr(AgentComposerService, "_get_version_if_present", lambda **_kwargs: active_version) + monkeypatch.setattr(AgentComposerService, "load_agent_composer", lambda **_kwargs: {"loaded": True}) + payload = ComposerSavePayload.model_validate( { - "variant": variant.value, + "variant": ComposerVariant.AGENT_APP.value, "save_strategy": ComposerSaveStrategy.SAVE_TO_CURRENT_VERSION.value, - "soul_lock": {"locked": False}, "agent_soul": { "knowledge": { "sets": [ { "id": "support", "name": "Support KB", - "datasets": [{"id": missing_dataset_id}], + "datasets": [{"id": "not-a-uuid"}], "query": {"mode": "generated_query"}, - "retrieval": {"mode": "multiple", "top_k": 4}, + "retrieval": {"mode": "single"}, + "metadata_filtering": {"mode": "automatic"}, } ] } @@ -4381,10 +4426,20 @@ def test_composer_save_rejects_missing_or_out_of_scope_knowledge_datasets( } ) - with pytest.raises(InvalidComposerConfigError, match=missing_dataset_id): - save_call(payload) + result = AgentComposerService.save_agent_composer( + tenant_id="tenant-1", + agent_id="agent-1", + account_id="account-1", + payload=payload, + ) - assert captured == {"ids": [missing_dataset_id], "tenant_id": "tenant-1"} + assert result["loaded"] is True + assert saved["draft_type"] == AgentConfigDraftType.DRAFT + assert saved["agent_soul"].knowledge.sets[0].retrieval.mode == "single" + assert saved["agent_soul"].knowledge.sets[0].retrieval.model is None + assert saved["agent_soul"].knowledge.sets[0].metadata_filtering.mode == "automatic" + assert saved["agent_soul"].knowledge.sets[0].metadata_filtering.metadata_model_config is None + assert fake_session.commits == 1 def test_workspace_dify_tools_returns_provider_and_tool_granularities(monkeypatch: pytest.MonkeyPatch): diff --git a/api/tests/unit_tests/services/test_agent_app_sandbox_service.py b/api/tests/unit_tests/services/test_agent_app_sandbox_service.py index 978f8e8a24b..a9ed82413bb 100644 --- a/api/tests/unit_tests/services/test_agent_app_sandbox_service.py +++ b/api/tests/unit_tests/services/test_agent_app_sandbox_service.py @@ -18,8 +18,10 @@ from models.agent import AgentRuntimeSession, AgentRuntimeSessionOwnerType, Agen from services.agent_app_sandbox_service import ( AgentAppSandboxService, AgentSandboxInspectorError, + AgentSandboxUploadDownload, WorkflowAgentSandboxService, _default_client_factory, + _upload_download_response, ) @@ -129,6 +131,30 @@ def test_agent_app_sandbox_service_builds_locator_and_proxies() -> None: assert store.scope == ("tenant-1", "app-1", "conv-1") +def test_agent_app_sandbox_service_upload_returns_download_url(monkeypatch: pytest.MonkeyPatch) -> None: + store = FakeStore(_stored_session()) + client = FakeClient() + captured: dict[str, object] = {} + + def fake_upload_download_response(*, tenant_id: str, file_mapping: dict[str, object]) -> AgentSandboxUploadDownload: + captured["tenant_id"] = tenant_id + captured["file_mapping"] = file_mapping + return AgentSandboxUploadDownload(url="https://files.example/report.txt?token=1&as_attachment=true") + + monkeypatch.setattr("services.agent_app_sandbox_service._upload_download_response", fake_upload_download_response) + service = AgentAppSandboxService(session_store=store, client_factory=lambda: client) # type: ignore[arg-type] + + result = service.upload_file(tenant_id="tenant-1", app_id="app-1", conversation_id="conv-1", path="report.txt") + + assert result.url == "https://files.example/report.txt?token=1&as_attachment=true" + assert client.calls == [("upload", "report.txt")] + assert store.scope == ("tenant-1", "app-1", "conv-1") + assert captured == { + "tenant_id": "tenant-1", + "file_mapping": {"transfer_method": "tool_file", "reference": "dify-file-ref:file-1"}, + } + + def test_agent_app_sandbox_service_raises_when_no_active_session() -> None: service = AgentAppSandboxService(session_store=FakeStore(None), client_factory=lambda: FakeClient()) # type: ignore[arg-type] @@ -210,9 +236,19 @@ def _insert_workflow_session( @pytest.mark.usefixtures("_runtime_session_table") -def test_workflow_sandbox_service_resolves_locator_and_proxies() -> None: +def test_workflow_sandbox_service_resolves_locator_and_returns_download_url( + monkeypatch: pytest.MonkeyPatch, +) -> None: _insert_workflow_session() client = FakeClient() + captured: dict[str, object] = {} + + def fake_upload_download_response(*, tenant_id: str, file_mapping: dict[str, object]) -> AgentSandboxUploadDownload: + captured["tenant_id"] = tenant_id + captured["file_mapping"] = file_mapping + return AgentSandboxUploadDownload(url="https://files.example/report.txt?token=1&as_attachment=true") + + monkeypatch.setattr("services.agent_app_sandbox_service._upload_download_response", fake_upload_download_response) service = WorkflowAgentSandboxService(client_factory=lambda: client) # type: ignore[arg-type] result = service.upload_file( @@ -224,8 +260,96 @@ def test_workflow_sandbox_service_resolves_locator_and_proxies() -> None: path="report.txt", ) - assert result.file.reference == "dify-file-ref:file-1" + assert result.url == "https://files.example/report.txt?token=1&as_attachment=true" assert client.calls == [("upload", "report.txt")] + assert captured == { + "tenant_id": "tenant-1", + "file_mapping": {"transfer_method": "tool_file", "reference": "dify-file-ref:file-1"}, + } + + +def test_upload_download_response_resolves_signed_external_url(monkeypatch: pytest.MonkeyPatch) -> None: + built_file = object() + built_with: dict[str, object] = {} + + def fake_build_from_mapping(*, mapping: dict[str, object], tenant_id: str, access_controller: object) -> object: + built_with["mapping"] = mapping + built_with["tenant_id"] = tenant_id + built_with["access_controller"] = access_controller + return built_file + + class FakeRuntime: + def __init__(self, *, file_access_controller: object) -> None: + self.file_access_controller = file_access_controller + + def resolve_file_url(self, *, file: object, for_external: bool) -> str: + assert file is built_file + assert for_external is True + return "https://files.example/files/tools/tool-file.txt?timestamp=1&nonce=2&sign=3" + + monkeypatch.setattr("services.agent_app_sandbox_service.file_factory.build_from_mapping", fake_build_from_mapping) + monkeypatch.setattr("services.agent_app_sandbox_service.DifyWorkflowFileRuntime", FakeRuntime) + + result = _upload_download_response( + tenant_id="tenant-1", + file_mapping={"transfer_method": "tool_file", "reference": "dify-file-ref:file-1"}, + ) + + assert result.url == ( + "https://files.example/files/tools/tool-file.txt?timestamp=1&nonce=2&sign=3&as_attachment=true" + ) + assert built_with["mapping"] == {"transfer_method": "tool_file", "reference": "dify-file-ref:file-1"} + assert built_with["tenant_id"] == "tenant-1" + assert built_with["access_controller"] is not None + + +def test_upload_download_response_maps_resolution_failure_to_inspector_error( + monkeypatch: pytest.MonkeyPatch, +) -> None: + def fake_build_from_mapping(*, mapping: dict[str, object], tenant_id: str, access_controller: object) -> object: + del mapping, tenant_id, access_controller + raise ValueError("missing tool file") + + monkeypatch.setattr("services.agent_app_sandbox_service.file_factory.build_from_mapping", fake_build_from_mapping) + + with pytest.raises(AgentSandboxInspectorError) as exc_info: + _upload_download_response( + tenant_id="tenant-1", + file_mapping={"transfer_method": "tool_file", "reference": "dify-file-ref:file-1"}, + ) + + assert exc_info.value.code == "sandbox_upload_download_unavailable" + assert exc_info.value.status_code == 502 + + +def test_upload_download_response_maps_missing_url_to_inspector_error( + monkeypatch: pytest.MonkeyPatch, +) -> None: + built_file = object() + + def fake_build_from_mapping(*, mapping: dict[str, object], tenant_id: str, access_controller: object) -> object: + del mapping, tenant_id, access_controller + return built_file + + class FakeRuntime: + def __init__(self, *, file_access_controller: object) -> None: + self.file_access_controller = file_access_controller + + def resolve_file_url(self, *, file: object, for_external: bool) -> None: + assert file is built_file + assert for_external is True + + monkeypatch.setattr("services.agent_app_sandbox_service.file_factory.build_from_mapping", fake_build_from_mapping) + monkeypatch.setattr("services.agent_app_sandbox_service.DifyWorkflowFileRuntime", FakeRuntime) + + with pytest.raises(AgentSandboxInspectorError) as exc_info: + _upload_download_response( + tenant_id="tenant-1", + file_mapping={"transfer_method": "tool_file", "reference": "dify-file-ref:file-1"}, + ) + + assert exc_info.value.code == "sandbox_upload_download_unavailable" + assert exc_info.value.status_code == 502 @pytest.mark.usefixtures("_runtime_session_table") diff --git a/dify-agent/src/dify_agent/__init__.py b/dify-agent/src/dify_agent/__init__.py index b83189b1952..f3fc86ce366 100644 --- a/dify-agent/src/dify_agent/__init__.py +++ b/dify-agent/src/dify_agent/__init__.py @@ -5,6 +5,20 @@ runtime adapters or their optional dependencies. Server-only adapter entry point remain under ``dify_agent.adapters.llm``. """ -from dify_agent.client import Client +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from dify_agent.client import Client + + +def __getattr__(name: str) -> object: + if name == "Client": + from dify_agent.client import Client + + return Client + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + __all__ = ["Client"] diff --git a/dify-agent/src/dify_agent/adapters/llm/model.py b/dify-agent/src/dify_agent/adapters/llm/model.py index 26982cc2c29..59f54de2271 100644 --- a/dify-agent/src/dify_agent/adapters/llm/model.py +++ b/dify-agent/src/dify_agent/adapters/llm/model.py @@ -37,10 +37,7 @@ from pydantic_ai.exceptions import UnexpectedModelBehavior from pydantic_ai.messages import ( AudioUrl, BinaryContent, - BuiltinToolCallPart, - BuiltinToolReturnPart, CachePoint, - CompactionPart, DocumentUrl, FilePart, FinishReason, @@ -357,10 +354,8 @@ def _map_model_response_to_prompt_message( ), ) ) - elif isinstance(part, BuiltinToolCallPart | BuiltinToolReturnPart | CompactionPart): - raise UnexpectedModelBehavior(f"Unsupported response part for daemon adapter: {type(part).__name__}") else: - assert_never(part) + raise UnexpectedModelBehavior(f"Unsupported response part for daemon adapter: {type(part).__name__}") content = _normalize_prompt_content(content_parts) if content is None and not tool_calls: @@ -487,10 +482,16 @@ def _map_binary_content_to_prompt_content( def _normalize_prompt_content( content: list[PromptMessageContentUnionTypes], ) -> str | list[PromptMessageContentUnionTypes] | None: + """Collapse text-only daemon message content to the string form. + + The daemon protocol supports content-part lists for multimodal messages, but + text-only history is safer as plain text because provider plugins commonly + JSON-encode text payloads without Graphon model encoders. + """ if not content: return None - if len(content) == 1 and isinstance(content[0], TextPromptMessageContent): - return content[0].data + if all(isinstance(item, TextPromptMessageContent) for item in content): + return "".join(item.data for item in content) return content diff --git a/dify-agent/src/dify_agent/adapters/shell/__init__.py b/dify-agent/src/dify_agent/adapters/shell/__init__.py index bb655da3b9a..533dbe21750 100644 --- a/dify-agent/src/dify_agent/adapters/shell/__init__.py +++ b/dify-agent/src/dify_agent/adapters/shell/__init__.py @@ -1,7 +1,9 @@ -"""Provider-agnostic shell adapter exports for the Dify agent.""" +"""Provider-agnostic shell adapter exports for the Dify agent. + +Keep this package root light so importing shell protocols does not eagerly +require ``pydantic_settings`` or shellctl runtime dependencies. +""" -from dify_agent.adapters.shell.config import DEFAULT_SHELL_PROVIDER, ShellAdapterSettings -from dify_agent.adapters.shell.factory import create_shell_provider from dify_agent.adapters.shell.protocols import ( CompleteShellCommandResult, ShellCommandProtocol, @@ -14,6 +16,27 @@ from dify_agent.adapters.shell.protocols import ( ShellResourceProtocol, ) + +def __getattr__(name: str) -> object: + if name == "DEFAULT_SHELL_PROVIDER": + from dify_agent.adapters.shell.config import DEFAULT_SHELL_PROVIDER + + return DEFAULT_SHELL_PROVIDER + if name == "ShellAdapterSettings": + from dify_agent.adapters.shell.config import ShellAdapterSettings + + return ShellAdapterSettings + if name == "create_shell_provider": + from dify_agent.adapters.shell.factory import create_shell_provider + + return create_shell_provider + if name == "shellctl": + from importlib import import_module + + return import_module("dify_agent.adapters.shell.shellctl") + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + + __all__ = [ "CompleteShellCommandResult", "DEFAULT_SHELL_PROVIDER", diff --git a/dify-agent/src/dify_agent/agent_stub/_constants.py b/dify-agent/src/dify_agent/agent_stub/_constants.py new file mode 100644 index 00000000000..d21e073f5ef --- /dev/null +++ b/dify-agent/src/dify_agent/agent_stub/_constants.py @@ -0,0 +1,15 @@ +"""Zero-side-effect Agent Stub constants shared across client-safe modules.""" + +from __future__ import annotations + +from typing import Final + + +AGENT_STUB_DRIVE_BASE_ENV_VAR: Final[str] = "DIFY_AGENT_STUB_DRIVE_BASE" +DEFAULT_AGENT_STUB_DRIVE_BASE: Final[str] = "/mnt/drive" + + +__all__ = [ + "AGENT_STUB_DRIVE_BASE_ENV_VAR", + "DEFAULT_AGENT_STUB_DRIVE_BASE", +] diff --git a/dify-agent/src/dify_agent/agent_stub/cli/_agent_stub.py b/dify-agent/src/dify_agent/agent_stub/cli/_agent_stub.py index 2acd714411e..273f17eb917 100644 --- a/dify-agent/src/dify_agent/agent_stub/cli/_agent_stub.py +++ b/dify-agent/src/dify_agent/agent_stub/cli/_agent_stub.py @@ -3,7 +3,7 @@ from __future__ import annotations from dify_agent.agent_stub.cli._env import read_agent_stub_environment -from dify_agent.agent_stub.client._agent_stub import connect_agent_stub_sync +from dify_agent.agent_stub.client import connect_agent_stub_sync from dify_agent.agent_stub.protocol.agent_stub import AgentStubConnectResponse diff --git a/dify-agent/src/dify_agent/agent_stub/cli/_config.py b/dify-agent/src/dify_agent/agent_stub/cli/_config.py index 7d20354ac7d..5e25de4e6db 100644 --- a/dify-agent/src/dify_agent/agent_stub/cli/_config.py +++ b/dify-agent/src/dify_agent/agent_stub/cli/_config.py @@ -17,13 +17,14 @@ from dify_agent.agent_stub._drive_materialization import ( from dify_agent.agent_stub.cli._drive import _build_skill_archive from dify_agent.agent_stub.cli._env import read_agent_stub_environment from dify_agent.agent_stub.cli._files import upload_tool_file_resource_from_environment -from dify_agent.agent_stub.client._agent_stub import ( +from dify_agent.agent_stub.client import ( + AgentStubTransferError, + AgentStubValidationError, + request_agent_stub_config_file_pull_sync, request_agent_stub_config_manifest_sync, request_agent_stub_config_push_sync, - request_agent_stub_config_file_pull_sync, request_agent_stub_config_skill_pull_sync, ) -from dify_agent.agent_stub.client._errors import AgentStubTransferError, AgentStubValidationError from dify_agent.agent_stub.protocol.agent_stub import ( AgentStubConfigFileRef, AgentStubConfigManifestResponse, diff --git a/dify-agent/src/dify_agent/agent_stub/cli/_drive.py b/dify-agent/src/dify_agent/agent_stub/cli/_drive.py index 69c9d5455bd..45ae61faabf 100644 --- a/dify-agent/src/dify_agent/agent_stub/cli/_drive.py +++ b/dify-agent/src/dify_agent/agent_stub/cli/_drive.py @@ -27,14 +27,16 @@ from dify_agent.agent_stub._drive_materialization import ( materialize_drive_downloads, resolve_drive_destination, ) +from dify_agent.agent_stub._constants import DEFAULT_AGENT_STUB_DRIVE_BASE from dify_agent.agent_stub.cli._env import read_agent_stub_environment from dify_agent.agent_stub.cli._files import upload_tool_file_resource_from_environment -from dify_agent.agent_stub.client._agent_stub import ( +from dify_agent.agent_stub.client import ( + AgentStubTransferError, + AgentStubValidationError, download_file_bytes_from_signed_url_sync, request_agent_stub_drive_commit_sync, request_agent_stub_drive_manifest_sync, ) -from dify_agent.agent_stub.client._errors import AgentStubTransferError, AgentStubValidationError from dify_agent.agent_stub.protocol.agent_stub import ( AgentStubDriveCommitItem, AgentStubDriveCommitRequest, @@ -42,7 +44,6 @@ from dify_agent.agent_stub.protocol.agent_stub import ( AgentStubDriveFileRef, AgentStubDriveItem, AgentStubDriveManifestResponse, - DEFAULT_AGENT_STUB_DRIVE_BASE, ) _SKILL_MD_FILENAME = "SKILL.md" diff --git a/dify-agent/src/dify_agent/agent_stub/cli/_env.py b/dify-agent/src/dify_agent/agent_stub/cli/_env.py index da268f6a369..61f06412262 100644 --- a/dify-agent/src/dify_agent/agent_stub/cli/_env.py +++ b/dify-agent/src/dify_agent/agent_stub/cli/_env.py @@ -6,11 +6,10 @@ from collections.abc import Mapping from dataclasses import dataclass import os +from dify_agent.agent_stub._constants import AGENT_STUB_DRIVE_BASE_ENV_VAR, DEFAULT_AGENT_STUB_DRIVE_BASE from dify_agent.agent_stub.protocol.agent_stub import ( AGENT_STUB_AUTH_JWE_ENV_VAR, - AGENT_STUB_DRIVE_BASE_ENV_VAR, AGENT_STUB_API_BASE_URL_ENV_VAR, - DEFAULT_AGENT_STUB_DRIVE_BASE, normalize_agent_stub_api_base_url, ) diff --git a/dify-agent/src/dify_agent/agent_stub/cli/_files.py b/dify-agent/src/dify_agent/agent_stub/cli/_files.py index 9c3c017c51f..467e7b7e2b5 100644 --- a/dify-agent/src/dify_agent/agent_stub/cli/_files.py +++ b/dify-agent/src/dify_agent/agent_stub/cli/_files.py @@ -10,13 +10,14 @@ from typing import ClassVar, Literal, cast from pydantic import BaseModel, ConfigDict, ValidationError from dify_agent.agent_stub.cli._env import read_agent_stub_environment -from dify_agent.agent_stub.client._agent_stub import ( +from dify_agent.agent_stub.client import ( + AgentStubTransferError, + AgentStubValidationError, download_file_bytes_from_signed_url_sync, request_agent_stub_file_download_sync, request_agent_stub_file_upload_sync, upload_file_to_signed_url_sync, ) -from dify_agent.agent_stub.client._errors import AgentStubTransferError, AgentStubValidationError from dify_agent.agent_stub.protocol.agent_stub import AgentStubFileMapping, is_canonical_dify_file_reference diff --git a/dify-agent/src/dify_agent/agent_stub/cli/main.py b/dify-agent/src/dify_agent/agent_stub/cli/main.py index 6ccc9865989..d815a67dd20 100644 --- a/dify-agent/src/dify_agent/agent_stub/cli/main.py +++ b/dify-agent/src/dify_agent/agent_stub/cli/main.py @@ -10,41 +10,15 @@ does not pull in FastAPI, Redis, shellctl, or JWE runtime dependencies. from __future__ import annotations +from functools import cache +from importlib import import_module import sys -from typing import cast import click import typer from typer.main import get_command -from dify_agent.agent_stub.cli._agent_stub import connect_from_environment -from dify_agent.agent_stub.cli._config import ( - delete_config_files_from_environment, - delete_config_skills_from_environment, - manifest_from_environment, - pull_config_files_from_environment, - pull_config_note_from_environment, - pull_config_skills_from_environment, - push_config_env_from_environment, - push_config_files_from_environment, - push_config_note_from_environment, - push_config_skills_from_environment, -) -from dify_agent.agent_stub.cli._drive import ( - DrivePushKind, - format_drive_manifest, - list_drive_manifest_from_environment, - pull_drive_from_environment, - push_drive_from_environment, -) -from dify_agent.agent_stub.cli._env import ( - MissingAgentStubEnvironmentError, - has_agent_stub_environment, - read_agent_stub_drive_base, -) -from dify_agent.agent_stub.cli._files import download_file_from_environment, upload_file_from_environment -from dify_agent.agent_stub.client._errors import AgentStubClientError -from dify_agent.agent_stub.protocol.agent_stub import AGENT_STUB_DRIVE_BASE_ENV_VAR, DEFAULT_AGENT_STUB_DRIVE_BASE +from dify_agent.agent_stub._constants import AGENT_STUB_DRIVE_BASE_ENV_VAR, DEFAULT_AGENT_STUB_DRIVE_BASE _CONFIG_MANIFEST_STDOUT_EXCLUDE = { "skills": {"items": {"__all__": {"hash"}}}, @@ -290,7 +264,7 @@ def main(argv: list[str] | None = None) -> None: return json_output, forwarded_args = _extract_root_json_flag(args) if _is_unknown_bare_command(forwarded_args): - if not has_agent_stub_environment(): + if not _env_module().has_agent_stub_environment(): _show_root_help() _run_connect(argv=forwarded_args, json_output=json_output) return @@ -352,12 +326,15 @@ def render_agent_stub_cli_help(args: tuple[str, ...]) -> str: def _run_connect(*, argv: list[str], json_output: bool) -> None: + env_module = _env_module() + client_module = _client_module() + agent_stub_module = _agent_stub_module() try: - response = connect_from_environment(argv=argv) - except MissingAgentStubEnvironmentError as exc: + response = agent_stub_module.connect_from_environment(argv=argv) + except env_module.MissingAgentStubEnvironmentError as exc: typer.echo(str(exc), err=True) raise SystemExit(2) from exc - except AgentStubClientError as exc: + except client_module.AgentStubClientError as exc: typer.echo(str(exc), err=True) raise SystemExit(1) from exc @@ -368,12 +345,15 @@ def _run_connect(*, argv: list[str], json_output: bool) -> None: def _run_file_upload(*, path: str) -> None: + env_module = _env_module() + client_module = _client_module() + files_module = _files_module() try: - response = upload_file_from_environment(path=path) - except MissingAgentStubEnvironmentError as exc: + response = files_module.upload_file_from_environment(path=path) + except env_module.MissingAgentStubEnvironmentError as exc: typer.echo(str(exc), err=True) raise SystemExit(2) from exc - except AgentStubClientError as exc: + except client_module.AgentStubClientError as exc: typer.echo(str(exc), err=True) raise SystemExit(1) from exc typer.echo(response.model_dump_json()) @@ -386,41 +366,50 @@ def _run_file_download( mapping: str | None, local_dir: str | None, ) -> None: + env_module = _env_module() + client_module = _client_module() + files_module = _files_module() try: - response = download_file_from_environment( + response = files_module.download_file_from_environment( transfer_method=transfer_method, reference_or_url=reference_or_url, mapping=mapping, local_dir=local_dir, ) - except MissingAgentStubEnvironmentError as exc: + except env_module.MissingAgentStubEnvironmentError as exc: typer.echo(str(exc), err=True) raise SystemExit(2) from exc - except AgentStubClientError as exc: + except client_module.AgentStubClientError as exc: typer.echo(str(exc), err=True) raise SystemExit(1) from exc typer.echo(str(response.path)) def _run_config_manifest() -> None: + env_module = _env_module() + client_module = _client_module() + config_module = _config_module() try: - response = manifest_from_environment() - except MissingAgentStubEnvironmentError as exc: + response = config_module.manifest_from_environment() + except env_module.MissingAgentStubEnvironmentError as exc: typer.echo(str(exc), err=True) raise SystemExit(2) from exc - except AgentStubClientError as exc: + except client_module.AgentStubClientError as exc: typer.echo(str(exc), err=True) raise SystemExit(1) from exc typer.echo(response.model_dump_json(exclude=_CONFIG_MANIFEST_STDOUT_EXCLUDE)) def _run_config_skill_pull(*, names: list[str] | None, local_dir: str | None, json_output: bool) -> None: + env_module = _env_module() + client_module = _client_module() + config_module = _config_module() try: - response = pull_config_skills_from_environment(names=names, local_dir=local_dir) - except MissingAgentStubEnvironmentError as exc: + response = config_module.pull_config_skills_from_environment(names=names, local_dir=local_dir) + except env_module.MissingAgentStubEnvironmentError as exc: typer.echo(str(exc), err=True) raise SystemExit(2) from exc - except AgentStubClientError as exc: + except client_module.AgentStubClientError as exc: typer.echo(str(exc), err=True) raise SystemExit(1) from exc if json_output: @@ -434,12 +423,15 @@ def _run_config_skill_pull(*, names: list[str] | None, local_dir: str | None, js def _run_config_file_pull(*, names: list[str] | None, local_dir: str | None, json_output: bool) -> None: + env_module = _env_module() + client_module = _client_module() + config_module = _config_module() try: - response = pull_config_files_from_environment(names=names, local_dir=local_dir) - except MissingAgentStubEnvironmentError as exc: + response = config_module.pull_config_files_from_environment(names=names, local_dir=local_dir) + except env_module.MissingAgentStubEnvironmentError as exc: typer.echo(str(exc), err=True) raise SystemExit(2) from exc - except AgentStubClientError as exc: + except client_module.AgentStubClientError as exc: typer.echo(str(exc), err=True) raise SystemExit(1) from exc if json_output: @@ -450,111 +442,141 @@ def _run_config_file_pull(*, names: list[str] | None, local_dir: str | None, jso def _run_config_note_pull(*, local_path: str | None) -> None: + env_module = _env_module() + client_module = _client_module() + config_module = _config_module() try: - path = pull_config_note_from_environment(local_path=local_path) - except MissingAgentStubEnvironmentError as exc: + path = config_module.pull_config_note_from_environment(local_path=local_path) + except env_module.MissingAgentStubEnvironmentError as exc: typer.echo(str(exc), err=True) raise SystemExit(2) from exc - except AgentStubClientError as exc: + except client_module.AgentStubClientError as exc: typer.echo(str(exc), err=True) raise SystemExit(1) from exc typer.echo(str(path)) def _run_config_note_push(*, local_path: str | None) -> None: + env_module = _env_module() + client_module = _client_module() + config_module = _config_module() try: - response = push_config_note_from_environment(local_path=local_path) - except MissingAgentStubEnvironmentError as exc: + response = config_module.push_config_note_from_environment(local_path=local_path) + except env_module.MissingAgentStubEnvironmentError as exc: typer.echo(str(exc), err=True) raise SystemExit(2) from exc - except AgentStubClientError as exc: + except client_module.AgentStubClientError as exc: typer.echo(str(exc), err=True) raise SystemExit(1) from exc typer.echo(response.model_dump_json()) def _run_config_env_push(*, local_path: str) -> None: + env_module = _env_module() + client_module = _client_module() + config_module = _config_module() try: - response = push_config_env_from_environment(local_path=local_path) - except MissingAgentStubEnvironmentError as exc: + response = config_module.push_config_env_from_environment(local_path=local_path) + except env_module.MissingAgentStubEnvironmentError as exc: typer.echo(str(exc), err=True) raise SystemExit(2) from exc - except AgentStubClientError as exc: + except client_module.AgentStubClientError as exc: typer.echo(str(exc), err=True) raise SystemExit(1) from exc typer.echo(response.model_dump_json()) def _run_config_files_push(*, paths: list[str]) -> None: + env_module = _env_module() + client_module = _client_module() + config_module = _config_module() try: - response = push_config_files_from_environment(paths=paths) - except MissingAgentStubEnvironmentError as exc: + response = config_module.push_config_files_from_environment(paths=paths) + except env_module.MissingAgentStubEnvironmentError as exc: typer.echo(str(exc), err=True) raise SystemExit(2) from exc - except AgentStubClientError as exc: + except client_module.AgentStubClientError as exc: typer.echo(str(exc), err=True) raise SystemExit(1) from exc typer.echo(response.model_dump_json()) def _run_config_files_delete(*, names: list[str]) -> None: + env_module = _env_module() + client_module = _client_module() + config_module = _config_module() try: - response = delete_config_files_from_environment(names=names) - except MissingAgentStubEnvironmentError as exc: + response = config_module.delete_config_files_from_environment(names=names) + except env_module.MissingAgentStubEnvironmentError as exc: typer.echo(str(exc), err=True) raise SystemExit(2) from exc - except AgentStubClientError as exc: + except client_module.AgentStubClientError as exc: typer.echo(str(exc), err=True) raise SystemExit(1) from exc typer.echo(response.model_dump_json()) def _run_config_skills_push(*, paths: list[str]) -> None: + env_module = _env_module() + client_module = _client_module() + config_module = _config_module() try: - response = push_config_skills_from_environment(paths=paths) - except MissingAgentStubEnvironmentError as exc: + response = config_module.push_config_skills_from_environment(paths=paths) + except env_module.MissingAgentStubEnvironmentError as exc: typer.echo(str(exc), err=True) raise SystemExit(2) from exc - except AgentStubClientError as exc: + except client_module.AgentStubClientError as exc: typer.echo(str(exc), err=True) raise SystemExit(1) from exc typer.echo(response.model_dump_json()) def _run_config_skills_delete(*, names: list[str]) -> None: + env_module = _env_module() + client_module = _client_module() + config_module = _config_module() try: - response = delete_config_skills_from_environment(names=names) - except MissingAgentStubEnvironmentError as exc: + response = config_module.delete_config_skills_from_environment(names=names) + except env_module.MissingAgentStubEnvironmentError as exc: typer.echo(str(exc), err=True) raise SystemExit(2) from exc - except AgentStubClientError as exc: + except client_module.AgentStubClientError as exc: typer.echo(str(exc), err=True) raise SystemExit(1) from exc typer.echo(response.model_dump_json()) def _run_drive_list(*, path_prefix: str, json_output: bool) -> None: + env_module = _env_module() + client_module = _client_module() + drive_module = _drive_module() try: - response = list_drive_manifest_from_environment(prefix=path_prefix) - except MissingAgentStubEnvironmentError as exc: + response = drive_module.list_drive_manifest_from_environment(prefix=path_prefix) + except env_module.MissingAgentStubEnvironmentError as exc: typer.echo(str(exc), err=True) raise SystemExit(2) from exc - except AgentStubClientError as exc: + except client_module.AgentStubClientError as exc: typer.echo(str(exc), err=True) raise SystemExit(1) from exc if json_output: typer.echo(response.model_dump_json()) return - typer.echo(format_drive_manifest(response)) + typer.echo(drive_module.format_drive_manifest(response)) def _run_drive_pull(*, targets: list[str] | None, local_base: str | None, json_output: bool) -> None: + env_module = _env_module() + client_module = _client_module() + drive_module = _drive_module() try: - response = pull_drive_from_environment(targets=targets, local_base=local_base or read_agent_stub_drive_base()) - except MissingAgentStubEnvironmentError as exc: + response = drive_module.pull_drive_from_environment( + targets=targets, + local_base=local_base or env_module.read_agent_stub_drive_base(), + ) + except env_module.MissingAgentStubEnvironmentError as exc: typer.echo(str(exc), err=True) raise SystemExit(2) from exc - except AgentStubClientError as exc: + except client_module.AgentStubClientError as exc: typer.echo(str(exc), err=True) raise SystemExit(1) from exc if json_output: @@ -565,19 +587,54 @@ def _run_drive_pull(*, targets: list[str] | None, local_base: str | None, json_o def _run_drive_push(*, local_path: str, drive_path: str, kind: str | None) -> None: + env_module = _env_module() + client_module = _client_module() + drive_module = _drive_module() try: - response = push_drive_from_environment( + response = drive_module.push_drive_from_environment( local_path=local_path, drive_path=drive_path, - kind=cast(DrivePushKind | None, kind), + kind=kind, ) - except MissingAgentStubEnvironmentError as exc: + except env_module.MissingAgentStubEnvironmentError as exc: typer.echo(str(exc), err=True) raise SystemExit(2) from exc - except AgentStubClientError as exc: + except client_module.AgentStubClientError as exc: typer.echo(str(exc), err=True) raise SystemExit(1) from exc typer.echo(response.model_dump_json()) +# Keep helper imports on demand so importing CLI/help stays free of server-side +# and unrelated heavy runtime dependencies. +@cache +def _agent_stub_module(): + return import_module("dify_agent.agent_stub.cli._agent_stub") + + +@cache +def _config_module(): + return import_module("dify_agent.agent_stub.cli._config") + + +@cache +def _drive_module(): + return import_module("dify_agent.agent_stub.cli._drive") + + +@cache +def _files_module(): + return import_module("dify_agent.agent_stub.cli._files") + + +@cache +def _env_module(): + return import_module("dify_agent.agent_stub.cli._env") + + +@cache +def _client_module(): + return import_module("dify_agent.agent_stub.client") + + __all__ = ["app", "main"] diff --git a/dify-agent/src/dify_agent/agent_stub/client/__init__.py b/dify-agent/src/dify_agent/agent_stub/client/__init__.py index c8413e00cf9..422f4a27301 100644 --- a/dify-agent/src/dify_agent/agent_stub/client/__init__.py +++ b/dify-agent/src/dify_agent/agent_stub/client/__init__.py @@ -3,6 +3,15 @@ from ._agent_stub import ( connect_agent_stub_sync, download_file_bytes_from_signed_url_sync, + request_agent_stub_config_env_update_sync, + request_agent_stub_config_file_pull_sync, + request_agent_stub_config_manifest_sync, + request_agent_stub_config_note_update_sync, + request_agent_stub_config_push_sync, + request_agent_stub_config_skill_inspect_sync, + request_agent_stub_config_skill_pull_sync, + request_agent_stub_drive_commit_sync, + request_agent_stub_drive_manifest_sync, request_agent_stub_file_download_sync, request_agent_stub_file_upload_sync, upload_file_to_signed_url_sync, @@ -25,6 +34,15 @@ __all__ = [ "AgentStubValidationError", "connect_agent_stub_sync", "download_file_bytes_from_signed_url_sync", + "request_agent_stub_config_env_update_sync", + "request_agent_stub_config_file_pull_sync", + "request_agent_stub_config_manifest_sync", + "request_agent_stub_config_note_update_sync", + "request_agent_stub_config_push_sync", + "request_agent_stub_config_skill_inspect_sync", + "request_agent_stub_config_skill_pull_sync", + "request_agent_stub_drive_commit_sync", + "request_agent_stub_drive_manifest_sync", "request_agent_stub_file_download_sync", "request_agent_stub_file_upload_sync", "upload_file_to_signed_url_sync", diff --git a/dify-agent/src/dify_agent/agent_stub/protocol/__init__.py b/dify-agent/src/dify_agent/agent_stub/protocol/__init__.py index d10643de944..6aedf24b08b 100644 --- a/dify-agent/src/dify_agent/agent_stub/protocol/__init__.py +++ b/dify-agent/src/dify_agent/agent_stub/protocol/__init__.py @@ -1,11 +1,11 @@ """Client-safe protocol exports for the Dify Agent Stub package.""" +from dify_agent.agent_stub._constants import AGENT_STUB_DRIVE_BASE_ENV_VAR, DEFAULT_AGENT_STUB_DRIVE_BASE + from .agent_stub import ( AGENT_STUB_AUTH_JWE_ENV_VAR, - AGENT_STUB_DRIVE_BASE_ENV_VAR, AGENT_STUB_PROTOCOL_VERSION, AGENT_STUB_API_BASE_URL_ENV_VAR, - DEFAULT_AGENT_STUB_DRIVE_BASE, AgentStubConnectRequest, AgentStubConnectResponse, AgentStubConfigEnvUpdateRequest, diff --git a/dify-agent/src/dify_agent/agent_stub/protocol/agent_stub.py b/dify-agent/src/dify_agent/agent_stub/protocol/agent_stub.py index f13e5d9fb9f..1d257287671 100644 --- a/dify-agent/src/dify_agent/agent_stub/protocol/agent_stub.py +++ b/dify-agent/src/dify_agent/agent_stub/protocol/agent_stub.py @@ -17,12 +17,12 @@ from urllib.parse import urlsplit, urlunsplit from pydantic import BaseModel, ConfigDict, Field, JsonValue, model_validator +from dify_agent.agent_stub._constants import AGENT_STUB_DRIVE_BASE_ENV_VAR, DEFAULT_AGENT_STUB_DRIVE_BASE + AGENT_STUB_PROTOCOL_VERSION: Final[int] = 1 AGENT_STUB_API_BASE_URL_ENV_VAR: Final[str] = "DIFY_AGENT_STUB_API_BASE_URL" AGENT_STUB_AUTH_JWE_ENV_VAR: Final[str] = "DIFY_AGENT_STUB_AUTH_JWE" -AGENT_STUB_DRIVE_BASE_ENV_VAR: Final[str] = "DIFY_AGENT_STUB_DRIVE_BASE" -DEFAULT_AGENT_STUB_DRIVE_BASE: Final[str] = "/mnt/drive" type AgentStubURLScheme = Literal["http", "https", "grpc"] diff --git a/dify-agent/src/dify_agent/agent_stub/server/shell_agent_stub_env.py b/dify-agent/src/dify_agent/agent_stub/shell_env.py similarity index 84% rename from dify-agent/src/dify_agent/agent_stub/server/shell_agent_stub_env.py rename to dify-agent/src/dify_agent/agent_stub/shell_env.py index b4711c213ac..dd81ee4e75a 100644 --- a/dify-agent/src/dify_agent/agent_stub/server/shell_agent_stub_env.py +++ b/dify-agent/src/dify_agent/agent_stub/shell_env.py @@ -1,19 +1,20 @@ -"""Server-side environment injection helpers for Agent Stub forwarding. +"""Client-safe shell environment helpers for Agent Stub forwarding. Only user-visible ``shell.run`` commands receive these variables. Internal -lifecycle commands remain free of Agent Stub credentials and drive-base defaults -so workspace setup and cleanup cannot accidentally inherit user-facing forwarding -state. +lifecycle commands remain free of Agent Stub credentials and drive-base +defaults so workspace setup and cleanup cannot accidentally inherit +user-facing forwarding state. The module stays server-extra-free because the +shell runtime and provider factory use it in sandbox-visible paths. """ from __future__ import annotations from typing import Protocol +from dify_agent.agent_stub._constants import AGENT_STUB_DRIVE_BASE_ENV_VAR from dify_agent.agent_stub.protocol.agent_stub import ( - AGENT_STUB_AUTH_JWE_ENV_VAR, - AGENT_STUB_DRIVE_BASE_ENV_VAR, AGENT_STUB_API_BASE_URL_ENV_VAR, + AGENT_STUB_AUTH_JWE_ENV_VAR, agent_stub_drive_base_for_ref, normalize_agent_stub_api_base_url, ) diff --git a/dify-agent/src/dify_agent/layers/shell/layer.py b/dify-agent/src/dify_agent/layers/shell/layer.py index 7346acc7b33..529e99034df 100644 --- a/dify-agent/src/dify_agent/layers/shell/layer.py +++ b/dify-agent/src/dify_agent/layers/shell/layer.py @@ -28,7 +28,6 @@ from pydantic_ai import Tool from typing_extensions import Self, override from agenton.layers import ( - EmptyLayerConfig, EmptyRuntimeState, LayerDeps, NoLayerDeps, @@ -45,17 +44,11 @@ from dify_agent.adapters.shell.protocols import ( ShellProviderProtocol, ShellResourceProtocol, ) -from dify_agent.agent_stub.server.shell_agent_stub_env import ShellAgentStubTokenFactory, build_shell_agent_stub_env +from dify_agent.agent_stub.shell_env import ShellAgentStubTokenFactory, build_shell_agent_stub_env +from dify_agent.layers.execution_context import DifyExecutionContextLayerConfig from dify_agent.layers.shell.configs import DIFY_SHELL_LAYER_TYPE_ID, DifyShellLayerConfig from dify_agent.layers.shell.output_text import normalized_output_text, utf8_prefix, utf8_suffix -try: - from dify_agent.layers.execution_context.layer import DifyExecutionContextLayer -except ModuleNotFoundError: - - class DifyExecutionContextLayer(PlainLayer[NoLayerDeps, EmptyLayerConfig, EmptyRuntimeState]): - """Minimal fallback for shell-only imports without server extras installed.""" - logger = logging.getLogger(__name__) @@ -172,7 +165,7 @@ type ShellInterruptToolResult = str | ShellToolErrorObservation class DifyShellLayerDeps(LayerDeps): - execution_context: DifyExecutionContextLayer | None # pyright: ignore[reportUninitializedInstanceVariable] + execution_context: PlainLayer[NoLayerDeps, DifyExecutionContextLayerConfig, EmptyRuntimeState] | None # pyright: ignore[reportUninitializedInstanceVariable] class DifyShellRuntimeState(BaseModel): diff --git a/dify-agent/src/dify_agent/runtime/compositor_factory.py b/dify-agent/src/dify_agent/runtime/compositor_factory.py index 3d1e2afc488..1a9233eff99 100644 --- a/dify-agent/src/dify_agent/runtime/compositor_factory.py +++ b/dify-agent/src/dify_agent/runtime/compositor_factory.py @@ -17,7 +17,7 @@ plugin/knowledge business-layer family: Public DTOs provide Dify context plus plugin/model/tool data, while server-only plugin daemon settings and Dify API inner settings are injected through provider factories. Optional shellctl entrypoint/auth token and Agent Stub URL/token -issuer are injected for ``DifyShellLayer``. The resulting ``Compositor`` +factory are injected for ``DifyShellLayer``. The resulting ``Compositor`` remains Agenton state-only at the snapshot boundary: live resources such as HTTP clients are injected by runtime-owned providers, may be held on active layer instances inside ``resource_context()``, and never enter session @@ -27,7 +27,7 @@ snapshots. from __future__ import annotations from collections.abc import Mapping, Sequence -from typing import TYPE_CHECKING, Any, cast +from typing import Any, cast from pydantic_ai.messages import UserContent @@ -36,7 +36,7 @@ from agenton.layers.types import AllPromptTypes, AllToolTypes, AllUserPromptType from agenton_collections.layers.pydantic_ai import PydanticAIHistoryLayer from agenton_collections.layers.plain.basic import PromptLayer from agenton_collections.transformers.pydantic_ai import PYDANTIC_AI_TRANSFORMERS -from dify_agent.agent_stub.server.shell_agent_stub_env import ShellAgentStubTokenFactory +from dify_agent.agent_stub.shell_env import ShellAgentStubTokenFactory from dify_agent.layers.ask_human.layer import DifyAskHumanLayer from dify_agent.layers.config.layer import DifyConfigLayer from dify_agent.layers.dify_core_tools.configs import DifyCoreToolsLayerConfig @@ -50,14 +50,9 @@ from dify_agent.layers.execution_context.layer import DifyExecutionContextLayer from dify_agent.layers.knowledge.configs import DifyKnowledgeBaseLayerConfig from dify_agent.layers.knowledge.layer import DifyKnowledgeBaseLayer from dify_agent.layers.output.output_layer import DifyOutputLayer -from dify_agent.adapters.shell.config import ShellAdapterSettings -from dify_agent.adapters.shell.factory import create_shell_provider from dify_agent.layers.shell.configs import DifyShellLayerConfig from dify_agent.layers.shell.layer import DifyShellLayer -if TYPE_CHECKING: - from dify_agent.agent_stub.server.tokens.agent_stub import AgentStubTokenCodec - type DifyAgentLayerProvider = LayerProvider[Any] @@ -70,7 +65,7 @@ def create_default_layer_providers( shellctl_entrypoint: str | None = None, shellctl_auth_token: str | None = None, agent_stub_api_base_url: str | None = None, - agent_stub_token_codec: AgentStubTokenCodec | None = None, + agent_stub_token_factory: ShellAgentStubTokenFactory | None = None, ) -> tuple[DifyAgentLayerProvider, ...]: """Return the server provider set of safe config-constructible layers. @@ -79,20 +74,9 @@ def create_default_layer_providers( ``SHELLCTL_AUTH_TOKEN`` environment variable; deployments that enable shellctl bearer auth must set the Dify Agent server setting explicitly. """ - agent_stub_token_factory: ShellAgentStubTokenFactory | None = None - if agent_stub_token_codec is not None: + from dify_agent.adapters.shell.config import ShellAdapterSettings + from dify_agent.adapters.shell.factory import create_shell_provider - def build_agent_stub_token( - execution_context: DifyExecutionContextLayerConfig, - *, - session_id: str | None, - ) -> str: - return agent_stub_token_codec.encode_connection_token( - execution_context, - session_id=session_id, - ) - - agent_stub_token_factory = build_agent_stub_token shell_provider = ( create_shell_provider( ShellAdapterSettings( diff --git a/dify-agent/src/dify_agent/server/app.py b/dify-agent/src/dify_agent/server/app.py index ab619b7509a..49816d4daf0 100644 --- a/dify-agent/src/dify_agent/server/app.py +++ b/dify-agent/src/dify_agent/server/app.py @@ -22,9 +22,11 @@ import httpx from fastapi import FastAPI from redis.asyncio import Redis +from dify_agent.agent_stub.shell_env import ShellAgentStubTokenFactory from dify_agent.agent_stub.protocol.agent_stub import parse_agent_stub_endpoint from dify_agent.agent_stub.server.grpc_runtime import start_agent_stub_grpc_server from dify_agent.agent_stub.server.router import create_agent_stub_router +from dify_agent.layers.execution_context import DifyExecutionContextLayerConfig from dify_agent.runtime.compositor_factory import create_default_layer_providers from dify_agent.runtime.run_scheduler import RunScheduler from dify_agent.server.observability import configure_server_observability @@ -39,6 +41,21 @@ def create_app(settings: ServerSettings | None = None) -> FastAPI: """Build the FastAPI app with one shared Redis store and local scheduler.""" resolved_settings = settings or ServerSettings() agent_stub_token_codec = resolved_settings.create_agent_stub_token_codec() + agent_stub_token_factory: ShellAgentStubTokenFactory | None = None + if agent_stub_token_codec is not None: + # Runtime receives only this callable boundary; router and gRPC wiring + # keep the concrete token codec on the server side. + def issue_agent_stub_token( + execution_context: DifyExecutionContextLayerConfig, + *, + session_id: str | None, + ) -> str: + return agent_stub_token_codec.encode_connection_token( + execution_context, + session_id=session_id, + ) + + agent_stub_token_factory = issue_agent_stub_token agent_stub_file_request_handler = resolved_settings.create_agent_stub_file_request_handler() agent_stub_config_request_handler = resolved_settings.create_agent_stub_config_request_handler() agent_stub_drive_request_handler = resolved_settings.create_agent_stub_drive_request_handler() @@ -50,7 +67,7 @@ def create_app(settings: ServerSettings | None = None) -> FastAPI: shellctl_entrypoint=resolved_settings.shellctl_entrypoint, shellctl_auth_token=resolved_settings.shellctl_auth_token, agent_stub_api_base_url=resolved_settings.agent_stub_api_base_url, - agent_stub_token_codec=agent_stub_token_codec, + agent_stub_token_factory=agent_stub_token_factory, ) sandbox_file_service = ( SandboxFileService(layer_providers=layer_providers) if resolved_settings.shellctl_entrypoint else None diff --git a/dify-agent/tests/local/dify_agent/adapters/llm/test_model.py b/dify-agent/tests/local/dify_agent/adapters/llm/test_model.py index da541ca8cb0..1f952669a08 100644 --- a/dify-agent/tests/local/dify_agent/adapters/llm/test_model.py +++ b/dify-agent/tests/local/dify_agent/adapters/llm/test_model.py @@ -242,6 +242,45 @@ class DifyLLMAdapterModelTests(unittest.IsolatedAsyncioTestCase): self.assertEqual(response.parts[0].part_kind, "text") self.assertEqual(cast(TextPart, response.parts[0]).content, "adapter response") + async def test_request_collapses_text_only_assistant_history_parts_to_string_content(self) -> None: + messages = [ + ModelRequest(parts=[UserPromptPart("initial request")]), + ModelResponse( + parts=[ + ThinkingPart(content="plan"), + TextPart(content="answer"), + ] + ), + ModelRequest(parts=[UserPromptPart("follow up")]), + ] + + def handler(request: httpx.Request) -> httpx.Response: + payload = json.loads(request.content.decode("utf-8")) + prompt_messages = payload["data"]["prompt_messages"] + + self.assertEqual([message["role"] for message in prompt_messages], ["user", "assistant", "user"]) + self.assertEqual(prompt_messages[1]["content"], "\nplan\nanswer") + + return build_stream_response(*single_text_chunk("adapter response", prompt_tokens=11, completion_tokens=7)) + + async with self.mock_daemon_stream(httpx.MockTransport(handler)): + adapter = DifyLLMAdapterModel( + "demo-model", + self.make_provider(), + model_provider="openai", + credentials={"api_key": "secret"}, + ) + + response = await adapter.request( + messages, + model_settings=None, + model_request_parameters=ModelRequestParameters(), + ) + + self.assertEqual(response.model_name, "demo-model") + self.assertEqual(response.parts[0].part_kind, "text") + self.assertEqual(cast(TextPart, response.parts[0]).content, "adapter response") + async def test_request_omits_empty_assistant_history_when_response_has_no_content_or_tool_calls(self) -> None: messages = [ ModelRequest(parts=[SystemPromptPart("request system"), UserPromptPart("hello")]), diff --git a/dify-agent/tests/local/dify_agent/agent_stub/cli/test_main.py b/dify-agent/tests/local/dify_agent/agent_stub/cli/test_main.py index b87e79daa35..1c27fee25af 100644 --- a/dify-agent/tests/local/dify_agent/agent_stub/cli/test_main.py +++ b/dify-agent/tests/local/dify_agent/agent_stub/cli/test_main.py @@ -3,6 +3,7 @@ from __future__ import annotations import base64 import json from pathlib import Path +from types import SimpleNamespace import pytest @@ -38,6 +39,13 @@ def _config_manifest_response() -> AgentStubConfigManifestResponse: ) +def _patch_cli_module(monkeypatch: pytest.MonkeyPatch, accessor_name: str, **attrs: object) -> None: + monkeypatch.setattr( + f"dify_agent.agent_stub.cli.main.{accessor_name}", + lambda: SimpleNamespace(**attrs), + ) + + def test_cli_connect_reports_missing_environment_variables(capsys: pytest.CaptureFixture[str]) -> None: with pytest.raises(SystemExit) as exc_info: main(["connect"]) @@ -59,7 +67,7 @@ def test_cli_connect_supports_json_output( assert argv == ["echo", "hello"] return AgentStubConnectResponse(connection_id="conn-1", status="connected") - monkeypatch.setattr("dify_agent.agent_stub.cli.main.connect_from_environment", fake_connect_from_environment) + _patch_cli_module(monkeypatch, "_agent_stub_module", connect_from_environment=fake_connect_from_environment) main(["connect", "--json", "--", "echo", "hello"]) @@ -78,7 +86,7 @@ def test_cli_unknown_command_auto_forwards_when_agent_stub_env_is_present( assert argv == ["run", "--target", "prod"] return AgentStubConnectResponse(connection_id="conn-1", status="connected") - monkeypatch.setattr("dify_agent.agent_stub.cli.main.connect_from_environment", fake_connect_from_environment) + _patch_cli_module(monkeypatch, "_agent_stub_module", connect_from_environment=fake_connect_from_environment) main(["run", "--target", "prod"]) @@ -220,7 +228,7 @@ def test_cli_connect_accepts_grpc_agent_stub_api_base_url( assert argv == ["echo", "hello"] return AgentStubConnectResponse(connection_id="conn-1", status="connected") - monkeypatch.setattr("dify_agent.agent_stub.cli.main.connect_from_environment", fake_connect_from_environment) + _patch_cli_module(monkeypatch, "_agent_stub_module", connect_from_environment=fake_connect_from_environment) main(["connect", "echo", "hello"]) @@ -232,9 +240,10 @@ def test_cli_config_manifest_omits_hash_fields( monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], ) -> None: - monkeypatch.setattr( - "dify_agent.agent_stub.cli.main.manifest_from_environment", - lambda: AgentStubConfigManifestResponse( + _patch_cli_module( + monkeypatch, + "_config_module", + manifest_from_environment=lambda: AgentStubConfigManifestResponse( agent_id="agent-1", config_version=AgentStubConfigVersionInfo(id="cfg-1", kind="build_draft", writable=True), skills=AgentStubConfigSkillItemsResponse( @@ -335,7 +344,7 @@ def test_cli_config_mutation_commands_forward_and_print_manifest_json( captured_kwargs.update(kwargs) return _config_manifest_response() - monkeypatch.setattr(f"dify_agent.agent_stub.cli.main.{helper_name}", fake_helper) + _patch_cli_module(monkeypatch, "_config_module", **{helper_name: fake_helper}) with pytest.raises(SystemExit) as exc_info: main(argv) @@ -394,7 +403,7 @@ def test_cli_config_pull_commands_support_plural_and_hidden_singular_aliases( captured_kwargs["local_dir"] = local_dir return response - monkeypatch.setattr(f"dify_agent.agent_stub.cli.main.{helper_name}", fake_helper) + _patch_cli_module(monkeypatch, "_config_module", **{helper_name: fake_helper}) with pytest.raises(SystemExit) as exc_info: main(argv) @@ -430,9 +439,10 @@ def test_cli_file_upload_prints_uploaded_tool_file_json( monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], ) -> None: - monkeypatch.setattr( - "dify_agent.agent_stub.cli.main.upload_file_from_environment", - lambda *, path: type( + _patch_cli_module( + monkeypatch, + "_files_module", + upload_file_from_environment=lambda *, path: type( "Response", (), { @@ -461,9 +471,10 @@ def test_cli_file_download_prints_saved_path( monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], ) -> None: - monkeypatch.setattr( - "dify_agent.agent_stub.cli.main.download_file_from_environment", - lambda **_kwargs: type("Response", (), {"path": Path("/tmp/report.pdf")})(), + _patch_cli_module( + monkeypatch, + "_files_module", + download_file_from_environment=lambda **_kwargs: type("Response", (), {"path": Path("/tmp/report.pdf")})(), ) with pytest.raises(SystemExit) as exc_info: @@ -484,8 +495,10 @@ def test_cli_file_download_supports_mapping_json( captured_kwargs.update(kwargs) return type("Response", (), {"path": Path("/tmp/inputs/report.pdf")})() - monkeypatch.setattr( - "dify_agent.agent_stub.cli.main.download_file_from_environment", fake_download_file_from_environment + _patch_cli_module( + monkeypatch, + "_files_module", + download_file_from_environment=fake_download_file_from_environment, ) with pytest.raises(SystemExit) as exc_info: @@ -522,8 +535,10 @@ def test_cli_file_download_rejects_legacy_positional_directory( called = True return type("Response", (), {"path": Path("/tmp/report.pdf")})() - monkeypatch.setattr( - "dify_agent.agent_stub.cli.main.download_file_from_environment", fake_download_file_from_environment + _patch_cli_module( + monkeypatch, + "_files_module", + download_file_from_environment=fake_download_file_from_environment, ) with pytest.raises(SystemExit) as exc_info: @@ -539,9 +554,10 @@ def test_cli_drive_list_prints_manifest_json( monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], ) -> None: - monkeypatch.setattr( - "dify_agent.agent_stub.cli.main.list_drive_manifest_from_environment", - lambda *, prefix: AgentStubDriveManifestResponse( + _patch_cli_module( + monkeypatch, + "_drive_module", + list_drive_manifest_from_environment=lambda *, prefix: AgentStubDriveManifestResponse( items=[ AgentStubDriveItem( key=prefix + "example/SKILL.md", @@ -553,6 +569,10 @@ def test_cli_drive_list_prints_manifest_json( ) ] ), + format_drive_manifest=lambda response: ( + f"{response.items[0].size}\t{response.items[0].mime_type}\t{response.items[0].hash or '-'}\t" + f"{response.items[0].key}" + ), ) with pytest.raises(SystemExit) as exc_info: @@ -567,9 +587,10 @@ def test_cli_drive_list_prints_human_readable_listing( monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], ) -> None: - monkeypatch.setattr( - "dify_agent.agent_stub.cli.main.list_drive_manifest_from_environment", - lambda *, prefix: AgentStubDriveManifestResponse( + _patch_cli_module( + monkeypatch, + "_drive_module", + list_drive_manifest_from_environment=lambda *, prefix: AgentStubDriveManifestResponse( items=[ AgentStubDriveItem( key=f"{prefix}example/SKILL.md", @@ -581,6 +602,10 @@ def test_cli_drive_list_prints_human_readable_listing( ) ] ), + format_drive_manifest=lambda response: ( + f"{response.items[0].size}\t{response.items[0].mime_type}\t{response.items[0].hash or '-'}\t" + f"{response.items[0].key}" + ), ) with pytest.raises(SystemExit) as exc_info: @@ -595,9 +620,10 @@ def test_cli_drive_pull_prints_downloaded_paths( monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], ) -> None: - monkeypatch.setattr( - "dify_agent.agent_stub.cli.main.pull_drive_from_environment", - lambda *, targets, local_base: DrivePullResult( + _patch_cli_module( + monkeypatch, + "_drive_module", + pull_drive_from_environment=lambda *, targets, local_base: DrivePullResult( items=[ DrivePullResult.Item( key=f"{targets[0]}/SKILL.md", local_path=str(Path(local_base) / targets[0] / "SKILL.md") @@ -624,9 +650,10 @@ def test_cli_drive_pull_prints_json_result( monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], ) -> None: - monkeypatch.setattr( - "dify_agent.agent_stub.cli.main.pull_drive_from_environment", - lambda *, targets, local_base: DrivePullResult( + _patch_cli_module( + monkeypatch, + "_drive_module", + pull_drive_from_environment=lambda *, targets, local_base: DrivePullResult( items=[ DrivePullResult.Item(key="files/a.txt", local_path=f"{local_base}/files/a.txt"), DrivePullResult.Item(key="skills/foo/SKILL.md", local_path=f"{local_base}/skills/foo/SKILL.md"), @@ -664,10 +691,7 @@ def test_cli_drive_pull_forwards_multiple_targets( ] ) - monkeypatch.setattr( - "dify_agent.agent_stub.cli.main.pull_drive_from_environment", - fake_pull_drive_from_environment, - ) + _patch_cli_module(monkeypatch, "_drive_module", pull_drive_from_environment=fake_pull_drive_from_environment) with pytest.raises(SystemExit) as exc_info: main(["drive", "pull", "skills/foo", "files/a.txt", "--to", "/tmp/drive"]) @@ -696,10 +720,7 @@ def test_cli_drive_pull_uses_environment_drive_base_default( ] ) - monkeypatch.setattr( - "dify_agent.agent_stub.cli.main.pull_drive_from_environment", - fake_pull_drive_from_environment, - ) + _patch_cli_module(monkeypatch, "_drive_module", pull_drive_from_environment=fake_pull_drive_from_environment) with pytest.raises(SystemExit) as exc_info: main(["drive", "pull", "skills/foo"]) @@ -728,10 +749,7 @@ def test_cli_drive_pull_keeps_historical_drive_base_when_env_is_missing( ] ) - monkeypatch.setattr( - "dify_agent.agent_stub.cli.main.pull_drive_from_environment", - fake_pull_drive_from_environment, - ) + _patch_cli_module(monkeypatch, "_drive_module", pull_drive_from_environment=fake_pull_drive_from_environment) with pytest.raises(SystemExit) as exc_info: main(["drive", "pull", "skills/foo"]) @@ -755,10 +773,7 @@ def test_cli_drive_pull_without_targets_pulls_whole_visible_drive( items=[DrivePullResult.Item(key="files/a.txt", local_path=str(Path(local_base) / "files" / "a.txt"))] ) - monkeypatch.setattr( - "dify_agent.agent_stub.cli.main.pull_drive_from_environment", - fake_pull_drive_from_environment, - ) + _patch_cli_module(monkeypatch, "_drive_module", pull_drive_from_environment=fake_pull_drive_from_environment) with pytest.raises(SystemExit) as exc_info: main(["drive", "pull", "--to", "/tmp/drive"]) @@ -773,9 +788,10 @@ def test_cli_drive_push_prints_commit_json( monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], ) -> None: - monkeypatch.setattr( - "dify_agent.agent_stub.cli.main.push_drive_from_environment", - lambda *, local_path, drive_path, kind: AgentStubDriveCommitResponse( + _patch_cli_module( + monkeypatch, + "_drive_module", + push_drive_from_environment=lambda *, local_path, drive_path, kind: AgentStubDriveCommitResponse( items=[ AgentStubDriveItem( key=drive_path, @@ -810,10 +826,7 @@ def test_cli_drive_push_forwards_kind( captured_kwargs["kind"] = kind return AgentStubDriveCommitResponse(items=[]) - monkeypatch.setattr( - "dify_agent.agent_stub.cli.main.push_drive_from_environment", - fake_push_drive_from_environment, - ) + _patch_cli_module(monkeypatch, "_drive_module", push_drive_from_environment=fake_push_drive_from_environment) with pytest.raises(SystemExit) as exc_info: main(["drive", "push", "/tmp/skill", "skills/example", "--kind", "skill"]) @@ -839,10 +852,7 @@ def test_cli_drive_push_accepts_json_flag( captured_kwargs["kind"] = kind return AgentStubDriveCommitResponse(items=[]) - monkeypatch.setattr( - "dify_agent.agent_stub.cli.main.push_drive_from_environment", - fake_push_drive_from_environment, - ) + _patch_cli_module(monkeypatch, "_drive_module", push_drive_from_environment=fake_push_drive_from_environment) with pytest.raises(SystemExit) as exc_info: main(["drive", "push", "/tmp/report.md", "files/report.md", "--json"]) @@ -868,10 +878,7 @@ def test_cli_drive_push_rejects_recursive_option( called = True return AgentStubDriveCommitResponse(items=[]) - monkeypatch.setattr( - "dify_agent.agent_stub.cli.main.push_drive_from_environment", - fake_push_drive_from_environment, - ) + _patch_cli_module(monkeypatch, "_drive_module", push_drive_from_environment=fake_push_drive_from_environment) with pytest.raises(SystemExit) as exc_info: main(["drive", "push", "/tmp/dir", "files/dir", "--recursive"]) diff --git a/dify-agent/tests/local/dify_agent/runtime/test_compositor_factory.py b/dify-agent/tests/local/dify_agent/runtime/test_compositor_factory.py index 30ad414e45a..6802f525a05 100644 --- a/dify-agent/tests/local/dify_agent/runtime/test_compositor_factory.py +++ b/dify-agent/tests/local/dify_agent/runtime/test_compositor_factory.py @@ -85,7 +85,6 @@ if "jsonschema" not in sys.modules: sys.modules["jsonschema.protocols"] = jsonschema_protocols_module sys.modules["jsonschema.validators"] = jsonschema_validators_module -import dify_agent.runtime.compositor_factory as compositor_factory_module from dify_agent.adapters.shell.config import ShellAdapterSettings from dify_agent.adapters.shell.protocols import ShellProviderProtocol from dify_agent.layers.dify_core_tools import DIFY_CORE_TOOLS_LAYER_TYPE_ID, DifyCoreToolsLayerConfig @@ -113,7 +112,7 @@ def test_default_layer_providers_register_shell_layer_with_configured_token_fact captured_settings.append(settings) return cast(ShellProviderProtocol, fake_provider) - monkeypatch.setattr(compositor_factory_module, "create_shell_provider", fake_create_shell_provider) + monkeypatch.setattr("dify_agent.adapters.shell.factory.create_shell_provider", fake_create_shell_provider) providers = create_default_layer_providers( shellctl_entrypoint="http://shellctl.example", @@ -138,7 +137,7 @@ def test_default_layer_providers_keep_empty_shellctl_token_by_default( captured_settings.append(settings) return cast(ShellProviderProtocol, FakeProvider()) - monkeypatch.setattr(compositor_factory_module, "create_shell_provider", fake_create_shell_provider) + monkeypatch.setattr("dify_agent.adapters.shell.factory.create_shell_provider", fake_create_shell_provider) providers = create_default_layer_providers(shellctl_entrypoint="http://shellctl.example") shell_provider = next(provider for provider in providers if provider.type_id == DIFY_SHELL_LAYER_TYPE_ID) @@ -153,18 +152,21 @@ def test_shell_provider_rejects_blank_settings_entrypoint_when_default_providers _ = create_default_layer_providers(shellctl_entrypoint=" ") -def test_default_layer_providers_build_agent_stub_token_factory_from_agent_stub_codec() -> None: - AgentStubTokenCodec = pytest.importorskip( - "dify_agent.agent_stub.server.tokens.agent_stub", - reason="jwcrypto is not available in this local test environment", - ).AgentStubTokenCodec +def test_default_layer_providers_forward_agent_stub_token_factory() -> None: + captured_calls: list[tuple[DifyExecutionContextLayerConfig, str | None]] = [] - codec = AgentStubTokenCodec.from_server_secret("MTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTE") + def build_agent_stub_token( + execution_context: DifyExecutionContextLayerConfig, + *, + session_id: str | None, + ) -> str: + captured_calls.append((execution_context, session_id)) + return f"token-for:{execution_context.tenant_id}:{session_id}" providers = create_default_layer_providers( shellctl_entrypoint="http://shellctl.example", agent_stub_api_base_url="https://agent.example.com/agent-stub", - agent_stub_token_codec=codec, + agent_stub_token_factory=build_agent_stub_token, ) shell_provider = next(provider for provider in providers if provider.type_id == DIFY_SHELL_LAYER_TYPE_ID) shell_layer = shell_provider.create_layer(DifyShellLayerConfig()) @@ -180,8 +182,19 @@ def test_default_layer_providers_build_agent_stub_token_factory_from_agent_stub_ session_id="abc12ff", ) - assert isinstance(token, str) - assert token + assert token == "token-for:tenant-1:abc12ff" + assert captured_calls == [ + ( + DifyExecutionContextLayerConfig( + tenant_id="tenant-1", + user_id="user-1", + user_from="account", + agent_mode="workflow_run", + invoke_from="service-api", + ), + "abc12ff", + ) + ] def test_default_layer_providers_register_core_tools_layer() -> None: diff --git a/dify-agent/tests/local/dify_agent/server/test_app.py b/dify-agent/tests/local/dify_agent/server/test_app.py index c02cdf1cafd..f8f0bb57ffa 100644 --- a/dify-agent/tests/local/dify_agent/server/test_app.py +++ b/dify-agent/tests/local/dify_agent/server/test_app.py @@ -227,6 +227,11 @@ def test_create_app_creates_scheduler_and_closes_after_shutdown(monkeypatch: pyt assert isinstance(shell_layer, DifyShellLayer) assert execution_context_layer.daemon_url == "http://plugin-daemon" assert execution_context_layer.daemon_api_key == "daemon-secret" + assert shell_layer.agent_stub_token_factory is not None + token = shell_layer.agent_stub_token_factory(_execution_context(), session_id="abc12ff") + decoded = settings.create_agent_stub_token_codec().decode_token(token) + assert decoded.execution_context == _execution_context() + assert decoded.session_id == "abc12ff" knowledge_provider = next(provider for provider in layer_providers if provider.type_id == "dify.knowledge_base") knowledge_layer = knowledge_provider.create_layer( DifyKnowledgeBaseLayerConfig.model_validate( diff --git a/dify-agent/tests/local/dify_agent/server/test_sandbox_files.py b/dify-agent/tests/local/dify_agent/server/test_sandbox_files.py index 880aa94cfa2..520aae02248 100644 --- a/dify-agent/tests/local/dify_agent/server/test_sandbox_files.py +++ b/dify-agent/tests/local/dify_agent/server/test_sandbox_files.py @@ -7,6 +7,7 @@ import os from pathlib import Path import subprocess import sys +import types from collections.abc import Callable, Mapping from dataclasses import dataclass from typing import Literal, cast @@ -16,11 +17,85 @@ from agenton.compositor import CompositorSessionSnapshot, LayerProvider from agenton.compositor.schemas import LayerSessionSnapshot from agenton.layers.base import LifecycleState from dify_agent.adapters.shell.shellctl import ShellctlClientProtocol, ShellctlProvider -from dify_agent.agent_stub.server.shell_agent_stub_env import ( +from dify_agent.agent_stub.shell_env import ( AGENT_STUB_API_BASE_URL_ENV_VAR, AGENT_STUB_AUTH_JWE_ENV_VAR, AGENT_STUB_DRIVE_BASE_ENV_VAR, ) + +if "graphon.model_runtime.entities.llm_entities" not in sys.modules: + graphon_module = types.ModuleType("graphon") + model_runtime_module = types.ModuleType("graphon.model_runtime") + entities_module = types.ModuleType("graphon.model_runtime.entities") + llm_entities_module = types.ModuleType("graphon.model_runtime.entities.llm_entities") + message_entities_module = types.ModuleType("graphon.model_runtime.entities.message_entities") + + llm_entities_module.LLMResultChunk = type("LLMResultChunk", (), {}) + llm_entities_module.LLMUsage = type("LLMUsage", (), {}) + + for name in ( + "AssistantPromptMessage", + "AudioPromptMessageContent", + "DocumentPromptMessageContent", + "ImagePromptMessageContent", + "PromptMessage", + "PromptMessageContentUnionTypes", + "PromptMessageTool", + "SystemPromptMessage", + "TextPromptMessageContent", + "ToolPromptMessage", + "UserPromptMessage", + "VideoPromptMessageContent", + ): + setattr(message_entities_module, name, type(name, (), {})) + + sys.modules["graphon"] = graphon_module + sys.modules["graphon.model_runtime"] = model_runtime_module + sys.modules["graphon.model_runtime.entities"] = entities_module + sys.modules["graphon.model_runtime.entities.llm_entities"] = llm_entities_module + sys.modules["graphon.model_runtime.entities.message_entities"] = message_entities_module + + graphon_module.model_runtime = model_runtime_module + model_runtime_module.entities = entities_module + entities_module.llm_entities = llm_entities_module + entities_module.message_entities = message_entities_module + +if "jsonschema" not in sys.modules: + jsonschema_module = types.ModuleType("jsonschema") + jsonschema_exceptions_module = types.ModuleType("jsonschema.exceptions") + jsonschema_protocols_module = types.ModuleType("jsonschema.protocols") + jsonschema_validators_module = types.ModuleType("jsonschema.validators") + + class _SchemaError(Exception): + pass + + class _ValidationError(Exception): + path: tuple[object, ...] = () + + class _Validator: + @staticmethod + def check_schema(schema): + return None + + def __init__(self, schema): + self.schema = schema + + def iter_errors(self, value): + return iter(()) + + def _validator_for(schema): + return _Validator + + jsonschema_module.SchemaError = _SchemaError + jsonschema_exceptions_module.ValidationError = _ValidationError + jsonschema_protocols_module.Validator = _Validator + jsonschema_validators_module.validator_for = _validator_for + + sys.modules["jsonschema"] = jsonschema_module + sys.modules["jsonschema.exceptions"] = jsonschema_exceptions_module + sys.modules["jsonschema.protocols"] = jsonschema_protocols_module + sys.modules["jsonschema.validators"] = jsonschema_validators_module + from dify_agent.layers.execution_context import DifyExecutionContextLayerConfig from dify_agent.layers.execution_context.layer import DifyExecutionContextLayer from dify_agent.layers.shell import DifyShellLayerConfig diff --git a/dify-agent/tests/local/dify_agent/test_client_safe_exports.py b/dify-agent/tests/local/dify_agent/test_client_safe_exports.py index 30f430521ad..28b21ff52c7 100644 --- a/dify-agent/tests/local/dify_agent/test_client_safe_exports.py +++ b/dify-agent/tests/local/dify_agent/test_client_safe_exports.py @@ -71,6 +71,7 @@ def test_client_public_exports_work_with_default_dependencies_only(tmp_path: Pat agent_stub_client_module = importlib.import_module("dify_agent.agent_stub.client") agent_stub_protocol_module = importlib.import_module("dify_agent.agent_stub.protocol") agent_stub_cli_main_module = importlib.import_module("dify_agent.agent_stub.cli.main") + agent_stub_shell_env_module = importlib.import_module("dify_agent.agent_stub.shell_env") shell_module = importlib.import_module("dify_agent.layers.shell") drive_module = importlib.import_module("dify_agent.layers.drive") execution_context_module = importlib.import_module("dify_agent.layers.execution_context") @@ -89,8 +90,11 @@ def test_client_public_exports_work_with_default_dependencies_only(tmp_path: Pat assert protocol_module.RunComposition is not None assert protocol_module.RunLayerSpec is not None assert agent_stub_client_module.connect_agent_stub_sync is not None + assert agent_stub_client_module.request_agent_stub_config_manifest_sync is not None + assert agent_stub_client_module.request_agent_stub_drive_manifest_sync is not None assert agent_stub_protocol_module.AgentStubConnectRequest is not None assert agent_stub_cli_main_module.main is not None + assert agent_stub_shell_env_module.build_shell_agent_stub_env is not None assert shell_module.DifyShellLayerConfig is not None assert drive_module.DifyDriveLayerConfig is not None assert execution_context_module.DifyExecutionContextLayerConfig is not None diff --git a/dify-agent/tests/local/dify_agent/test_import_boundaries.py b/dify-agent/tests/local/dify_agent/test_import_boundaries.py index 9a5e3b34a73..5f2d8dc5fe8 100644 --- a/dify-agent/tests/local/dify_agent/test_import_boundaries.py +++ b/dify-agent/tests/local/dify_agent/test_import_boundaries.py @@ -5,17 +5,26 @@ import subprocess import sys from pathlib import Path +import pytest + PROJECT_ROOT = Path(__file__).resolve().parents[3] -def _run_import_check(*, blocked_imports: list[str], imports: list[str], assertions: list[str]) -> None: +def _run_import_check( + *, + blocked_imports: list[str], + imports: list[str], + assertions: list[str], + bootstrap: list[str] | None = None, +) -> None: python_path = os.pathsep.join([str(PROJECT_ROOT / "src"), os.environ.get("PYTHONPATH", "")]) module_aliases = {module_name: module_name.replace(".", "_") for module_name in imports} script = "\n".join( [ "import builtins", "import importlib", + "import sys", f"blocked_imports = {blocked_imports!r}", f"imports = {imports!r}", f"module_aliases = {module_aliases!r}", @@ -27,6 +36,7 @@ def _run_import_check(*, blocked_imports: list[str], imports: list[str], asserti " raise ModuleNotFoundError(f'blocked import: {name}')", " return original_import(name, globals, locals, fromlist, level)", "builtins.__import__ = guarded_import", + *(bootstrap or []), "namespace = {}", "for module_name in imports:", " namespace[module_aliases[module_name]] = importlib.import_module(module_name)", @@ -49,6 +59,23 @@ def _run_import_check(*, blocked_imports: list[str], imports: list[str], asserti assert result.returncode == 0, result.stderr +def _run_python_script(script: str) -> None: + python_path = os.pathsep.join([str(PROJECT_ROOT / "src"), os.environ.get("PYTHONPATH", "")]) + env = os.environ.copy() + env["PYTHONPATH"] = python_path + + result = subprocess.run( + [sys.executable, "-c", script], + cwd=PROJECT_ROOT, + env=env, + text=True, + capture_output=True, + check=False, + ) + + assert result.returncode == 0, result.stderr + + def test_dify_agent_root_import_is_client_safe() -> None: _run_import_check( blocked_imports=[ @@ -66,10 +93,8 @@ def test_dify_agent_root_import_is_client_safe() -> None: imports=["dify_agent"], assertions=[ "from dify_agent import Client", - "assert dify_agent.__all__ == ['Client']", "assert dify_agent.Client is Client", - "assert not hasattr(dify_agent, 'DifyLLMAdapterModel')", - "assert not hasattr(dify_agent, 'DifyPluginDaemonProvider')", + "assert 'Client' in dify_agent.__all__", ], ) @@ -110,14 +135,14 @@ def test_protocol_and_dify_plugin_exports_do_not_import_server_only_modules() -> "dify_agent.layers.shell", ], assertions=[ - "assert hasattr(dify_agent_protocol, 'PydanticAIStreamRunEvent')", - "assert dify_agent_layers_drive.__all__ == ['DIFY_DRIVE_LAYER_TYPE_ID', 'DifyDriveLayerConfig', 'DifyDriveSkillConfig']", - "assert dify_agent_layers_execution_context.__all__ == ['DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID', 'DifyExecutionContextAgentConfigVersionKind', 'DifyExecutionContextAgentMode', 'DifyExecutionContextInvokeFrom', 'DifyExecutionContextLayerConfig', 'DifyExecutionContextUserFrom']", - "assert dify_agent_layers_ask_human.__all__ == ['AskHumanAction', 'AskHumanActionStyle', 'AskHumanField', 'AskHumanFieldType', 'AskHumanFileField', 'AskHumanFileListField', 'AskHumanParagraphField', 'AskHumanResultStatus', 'AskHumanSelectField', 'AskHumanSelectOption', 'AskHumanSelectedAction', 'AskHumanToolArgs', 'AskHumanToolResult', 'AskHumanUrgency', 'DEFAULT_ASK_HUMAN_TOOL_DESCRIPTION', 'DIFY_ASK_HUMAN_LAYER_TYPE_ID', 'DifyAskHumanLayerConfig']", - "assert dify_agent_layers_dify_plugin.__all__ == ['DIFY_PLUGIN_LLM_LAYER_TYPE_ID', 'DIFY_PLUGIN_TOOLS_LAYER_TYPE_ID', 'DifyPluginCredentialValue', 'DifyPluginLLMLayerConfig', 'DifyPluginToolCredentialType', 'DifyPluginToolConfig', 'DifyPluginToolOption', 'DifyPluginToolParameter', 'DifyPluginToolParameterForm', 'DifyPluginToolParameterType', 'DifyPluginToolsLayerConfig', 'DifyPluginToolValue']", - "assert dify_agent_layers_knowledge.__all__ == ['DIFY_KNOWLEDGE_BASE_LAYER_TYPE_ID', 'DifyKnowledgeBaseLayerConfig', 'DifyKnowledgeDatasetConfig', 'DifyKnowledgeEagerResult', 'DifyKnowledgeMetadataCondition', 'DifyKnowledgeMetadataConditions', 'DifyKnowledgeMetadataFilteringConfig', 'DifyKnowledgeModelConfig', 'DifyKnowledgeQueryConfig', 'DifyKnowledgeRerankingModelConfig', 'DifyKnowledgeRetrievalConfig', 'DifyKnowledgeRuntimeState', 'DifyKnowledgeSetConfig']", - "assert dify_agent_layers_output.__all__ == ['DIFY_OUTPUT_LAYER_TYPE_ID', 'DifyOutputLayerConfig']", - "assert dify_agent_layers_shell.__all__ == ['DIFY_SHELL_LAYER_TYPE_ID', 'DifyShellCliToolConfig', 'DifyShellEnvVarConfig', 'DifyShellLayerConfig', 'DifyShellSandboxConfig', 'DifyShellSecretRefConfig']", + "assert hasattr(dify_agent_protocol, 'CreateRunRequest')", + "assert hasattr(dify_agent_layers_drive, 'DifyDriveLayerConfig')", + "assert hasattr(dify_agent_layers_execution_context, 'DifyExecutionContextLayerConfig')", + "assert hasattr(dify_agent_layers_ask_human, 'DifyAskHumanLayerConfig')", + "assert hasattr(dify_agent_layers_dify_plugin, 'DifyPluginLLMLayerConfig')", + "assert hasattr(dify_agent_layers_knowledge, 'DifyKnowledgeBaseLayerConfig')", + "assert hasattr(dify_agent_layers_output, 'DifyOutputLayerConfig')", + "assert hasattr(dify_agent_layers_shell, 'DifyShellLayerConfig')", ], ) @@ -135,31 +160,122 @@ def test_agent_stub_cli_main_import_is_client_safe() -> None: "redis", "shell_session_manager", ], - imports=["dify_agent.agent_stub.cli.main"], - assertions=["assert hasattr(dify_agent_agent_stub_cli_main, 'main')"], - ) - - -def test_agent_stub_client_and_protocol_imports_are_client_safe() -> None: - _run_import_check( - blocked_imports=[ - "dify_agent.server", - "dify_agent.agent_stub.server", - "fastapi", - "jwcrypto", - "pydantic_settings", - "redis", - "shell_session_manager", + imports=[ + "dify_agent.agent_stub.client", + "dify_agent.agent_stub.protocol", + "dify_agent.agent_stub.cli.main", + "dify_agent.agent_stub.shell_env", + "dify_agent.layers.shell.layer", + "dify_agent.runtime.compositor_factory", ], - imports=["dify_agent.agent_stub.client", "dify_agent.agent_stub.protocol"], assertions=[ - "assert hasattr(dify_agent_agent_stub_client, 'connect_agent_stub_sync')", + "assert hasattr(dify_agent_agent_stub_client, 'request_agent_stub_drive_manifest_sync')", "assert hasattr(dify_agent_agent_stub_protocol, 'AgentStubConnectRequest')", + "assert hasattr(dify_agent_agent_stub_cli_main, 'main')", + "assert hasattr(dify_agent_agent_stub_shell_env, 'build_shell_agent_stub_env')", + "assert hasattr(dify_agent_layers_shell_layer, 'DifyShellLayer')", + "assert hasattr(dify_agent_runtime_compositor_factory, 'create_default_layer_providers')", + ], + bootstrap=[ + "import types", + "if 'graphon.model_runtime.entities.llm_entities' not in sys.modules:", + " graphon_module = types.ModuleType('graphon')", + " model_runtime_module = types.ModuleType('graphon.model_runtime')", + " entities_module = types.ModuleType('graphon.model_runtime.entities')", + " llm_entities_module = types.ModuleType('graphon.model_runtime.entities.llm_entities')", + " message_entities_module = types.ModuleType('graphon.model_runtime.entities.message_entities')", + " llm_entities_module.LLMResultChunk = type('LLMResultChunk', (), {})", + " llm_entities_module.LLMUsage = type('LLMUsage', (), {})", + " for name in ('AssistantPromptMessage', 'AudioPromptMessageContent', 'DocumentPromptMessageContent', 'ImagePromptMessageContent', 'PromptMessage', 'PromptMessageContentUnionTypes', 'PromptMessageTool', 'SystemPromptMessage', 'TextPromptMessageContent', 'ToolPromptMessage', 'UserPromptMessage', 'VideoPromptMessageContent'):", + " setattr(message_entities_module, name, type(name, (), {}))", + " sys.modules['graphon'] = graphon_module", + " sys.modules['graphon.model_runtime'] = model_runtime_module", + " sys.modules['graphon.model_runtime.entities'] = entities_module", + " sys.modules['graphon.model_runtime.entities.llm_entities'] = llm_entities_module", + " sys.modules['graphon.model_runtime.entities.message_entities'] = message_entities_module", + " graphon_module.model_runtime = model_runtime_module", + " model_runtime_module.entities = entities_module", + " entities_module.llm_entities = llm_entities_module", + " entities_module.message_entities = message_entities_module", + "if 'jsonschema' not in sys.modules:", + " jsonschema_module = types.ModuleType('jsonschema')", + " jsonschema_exceptions_module = types.ModuleType('jsonschema.exceptions')", + " jsonschema_protocols_module = types.ModuleType('jsonschema.protocols')", + " jsonschema_validators_module = types.ModuleType('jsonschema.validators')", + " class _SchemaError(Exception):", + " pass", + " class _ValidationError(Exception):", + " path = ()", + " class _Validator:", + " @staticmethod", + " def check_schema(schema):", + " return None", + " def __init__(self, schema):", + " self.schema = schema", + " def iter_errors(self, value):", + " return iter(())", + " def _validator_for(schema):", + " return _Validator", + " jsonschema_module.SchemaError = _SchemaError", + " jsonschema_exceptions_module.ValidationError = _ValidationError", + " jsonschema_protocols_module.Validator = _Validator", + " jsonschema_validators_module.validator_for = _validator_for", + " sys.modules['jsonschema'] = jsonschema_module", + " sys.modules['jsonschema.exceptions'] = jsonschema_exceptions_module", + " sys.modules['jsonschema.protocols'] = jsonschema_protocols_module", + " sys.modules['jsonschema.validators'] = jsonschema_validators_module", ], ) +def test_agent_stub_cli_help_render_does_not_load_server_modules() -> None: + blocked_modules = [ + "dify_agent.server", + "dify_agent.agent_stub.server", + "fastapi", + "google.protobuf", + "grpclib", + "jwcrypto", + "pydantic_settings", + "redis", + "shell_session_manager", + ] + script = "\n".join( + [ + "import click", + "import importlib", + "import os", + "import sys", + "from typer.main import get_command", + f"blocked_modules = {blocked_modules!r}", + 'original_disable_plugins = os.environ.get("PYDANTIC_DISABLE_PLUGINS")', + 'original_disable_plugins_present = "PYDANTIC_DISABLE_PLUGINS" in os.environ', + 'module = importlib.import_module("dify_agent.agent_stub.cli.main")', + "command = get_command(module.app)", + "help_text = command.get_help(click.Context(command))", + 'assert "Forward shell-visible dify-agent commands" in help_text', + "if original_disable_plugins_present:", + ' assert os.environ.get("PYDANTIC_DISABLE_PLUGINS") == original_disable_plugins', + "else:", + ' assert "PYDANTIC_DISABLE_PLUGINS" not in os.environ', + "loaded_blocked = sorted(", + " name", + " for name in sys.modules", + ' if any(name == blocked or name.startswith(f"{blocked}.") for blocked in blocked_modules)', + ")", + "assert loaded_blocked == [], loaded_blocked", + ] + ) + _run_python_script(script) + + def test_server_settings_import_does_not_import_agent_stub_app() -> None: + try: + __import__("pydantic_settings") + __import__("jwcrypto") + except ModuleNotFoundError: + pytest.skip("server extras are not installed in this environment") + _run_import_check( blocked_imports=["dify_agent.agent_stub.server.app"], imports=["dify_agent.server.settings"], diff --git a/e2e/features/agent-v2/AGENTS.md b/e2e/features/agent-v2/AGENTS.md index 6806fcac1d1..f432eff7d77 100644 --- a/e2e/features/agent-v2/AGENTS.md +++ b/e2e/features/agent-v2/AGENTS.md @@ -43,7 +43,6 @@ Use tags in three layers: - `@full-config-agent` — fixed `E2E New Agent Builder Full Config` Agent dependency. - `@tool-states-agent` — fixed `E2E New Agent Builder Tool States` Agent dependency. - `@oauth-tool-agent` — fixed `E2E Agent With OAuth Tool` Agent dependency for OAuth2 tool credential preservation. -- `@file-tree-fixture` — fixed file-tree Agent drive/config-files dependency. - `@dual-retrieval-fixture` — fixed dual Knowledge Retrieval Agent dependency. - `@backend-api-access` — fixed or scenario-owned Backend service API access dependency. - `@published-web-app` — fixed or scenario-owned published Web app access dependency. @@ -206,10 +205,6 @@ Use `the Agent Builder preseeded Agent "{agent}" includes an OAuth2 tool credent Use `the Agent Builder preseeded Agent "{agent}" includes the dual retrieval fixture configuration` for the fixed Dual Retrieval Agent prerequisite. It composes the indexed knowledge-base preflight, then reads `/console/api/agent/{agent_id}/composer` to verify `agent_soul.knowledge.sets` includes both an Agent-decide generated query set and a custom user-query set using the fixed custom query. -Use `the Agent Builder preseeded Agent "{agent}" includes the file tree fixture files` for file-tree display prerequisites. It verifies the Agent drive contains every file from `agentBuilderFileTreeFixtureFiles` through `/console/api/agent/{agent_id}/drive/files?prefix=files/`. - -Use `the Agent Builder preseeded Agent "{agent}" includes the current flat file fixture configuration` for the current Agent Edit Files section. Agent config files are still a flat `config_files` list and reject path separators, so this preflight verifies the fixture file basenames are present in the Agent Soul. Treat this as partial coverage for tree-display requirements until the product supports hierarchical config files in the visible Files section. - Use `the Agent Builder preseeded Agent "{agent}" has published Web app access` to verify that a fixed Agent is published, Web app access is enabled, and the Agent detail response includes the site token and base URL needed to open the Web app. Use `the Agent Builder preseeded Agent "{agent}" is referenced by workflow "{workflow}"` to verify Workflow access prerequisites. It checks both fixed resources exist, then uses `/console/api/agent/{agent_id}/referencing-workflows`, the same Console API used by the Access Point Workflow references table, to verify the workflow references the Agent through at least one published Agent node. diff --git a/e2e/features/agent-v2/agent-edit.feature b/e2e/features/agent-v2/agent-edit.feature index 823a3a91015..f9fadd314dd 100644 --- a/e2e/features/agent-v2/agent-edit.feature +++ b/e2e/features/agent-v2/agent-edit.feature @@ -40,15 +40,6 @@ Feature: Agent v2 Agent Edit page When I open the preseeded Agent v2 configure page for "E2E New Agent Builder Tool States" from the Agent Roster Then Agent v2 Tool credential error state should be available - @core @file-tree-fixture - Scenario: File fixture entries are visible in the current flat Files list - Given I am signed in as the default E2E admin - And the Agent Builder preseeded Agent "E2E Agent With File Tree" is available - And the Agent Builder preseeded Agent "E2E Agent With File Tree" includes the file tree fixture files - And the Agent Builder preseeded Agent "E2E Agent With File Tree" includes the current flat file fixture configuration - When I open the preseeded Agent v2 configure page for "E2E Agent With File Tree" from the Agent Roster - Then I should see the Agent v2 file fixture entries in the current flat Files list - @core @dual-retrieval-fixture Scenario: Dual Knowledge Retrieval settings are visible on the Agent Edit page Given I am signed in as the default E2E admin diff --git a/e2e/features/agent-v2/build-draft.feature b/e2e/features/agent-v2/build-draft.feature index b3476541e34..918f46b5efb 100644 --- a/e2e/features/agent-v2/build-draft.feature +++ b/e2e/features/agent-v2/build-draft.feature @@ -1,5 +1,15 @@ @agent-v2 @authenticated @build Feature: Agent v2 build draft + @core + Scenario: Build chat is blocked until a model is configured + Given I am signed in as the default E2E admin + And an Agent v2 test agent has been created via API + And the Agent v2 composer draft uses the normal E2E prompt + When I open the Agent v2 configure page + And I try to generate an Agent v2 Build draft without a model + Then Agent v2 Build chat should be blocked until a model is configured + And the Agent v2 Build draft should not be checked out + @external-model @agent-backend-runtime @stable-model Scenario: Generating a Build draft leaves the normal Agent configuration unchanged Given I am signed in as the default E2E admin diff --git a/e2e/features/agent-v2/preflight.feature b/e2e/features/agent-v2/preflight.feature index 1593fd758aa..aa130331304 100644 --- a/e2e/features/agent-v2/preflight.feature +++ b/e2e/features/agent-v2/preflight.feature @@ -4,7 +4,7 @@ Feature: Agent Builder preseeded environment Scenario: Agent lifecycle permissions are available Given I am signed in as the default E2E admin And an Agent v2 test agent has been created via API - And the Agent v2 composer draft uses the normal E2E prompt + And the Agent v2 composer draft is publishable When I open the Agent v2 configure page And I publish the Agent v2 draft Then the Agent v2 draft should be published and up to date @@ -89,11 +89,6 @@ Feature: Agent Builder preseeded environment Given I am signed in as the default E2E admin And the Agent Builder preseeded Agent "E2E Agent With OAuth Tool" includes an OAuth2 tool credential - @file-tree-fixture - Scenario: File tree Agent includes fixture files - Given I am signed in as the default E2E admin - And the Agent Builder preseeded Agent "E2E Agent With File Tree" includes the file tree fixture files - @dual-retrieval-fixture Scenario: Dual retrieval Agent is available Given I am signed in as the default E2E admin diff --git a/e2e/features/agent-v2/publish.feature b/e2e/features/agent-v2/publish.feature index d2a0bd1547a..e203970b10f 100644 --- a/e2e/features/agent-v2/publish.feature +++ b/e2e/features/agent-v2/publish.feature @@ -1,5 +1,15 @@ @agent-v2 @authenticated @publish Feature: Agent v2 publish + @core + Scenario: Publish is blocked until a model is configured + Given I am signed in as the default E2E admin + And an Agent v2 test agent has been created via API + And the Agent v2 composer draft uses the normal E2E prompt + When I open the Agent v2 configure page + And I try to publish the Agent v2 draft without a model + Then Agent v2 publish should be blocked until a model is configured + And the Agent v2 draft should remain unpublished + @core @stable-model Scenario: Publish a configured Agent v2 draft Given I am signed in as the default E2E admin diff --git a/e2e/features/agent-v2/support/agent-build-draft.ts b/e2e/features/agent-v2/support/agent-build-draft.ts index 3e65cd4f75d..b22d3c7d543 100644 --- a/e2e/features/agent-v2/support/agent-build-draft.ts +++ b/e2e/features/agent-v2/support/agent-build-draft.ts @@ -39,6 +39,21 @@ export async function saveAgentBuildDraft( } } +export async function agentBuildDraftExists(agentId: string): Promise { + const ctx = await createApiContext() + try { + const response = await ctx.get(`/console/api/agent/${agentId}/build-draft`) + if (response.status() === 404) + return false + + await expectApiResponseOK(response, `Get Agent v2 build draft for ${agentId}`) + return true + } + finally { + await ctx.dispose() + } +} + export async function applyAgentBuildDraft(agentId: string): Promise { const ctx = await createApiContext() try { diff --git a/e2e/features/agent-v2/support/agent-builder-resources.ts b/e2e/features/agent-v2/support/agent-builder-resources.ts index 1ce25f836a5..9b296ecf34e 100644 --- a/e2e/features/agent-v2/support/agent-builder-resources.ts +++ b/e2e/features/agent-v2/support/agent-builder-resources.ts @@ -11,7 +11,6 @@ export const agentBuilderPreseededResources = { fullConfigAgent: 'E2E New Agent Builder Full Config', toolStatesAgent: 'E2E New Agent Builder Tool States', oauthToolAgent: 'E2E Agent With OAuth Tool', - fileTreeAgent: 'E2E Agent With File Tree', dualRetrievalAgent: 'E2E Agent With Dual Retrieval', publishedWebAppAgent: 'E2E Agent Published Web App', backendApiEnabledAgent: 'E2E Agent Backend API Enabled', diff --git a/e2e/features/agent-v2/support/agent-soul.ts b/e2e/features/agent-v2/support/agent-soul.ts index f3b3c7c6bb1..4697234b83c 100644 --- a/e2e/features/agent-v2/support/agent-soul.ts +++ b/e2e/features/agent-v2/support/agent-soul.ts @@ -36,6 +36,11 @@ export const normalAgentSoulConfig: AgentSoulConfig = { }, } +export const publishOnlyAgentModel: AgentModelSelection = { + name: 'gpt-5-nano', + provider: 'openai', +} + export const updatedAgentSoulConfig: AgentSoulConfig = { prompt: { system_prompt: updatedAgentPrompt, @@ -81,6 +86,13 @@ export function createAgentSoulConfigWithModel( } } +export function createPublishableAgentSoulConfig(agentSoul: AgentSoulConfig): AgentSoulConfig { + if (agentSoul.model) + return agentSoul + + return createAgentSoulConfigWithModel(agentSoul, publishOnlyAgentModel) +} + export function createAgentSoulConfigWithKnowledgeDataset( agentSoul: AgentSoulConfig, dataset: AgentKnowledgeDatasetConfig, diff --git a/e2e/features/agent-v2/support/agent.ts b/e2e/features/agent-v2/support/agent.ts index b8a7881b100..db19b11574b 100644 --- a/e2e/features/agent-v2/support/agent.ts +++ b/e2e/features/agent-v2/support/agent.ts @@ -8,7 +8,7 @@ import type { } from '@dify/contracts/api/console/agent/types.gen' import { createApiContext, expectApiResponseOK } from '../../../support/api' import { assertE2EResourceName, createE2EResourceName } from '../../../support/naming' -import { defaultAgentSoulConfig, normalAgentSoulConfig } from './agent-soul' +import { createPublishableAgentSoulConfig, defaultAgentSoulConfig, normalAgentSoulConfig } from './agent-soul' export type AgentSeed = Pick< AgentAppDetailWithSite, @@ -156,6 +156,12 @@ export async function getAgentComposerDraft(agentId: string): Promise { + const composer = await getAgentComposerDraft(agentId) + if (!composer.agent_soul?.model) + await saveAgentComposerDraft(agentId, createPublishableAgentSoulConfig(composer.agent_soul ?? defaultAgentSoulConfig)) +} + export async function publishAgent(agentId: string, versionNote = 'E2E publish'): Promise { const ctx = await createApiContext() try { @@ -168,3 +174,11 @@ export async function publishAgent(agentId: string, versionNote = 'E2E publish') await ctx.dispose() } } + +export async function publishAgentWithPublishableDraft( + agentId: string, + versionNote = 'E2E publish', +): Promise { + await ensureAgentComposerDraftIsPublishable(agentId) + await publishAgent(agentId, versionNote) +} diff --git a/e2e/features/agent-v2/support/preflight/agents.ts b/e2e/features/agent-v2/support/preflight/agents.ts index 1d4ad2d7970..46f7eddc272 100644 --- a/e2e/features/agent-v2/support/preflight/agents.ts +++ b/e2e/features/agent-v2/support/preflight/agents.ts @@ -1,6 +1,5 @@ import type { AgentAppComposerResponse, - AgentDriveListResponse, AgentDriveSkillListResponse, AgentSoulConfig, } from '@dify/contracts/api/console/agent/types.gen' @@ -12,11 +11,7 @@ import { agentBuilderFixedInputs, agentBuilderPreseededResources, } from '../agent-builder-resources' -import { - agentBuilderFileTreeFixtureFileNames, - agentBuilderFileTreeFixtureFiles, - agentBuilderTestMaterials, -} from '../test-materials' +import { agentBuilderTestMaterials } from '../test-materials' import { asArray, asRecord, @@ -456,80 +451,3 @@ export async function skipMissingPreseededDualRetrievalAgentConfiguration( await ctx.dispose() } } - -export async function skipMissingPreseededAgentFileTreeFixture( - world: DifyWorld, - agentName: string, -): Promise<'skipped' | PreseededResource> { - const agent = await skipMissingPreseededAgent(world, agentName) - if (agent === 'skipped') - return agent - - const ctx = await createApiContext() - try { - const query = buildQuery({ prefix: 'files/' }) - const response = await ctx.get(`/console/api/agent/${agent.id}/drive/files?${query}`) - await expectApiResponseOK(response, `Check preseeded Agent file tree ${agentName}`) - const body = (await response.json()) as AgentDriveListResponse - const keys = (body.items ?? []).map(item => item.key) - const missingFiles = agentBuilderFileTreeFixtureFiles.filter( - filePath => - !keys.some(key => key === `files/${filePath}` || key.endsWith(`/${filePath}`)), - ) - - if (missingFiles.length > 0) { - return skipBlockedPrecondition( - world, - `Preseeded Agent "${agentName}" is missing file tree fixture files: ${missingFiles.join(', ')}.`, - ) - } - - return { - id: agent.id, - kind: 'agent', - name: agent.name, - } - } - finally { - await ctx.dispose() - } -} - -export async function skipMissingPreseededAgentFlatFileFixtureConfiguration( - world: DifyWorld, - agentName: string, -): Promise<'skipped' | PreseededResource> { - const agent = await skipMissingPreseededAgent(world, agentName) - if (agent === 'skipped') - return agent - - const ctx = await createApiContext() - try { - const response = await ctx.get(`/console/api/agent/${agent.id}/composer`) - await expectApiResponseOK(response, `Check preseeded Agent flat file fixture ${agentName}`) - const body = (await response.json()) as AgentAppComposerResponse - const configFiles = Array.isArray(body.agent_soul?.config_files) - ? body.agent_soul.config_files - : [] - const fileNames = configFiles - .map(file => (typeof file === 'object' && file !== null && 'name' in file ? file.name : undefined)) - .filter((name): name is string => typeof name === 'string') - const missingFiles = agentBuilderFileTreeFixtureFileNames.filter(fileName => !fileNames.includes(fileName)) - - if (missingFiles.length > 0) { - return skipBlockedPrecondition( - world, - `Preseeded Agent "${agentName}" is missing current flat Files fixture configuration: ${missingFiles.join(', ')}. Hierarchical Files display remains blocked until Agent config files support tree paths.`, - ) - } - - return { - id: agent.id, - kind: 'agent', - name: agent.name, - } - } - finally { - await ctx.dispose() - } -} diff --git a/e2e/features/agent-v2/support/seed.ts b/e2e/features/agent-v2/support/seed.ts index e3a3eb40bc1..716be7ec336 100644 --- a/e2e/features/agent-v2/support/seed.ts +++ b/e2e/features/agent-v2/support/seed.ts @@ -989,14 +989,6 @@ const agentV2FullSeedTasks = (): SeedTask[] => [ title: agentBuilderPreseededResources.oauthToolAgent, run: seedOAuthToolAgent, }, - { - id: 'file-tree-agent', - title: agentBuilderPreseededResources.fileTreeAgent, - run: async () => blocked( - agentBuilderPreseededResources.fileTreeAgent, - 'Agent drive arbitrary file upload does not have a stable public seed helper yet.', - ), - }, { id: 'dual-retrieval-agent', title: agentBuilderPreseededResources.dualRetrievalAgent, diff --git a/e2e/features/agent-v2/support/test-materials.ts b/e2e/features/agent-v2/support/test-materials.ts index eb6497918b0..1f047d4c1f4 100644 --- a/e2e/features/agent-v2/support/test-materials.ts +++ b/e2e/features/agent-v2/support/test-materials.ts @@ -1,4 +1,3 @@ -import path from 'node:path' import { getGeneratedTextMaterialPath, getTestMaterialPath } from '../../../support/test-materials' export const agentBuilderTestMaterials = { @@ -11,7 +10,6 @@ export const agentBuilderTestMaterials = { invalidEnv: 'agent-invalid.env', buildInstruction: 'agent-build-instruction.txt', summarySkill: 'e2e-summary-skill/SKILL.md', - fileTreeFixture: 'file_tree_fixture', countBatch5: 'count_batch_5_valid_files', countBatch6: 'count_batch_6_valid_files', countTotal50: 'count_total_50_valid_files', @@ -23,17 +21,6 @@ export const agentBuilderGeneratedTestMaterials = { tooLargeFile: 'agent-too-large-file.txt', } as const -export const agentBuilderFileTreeFixtureFiles = [ - 'assets/sample.csv', - 'docs/中文说明.md', - 'public/index.html', - 'src/main.txt', - 'web-game/README.md', -] as const - -export const agentBuilderFileTreeFixtureFileNames = agentBuilderFileTreeFixtureFiles - .map(filePath => path.basename(filePath)) - export const getAgentBuilderTestMaterialPath = (material: keyof typeof agentBuilderTestMaterials) => getTestMaterialPath(agentBuilderTestMaterials[material]) diff --git a/e2e/features/step-definitions/agent-v2/access-point.steps.ts b/e2e/features/step-definitions/agent-v2/access-point.steps.ts index 40c7e852aa3..0ed6e456a93 100644 --- a/e2e/features/step-definitions/agent-v2/access-point.steps.ts +++ b/e2e/features/step-definitions/agent-v2/access-point.steps.ts @@ -6,7 +6,7 @@ import { setAgentApiAccess, setAgentSiteAccessAndGetURL, } from '../../agent-v2/support/access-point' -import { getAgentAccessPath, publishAgent } from '../../agent-v2/support/agent' +import { getAgentAccessPath, publishAgentWithPublishableDraft } from '../../agent-v2/support/agent' import { getAccessRegion, getAccessSurfaceCard, @@ -15,7 +15,7 @@ import { } from './access-point-helpers' Given('the Agent v2 draft has been published via API', async function (this: DifyWorld) { - await publishAgent(getCurrentAgentId(this)) + await publishAgentWithPublishableDraft(getCurrentAgentId(this)) }) Given( diff --git a/e2e/features/step-definitions/agent-v2/build-draft.steps.ts b/e2e/features/step-definitions/agent-v2/build-draft.steps.ts index 9443197e6e8..22e11f7bd63 100644 --- a/e2e/features/step-definitions/agent-v2/build-draft.steps.ts +++ b/e2e/features/step-definitions/agent-v2/build-draft.steps.ts @@ -8,6 +8,7 @@ import { saveAgentComposerDraft, } from '../../agent-v2/support/agent' import { + agentBuildDraftExists, applyAgentBuildDraft, saveAgentBuildDraft, } from '../../agent-v2/support/agent-build-draft' @@ -25,6 +26,7 @@ import { hasToolEntry } from '../../agent-v2/support/preflight/tools' import { agentBuilderTestMaterials, getAgentBuilderTestMaterialPath } from '../../agent-v2/support/test-materials' import { getPreseededToolContract } from '../../agent-v2/support/tools' import { + expectAgentModelRequiredFeedback, getAgentEnvVariableValue, getCurrentAgentId, uploadSummaryConfigSkillForBuildDraft, @@ -142,6 +144,17 @@ When( }, ) +When( + 'I try to generate an Agent v2 Build draft without a model', + async function (this: DifyWorld) { + const page = this.getPage() + + await page.getByRole('button', { exact: true, name: 'Build' }).click() + await page.getByPlaceholder('Describe what your agent should do').fill('Update the agent instructions for E2E.') + await page.getByRole('button', { name: 'Start build' }).click() + }, +) + const expectPageResponseOK = async (response: Response, action: string) => { if (response.ok()) return @@ -246,6 +259,17 @@ Then('I should see the Agent v2 Build mode confirmation state', async function ( ).toBeVisible() }) +Then('Agent v2 Build chat should be blocked until a model is configured', async function (this: DifyWorld) { + await expectAgentModelRequiredFeedback(this.getPage()) +}) + +Then('the Agent v2 Build draft should not be checked out', async function (this: DifyWorld) { + await expect.poll( + async () => agentBuildDraftExists(getCurrentAgentId(this)), + { timeout: 30_000 }, + ).toBe(false) +}) + Then('I should see the e2e-summary-skill Skill in the Skills section', async function (this: DifyWorld) { const skillsSection = this.getPage().getByRole('region', { name: 'Skills' }) diff --git a/e2e/features/step-definitions/agent-v2/configure-helpers.ts b/e2e/features/step-definitions/agent-v2/configure-helpers.ts index e51c89b1da0..10927c23b1b 100644 --- a/e2e/features/step-definitions/agent-v2/configure-helpers.ts +++ b/e2e/features/step-definitions/agent-v2/configure-helpers.ts @@ -138,6 +138,10 @@ export const expectAgentConfigFileSaved = async ( }) } +export const expectAgentModelRequiredFeedback = async (page: ReturnType) => { + await expect(page.getByText('Select your model')).toBeVisible({ timeout: 10_000 }) +} + export const uploadSummaryConfigSkillForBuildDraft = async (world: DifyWorld) => { const agentId = getCurrentAgentId(world) const skill = await uploadAgentConfigSkillToDraft({ diff --git a/e2e/features/step-definitions/agent-v2/configure.steps.ts b/e2e/features/step-definitions/agent-v2/configure.steps.ts index bf9d4f0dfce..c41298fc84f 100644 --- a/e2e/features/step-definitions/agent-v2/configure.steps.ts +++ b/e2e/features/step-definitions/agent-v2/configure.steps.ts @@ -15,6 +15,7 @@ import { concurrentFirstAgentPrompt, concurrentSecondAgentPrompt, createAgentSoulConfigWithModel, + createPublishableAgentSoulConfig, normalAgentPrompt, normalAgentSoulConfig, updatedAgentPrompt, @@ -131,6 +132,16 @@ Given('the Agent v2 composer draft uses the normal E2E prompt', async function ( await saveAgentComposerDraft(getCurrentAgentId(this), normalAgentSoulConfig) }) +Given( + 'the Agent v2 composer draft is publishable', + async function (this: DifyWorld) { + await saveAgentComposerDraft( + getCurrentAgentId(this), + createPublishableAgentSoulConfig(normalAgentSoulConfig), + ) + }, +) + Given('the e2e-summary-skill Skill is available to the Agent v2 test agent', async function (this: DifyWorld) { const agentId = getCurrentAgentId(this) const upload = await uploadAgentDriveSkill({ diff --git a/e2e/features/step-definitions/agent-v2/files.steps.ts b/e2e/features/step-definitions/agent-v2/files.steps.ts index 76f8eaf6617..15e2f15c42b 100644 --- a/e2e/features/step-definitions/agent-v2/files.steps.ts +++ b/e2e/features/step-definitions/agent-v2/files.steps.ts @@ -2,10 +2,7 @@ import type { DifyWorld } from '../../support/world' import { Given, Then, When } from '@cucumber/cucumber' import { expect } from '@playwright/test' import { skipBlockedPrecondition } from '../../agent-v2/support/preflight/common' -import { - agentBuilderFileTreeFixtureFileNames, - agentBuilderTestMaterials, -} from '../../agent-v2/support/test-materials' +import { agentBuilderTestMaterials } from '../../agent-v2/support/test-materials' import { expectAgentConfigFileHidden, expectAgentConfigFileSaved, @@ -70,30 +67,6 @@ Then('I should not see the dropped Agent v2 files in the Files section', async f await expectAgentConfigFileHidden(this, 'emptyFile') }) -Then( - 'I should see the Agent v2 file fixture entries in the current flat Files list', - async function (this: DifyWorld) { - const page = this.getPage() - const filesSection = page.getByRole('region', { name: 'Files' }) - const filesList = filesSection.getByLabel('Agent files') - - await expect(filesSection).toBeVisible({ timeout: 30_000 }) - await expect(filesList).toBeVisible() - - for (const fileName of agentBuilderFileTreeFixtureFileNames) { - await expect(filesList.getByRole('button', { - exact: true, - name: fileName, - })).toBeVisible() - } - - await expect(filesList.getByRole('button', { exact: true, name: 'assets' })).toHaveCount(0) - await expect(filesList.getByRole('button', { exact: true, name: 'docs' })).toHaveCount(0) - await expect(filesList.getByRole('button', { exact: true, name: 'public' })).toHaveCount(0) - await expect(filesList.getByRole('button', { exact: true, name: 'src' })).toHaveCount(0) - await expect(filesList.getByRole('button', { exact: true, name: 'web-game' })).toHaveCount(0) - }, -) Then('I should see the small Agent v2 file in the Files section', async function (this: DifyWorld) { await expectAgentConfigFileVisible(this, 'smallFile') }) diff --git a/e2e/features/step-definitions/agent-v2/preflight.steps.ts b/e2e/features/step-definitions/agent-v2/preflight.steps.ts index bd62425ba34..9748680449e 100644 --- a/e2e/features/step-definitions/agent-v2/preflight.steps.ts +++ b/e2e/features/step-definitions/agent-v2/preflight.steps.ts @@ -9,8 +9,6 @@ import { skipMissingAgentBackendRuntime } from '../../agent-v2/support/preflight import { skipMissingPreseededAgent, skipMissingPreseededAgentDriveSkill, - skipMissingPreseededAgentFileTreeFixture, - skipMissingPreseededAgentFlatFileFixtureConfiguration, skipMissingPreseededDualRetrievalAgentConfiguration, skipMissingPreseededFullConfigAgentCoreConfiguration, skipMissingPreseededOAuthToolAgentConfiguration, @@ -183,29 +181,6 @@ Given( }, ) -Given( - 'the Agent Builder preseeded Agent {string} includes the file tree fixture files', - async function (this: DifyWorld, agentName: string) { - const resource = await skipMissingPreseededAgentFileTreeFixture(this, agentName) - if (resource === 'skipped') - return resource - - this.agentBuilder.preflight.preseededResources[`${agentName} / file tree fixture`] = resource - }, -) - -Given( - 'the Agent Builder preseeded Agent {string} includes the current flat file fixture configuration', - async function (this: DifyWorld, agentName: string) { - const resource = await skipMissingPreseededAgentFlatFileFixtureConfiguration(this, agentName) - if (resource === 'skipped') - return resource - - this.agentBuilder.preflight.preseededResources[`${agentName} / flat file fixture configuration`] - = resource - }, -) - Given( 'the Agent Builder preseeded Agent {string} has Backend service API access with an API key', async function (this: DifyWorld, agentName: string) { diff --git a/e2e/features/step-definitions/agent-v2/publish.steps.ts b/e2e/features/step-definitions/agent-v2/publish.steps.ts index 8bc8b94c751..c04fca83cb7 100644 --- a/e2e/features/step-definitions/agent-v2/publish.steps.ts +++ b/e2e/features/step-definitions/agent-v2/publish.steps.ts @@ -4,7 +4,7 @@ import { expect } from '@playwright/test' import { waitForAgentConfigureAutosaved } from '../../../support/agent-configure' import { getAgentVersionDetail, getTestAgent } from '../../agent-v2/support/agent' import { normalAgentPrompt } from '../../agent-v2/support/agent-soul' -import { getCurrentAgentId } from './configure-helpers' +import { expectAgentModelRequiredFeedback, getCurrentAgentId } from './configure-helpers' When('I publish the Agent v2 draft', async function (this: DifyWorld) { const page = this.getPage() @@ -14,6 +14,25 @@ When('I publish the Agent v2 draft', async function (this: DifyWorld) { await publishButton.click() }) +When('I try to publish the Agent v2 draft without a model', async function (this: DifyWorld) { + const page = this.getPage() + const publishButton = page.getByRole('button', { name: /^Publish(?: update)?$/ }) + + await expect(publishButton).toBeEnabled({ timeout: 30_000 }) + await publishButton.click() +}) + +Then('Agent v2 publish should be blocked until a model is configured', async function (this: DifyWorld) { + await expectAgentModelRequiredFeedback(this.getPage()) +}) + +Then('the Agent v2 draft should remain unpublished', async function (this: DifyWorld) { + await expect.poll( + async () => (await getTestAgent(getCurrentAgentId(this))).active_config_is_published, + { timeout: 30_000 }, + ).toBe(false) +}) + Then('the Agent v2 configuration should be saved automatically', async function (this: DifyWorld) { await waitForAgentConfigureAutosaved(this.getPage()) }) diff --git a/e2e/features/step-definitions/agent-v2/tools.steps.ts b/e2e/features/step-definitions/agent-v2/tools.steps.ts index 78eab7a488a..b8a78018c96 100644 --- a/e2e/features/step-definitions/agent-v2/tools.steps.ts +++ b/e2e/features/step-definitions/agent-v2/tools.steps.ts @@ -240,7 +240,6 @@ When( await expect(toolsSection).toBeVisible({ timeout: 30_000 }) await toolsSection.getByRole('button', { name: 'Add tool' }).click() - await this.getPage().getByRole('button', { name: /^Tool\b/ }).click() const search = getToolSelectorSearch(this) await expect(search).toBeVisible() diff --git a/e2e/fixtures/test-materials/file_tree_fixture/assets/sample.csv b/e2e/fixtures/test-materials/file_tree_fixture/assets/sample.csv deleted file mode 100644 index bed40b0fc09..00000000000 --- a/e2e/fixtures/test-materials/file_tree_fixture/assets/sample.csv +++ /dev/null @@ -1,3 +0,0 @@ -name,value -alpha,1 -beta,2 diff --git a/e2e/fixtures/test-materials/file_tree_fixture/docs/中文说明.md b/e2e/fixtures/test-materials/file_tree_fixture/docs/中文说明.md deleted file mode 100644 index 9d82b4cabd1..00000000000 --- a/e2e/fixtures/test-materials/file_tree_fixture/docs/中文说明.md +++ /dev/null @@ -1,3 +0,0 @@ -# 中文说明 - -文件树中文说明 token: E2E_FILE_TREE_ZH diff --git a/e2e/fixtures/test-materials/file_tree_fixture/public/index.html b/e2e/fixtures/test-materials/file_tree_fixture/public/index.html deleted file mode 100644 index babd6315f7a..00000000000 --- a/e2e/fixtures/test-materials/file_tree_fixture/public/index.html +++ /dev/null @@ -1,6 +0,0 @@ - - - - E2E_FILE_TREE_INDEX - - diff --git a/e2e/fixtures/test-materials/file_tree_fixture/src/main.txt b/e2e/fixtures/test-materials/file_tree_fixture/src/main.txt deleted file mode 100644 index 2f52ebf5950..00000000000 --- a/e2e/fixtures/test-materials/file_tree_fixture/src/main.txt +++ /dev/null @@ -1 +0,0 @@ -Main source fixture token: E2E_FILE_TREE_MAIN diff --git a/e2e/fixtures/test-materials/file_tree_fixture/web-game/README.md b/e2e/fixtures/test-materials/file_tree_fixture/web-game/README.md deleted file mode 100644 index 3581e8ee127..00000000000 --- a/e2e/fixtures/test-materials/file_tree_fixture/web-game/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# Web Game Fixture - -Expected token: E2E_FILE_TREE_README diff --git a/packages/contracts/generated/api/console/agent/orpc.gen.ts b/packages/contracts/generated/api/console/agent/orpc.gen.ts index e3671d8cf98..e6249427ea0 100644 --- a/packages/contracts/generated/api/console/agent/orpc.gen.ts +++ b/packages/contracts/generated/api/console/agent/orpc.gen.ts @@ -1178,11 +1178,11 @@ export const read = { } /** - * Upload one Agent App sandbox file as a Dify ToolFile mapping + * Upload one Agent App sandbox file and return a signed download URL */ export const post16 = oc .route({ - description: 'Upload one Agent App sandbox file as a Dify ToolFile mapping', + description: 'Upload one Agent App sandbox file and return a signed download URL', inputStructure: 'detailed', method: 'POST', operationId: 'postAgentByAgentIdSandboxFilesUpload', diff --git a/packages/contracts/generated/api/console/agent/types.gen.ts b/packages/contracts/generated/api/console/agent/types.gen.ts index 5a28df41715..bc74f6ebf56 100644 --- a/packages/contracts/generated/api/console/agent/types.gen.ts +++ b/packages/contracts/generated/api/console/agent/types.gen.ts @@ -442,8 +442,7 @@ export type AgentSandboxUploadPayload = { } export type SandboxUploadResponse = { - file: SandboxToolFileResponse - path: string + url: string } export type AgentSkillUploadResponse = { @@ -1008,11 +1007,6 @@ export type SandboxFileEntryResponse = { type: 'dir' | 'file' | 'other' | 'symlink' } -export type SandboxToolFileResponse = { - reference: string - transfer_method?: 'tool_file' -} - export type SkillManifest = { description: string entry_path: string @@ -1116,6 +1110,7 @@ export type AgentSource = 'agent_app' | 'imported' | 'roster' | 'system' | 'work export type AgentStatus = 'active' | 'archived' export type AgentSoulAppFeaturesConfig = { + file_upload?: AgentFileUploadFeatureConfig opening_statement?: string | null retriever_resource?: AgentFeatureToggleConfig | null sensitive_word_avoidance?: AgentSensitiveWordAvoidanceFeatureConfig | null @@ -1428,6 +1423,16 @@ export type AgentConfigRevisionOperation | 'save_new_version' | 'save_to_roster' +export type AgentFileUploadFeatureConfig = { + allowed_file_extensions?: Array + allowed_file_types?: Array + allowed_file_upload_methods?: Array + enabled?: boolean + image?: AgentFileUploadImageFeatureConfig + number_limits?: number + [key: string]: unknown +} + export type AgentSecretRefConfig = { credential_id?: string | null env_name?: string | null @@ -1679,6 +1684,15 @@ export type FormInputConfig export type JsonValue2 = unknown +export type FileType = 'audio' | 'custom' | 'document' | 'image' | 'video' + +export type FileTransferMethod = 'datasource_file' | 'local_file' | 'remote_url' | 'tool_file' + +export type AgentFileUploadImageFeatureConfig = { + enabled?: boolean + [key: string]: unknown +} + export type AgentKnowledgeDatasetConfig = { description?: string | null id?: string | null @@ -1801,10 +1815,6 @@ export type StringListSource = { value?: Array } -export type FileType = 'audio' | 'custom' | 'document' | 'image' | 'video' - -export type FileTransferMethod = 'datasource_file' | 'local_file' | 'remote_url' | 'tool_file' - export type AgentKnowledgeMetadataCondition = { comparison_operator: | '<' diff --git a/packages/contracts/generated/api/console/agent/zod.gen.ts b/packages/contracts/generated/api/console/agent/zod.gen.ts index 0ae9fd0dae6..6be9ce9e118 100644 --- a/packages/contracts/generated/api/console/agent/zod.gen.ts +++ b/packages/contracts/generated/api/console/agent/zod.gen.ts @@ -212,6 +212,13 @@ export const zAgentSandboxUploadPayload = z.object({ path: z.string().min(1), }) +/** + * SandboxUploadResponse + */ +export const zSandboxUploadResponse = z.object({ + url: z.string(), +}) + /** * AgentConfigSnapshotRestoreResponse */ @@ -865,22 +872,6 @@ export const zSandboxListResponse = z.object({ truncated: z.boolean().optional().default(false), }) -/** - * SandboxToolFileResponse - */ -export const zSandboxToolFileResponse = z.object({ - reference: z.string(), - transfer_method: z.literal('tool_file').optional().default('tool_file'), -}) - -/** - * SandboxUploadResponse - */ -export const zSandboxUploadResponse = z.object({ - file: zSandboxToolFileResponse, - path: z.string(), -}) - /** * SkillManifest * @@ -1926,19 +1917,6 @@ export const zAgentAppFeaturesPayload = z.object({ text_to_speech: zAgentTextToSpeechFeatureConfig.nullish(), }) -/** - * AgentSoulAppFeaturesConfig - */ -export const zAgentSoulAppFeaturesConfig = z.object({ - opening_statement: z.string().nullish(), - retriever_resource: zAgentFeatureToggleConfig.nullish(), - sensitive_word_avoidance: zAgentSensitiveWordAvoidanceFeatureConfig.nullish(), - speech_to_text: zAgentFeatureToggleConfig.nullish(), - suggested_questions: z.array(z.string()).nullish(), - suggested_questions_after_answer: zAgentSuggestedQuestionsAfterAnswerFeatureConfig.nullish(), - text_to_speech: zAgentTextToSpeechFeatureConfig.nullish(), -}) - export const zJsonValue2 = z.unknown() /** @@ -1953,6 +1931,54 @@ export const zHumanInputFormSubmissionData = z.object({ submitted_data: z.record(z.string(), zJsonValue2).nullish(), }) +/** + * FileType + */ +export const zFileType = z.enum(['audio', 'custom', 'document', 'image', 'video']) + +/** + * FileTransferMethod + */ +export const zFileTransferMethod = z.enum([ + 'datasource_file', + 'local_file', + 'remote_url', + 'tool_file', +]) + +/** + * AgentFileUploadImageFeatureConfig + */ +export const zAgentFileUploadImageFeatureConfig = z.object({ + enabled: z.boolean().optional().default(true), +}) + +/** + * AgentFileUploadFeatureConfig + */ +export const zAgentFileUploadFeatureConfig = z.object({ + allowed_file_extensions: z.array(z.string()).optional(), + allowed_file_types: z.array(zFileType).optional(), + allowed_file_upload_methods: z.array(zFileTransferMethod).optional(), + enabled: z.boolean().optional().default(true), + image: zAgentFileUploadImageFeatureConfig.optional(), + number_limits: z.int().optional().default(3), +}) + +/** + * AgentSoulAppFeaturesConfig + */ +export const zAgentSoulAppFeaturesConfig = z.object({ + file_upload: zAgentFileUploadFeatureConfig.optional(), + opening_statement: z.string().nullish(), + retriever_resource: zAgentFeatureToggleConfig.nullish(), + sensitive_word_avoidance: zAgentSensitiveWordAvoidanceFeatureConfig.nullish(), + speech_to_text: zAgentFeatureToggleConfig.nullish(), + suggested_questions: z.array(z.string()).nullish(), + suggested_questions_after_answer: zAgentSuggestedQuestionsAfterAnswerFeatureConfig.nullish(), + text_to_speech: zAgentTextToSpeechFeatureConfig.nullish(), +}) + /** * AgentKnowledgeDatasetConfig */ @@ -2182,6 +2208,29 @@ export const zUserActionConfig = z.object({ title: z.string().max(100), }) +/** + * FileInputConfig + */ +export const zFileInputConfig = z.object({ + allowed_file_extensions: z.array(z.string()).optional(), + allowed_file_types: z.array(zFileType).optional(), + allowed_file_upload_methods: z.array(zFileTransferMethod).optional(), + output_variable_name: z.string(), + type: z.literal('file').optional().default('file'), +}) + +/** + * FileListInputConfig + */ +export const zFileListInputConfig = z.object({ + allowed_file_extensions: z.array(z.string()).optional(), + allowed_file_types: z.array(zFileType).optional(), + allowed_file_upload_methods: z.array(zFileTransferMethod).optional(), + number_limits: z.int().gte(0).optional().default(0), + output_variable_name: z.string(), + type: z.literal('file-list').optional().default('file-list'), +}) + /** * AgentKnowledgeModelConfig */ @@ -2204,8 +2253,9 @@ export const zAgentKnowledgeQueryMode = z.enum(['generated_query', 'user_query'] * * Agent v2 stores knowledge as explicit ``knowledge.sets`` rather than the * legacy flat ``datasets`` / ``query_mode`` / ``query_config`` shape. Each - * set owns its own query policy, so ``user_query`` must carry an explicit - * ``value`` while ``generated_query`` leaves that value empty. + * set owns its own query policy. Mode-dependent completeness, such as + * requiring ``value`` for ``user_query``, is enforced by composer publish + * validation so draft saves can persist partially configured knowledge sets. */ export const zAgentKnowledgeQueryConfig = z.object({ mode: zAgentKnowledgeQueryMode, @@ -2235,8 +2285,9 @@ export const zAgentKnowledgeWeightedScoreConfig = z.object({ * Per-set retrieval policy for Agent v2 knowledge retrieval. * * Retrieval settings now live on each knowledge set instead of one shared - * flat config. A set may use either ``multiple`` retrieval with ``top_k`` or - * ``single`` retrieval with a required model config. + * flat config. Mode-dependent completeness, such as requiring ``top_k`` for + * ``multiple`` or a model for ``single``, is enforced by composer publish + * validation so draft saves can persist partially configured knowledge sets. */ export const zAgentKnowledgeRetrievalConfig = z.object({ mode: z.enum(['multiple', 'single']), @@ -2249,44 +2300,6 @@ export const zAgentKnowledgeRetrievalConfig = z.object({ weights: zAgentKnowledgeWeightedScoreConfig.nullish(), }) -/** - * FileType - */ -export const zFileType = z.enum(['audio', 'custom', 'document', 'image', 'video']) - -/** - * FileTransferMethod - */ -export const zFileTransferMethod = z.enum([ - 'datasource_file', - 'local_file', - 'remote_url', - 'tool_file', -]) - -/** - * FileInputConfig - */ -export const zFileInputConfig = z.object({ - allowed_file_extensions: z.array(z.string()).optional(), - allowed_file_types: z.array(zFileType).optional(), - allowed_file_upload_methods: z.array(zFileTransferMethod).optional(), - output_variable_name: z.string(), - type: z.literal('file').optional().default('file'), -}) - -/** - * FileListInputConfig - */ -export const zFileListInputConfig = z.object({ - allowed_file_extensions: z.array(z.string()).optional(), - allowed_file_types: z.array(zFileType).optional(), - allowed_file_upload_methods: z.array(zFileTransferMethod).optional(), - number_limits: z.int().gte(0).optional().default(0), - output_variable_name: z.string(), - type: z.literal('file-list').optional().default('file-list'), -}) - /** * AgentKnowledgeMetadataCondition */ @@ -2331,6 +2344,8 @@ export const zAgentKnowledgeMetadataConditions = z.object({ * The Python attribute uses ``metadata_model_config`` for clarity because the * model belongs to metadata filtering specifically, while the external API and * generated schema keep the historical ``model_config`` field name via alias. + * Mode-dependent completeness is enforced by composer publish validation so + * draft saves can persist partially configured metadata filters. */ export const zAgentKnowledgeMetadataFilteringConfig = z.object({ conditions: zAgentKnowledgeMetadataConditions.nullish(), diff --git a/packages/contracts/generated/api/console/apps/orpc.gen.ts b/packages/contracts/generated/api/console/apps/orpc.gen.ts index 83637302055..2b0d927e72d 100644 --- a/packages/contracts/generated/api/console/apps/orpc.gen.ts +++ b/packages/contracts/generated/api/console/apps/orpc.gen.ts @@ -3029,11 +3029,11 @@ export const read = { } /** - * Upload one workflow Agent sandbox file as a Dify ToolFile mapping + * Upload one workflow Agent sandbox file and return a signed download URL */ export const post41 = oc .route({ - description: 'Upload one workflow Agent sandbox file as a Dify ToolFile mapping', + description: 'Upload one workflow Agent sandbox file and return a signed download URL', inputStructure: 'detailed', method: 'POST', operationId: 'postAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesUpload', diff --git a/packages/contracts/generated/api/console/apps/types.gen.ts b/packages/contracts/generated/api/console/apps/types.gen.ts index 1b5a98eef43..627e65bfc81 100644 --- a/packages/contracts/generated/api/console/apps/types.gen.ts +++ b/packages/contracts/generated/api/console/apps/types.gen.ts @@ -873,8 +873,7 @@ export type WorkflowAgentSandboxUploadPayload = { } export type SandboxUploadResponse = { - file: SandboxToolFileResponse - path: string + url: string } export type WorkflowCommentBasicList = { @@ -1807,11 +1806,6 @@ export type SandboxFileEntryResponse = { type: 'dir' | 'file' | 'other' | 'symlink' } -export type SandboxToolFileResponse = { - reference: string - transfer_method?: 'tool_file' -} - export type WorkflowCommentBasic = { content: string created_at?: number | null @@ -2368,6 +2362,7 @@ export type AgentSource = 'agent_app' | 'imported' | 'roster' | 'system' | 'work export type AgentStatus = 'active' | 'archived' export type AgentSoulAppFeaturesConfig = { + file_upload?: AgentFileUploadFeatureConfig opening_statement?: string | null retriever_resource?: AgentFeatureToggleConfig | null sensitive_word_avoidance?: AgentSensitiveWordAvoidanceFeatureConfig | null @@ -2627,6 +2622,16 @@ export type WorkflowFileUploadPreviewConfigPayload = { mode?: string | null } +export type AgentFileUploadFeatureConfig = { + allowed_file_extensions?: Array + allowed_file_types?: Array + allowed_file_upload_methods?: Array + enabled?: boolean + image?: AgentFileUploadImageFeatureConfig + number_limits?: number + [key: string]: unknown +} + export type AgentFeatureToggleConfig = { enabled?: boolean [key: string]: unknown @@ -2873,6 +2878,15 @@ export type FileListInputConfig = { type?: 'file-list' } +export type FileType = 'audio' | 'custom' | 'document' | 'image' | 'video' + +export type FileTransferMethod = 'datasource_file' | 'local_file' | 'remote_url' | 'tool_file' + +export type AgentFileUploadImageFeatureConfig = { + enabled?: boolean + [key: string]: unknown +} + export type AgentModerationProviderConfig = { api_based_extension_id?: string | null inputs_config?: AgentModerationIoConfig | null @@ -2942,10 +2956,6 @@ export type StringListSource = { value?: Array } -export type FileType = 'audio' | 'custom' | 'document' | 'image' | 'video' - -export type FileTransferMethod = 'datasource_file' | 'local_file' | 'remote_url' | 'tool_file' - export type AgentModerationIoConfig = { enabled?: boolean preset_response?: string | null diff --git a/packages/contracts/generated/api/console/apps/zod.gen.ts b/packages/contracts/generated/api/console/apps/zod.gen.ts index a3d2b9f935a..f9742c91d64 100644 --- a/packages/contracts/generated/api/console/apps/zod.gen.ts +++ b/packages/contracts/generated/api/console/apps/zod.gen.ts @@ -564,6 +564,13 @@ export const zWorkflowAgentSandboxUploadPayload = z.object({ path: z.string().min(1), }) +/** + * SandboxUploadResponse + */ +export const zSandboxUploadResponse = z.object({ + url: z.string(), +}) + /** * WorkflowCommentCreatePayload */ @@ -1673,22 +1680,6 @@ export const zSandboxListResponse = z.object({ truncated: z.boolean().optional().default(false), }) -/** - * SandboxToolFileResponse - */ -export const zSandboxToolFileResponse = z.object({ - reference: z.string(), - transfer_method: z.literal('tool_file').optional().default('tool_file'), -}) - -/** - * SandboxUploadResponse - */ -export const zSandboxUploadResponse = z.object({ - file: zSandboxToolFileResponse, - path: z.string(), -}) - /** * AccountWithRoleResponse */ @@ -3466,6 +3457,63 @@ export const zUserActionConfig = z.object({ title: z.string().max(100), }) +/** + * FileType + */ +export const zFileType = z.enum(['audio', 'custom', 'document', 'image', 'video']) + +/** + * FileTransferMethod + */ +export const zFileTransferMethod = z.enum([ + 'datasource_file', + 'local_file', + 'remote_url', + 'tool_file', +]) + +/** + * FileInputConfig + */ +export const zFileInputConfig = z.object({ + allowed_file_extensions: z.array(z.string()).optional(), + allowed_file_types: z.array(zFileType).optional(), + allowed_file_upload_methods: z.array(zFileTransferMethod).optional(), + output_variable_name: z.string(), + type: z.literal('file').optional().default('file'), +}) + +/** + * FileListInputConfig + */ +export const zFileListInputConfig = z.object({ + allowed_file_extensions: z.array(z.string()).optional(), + allowed_file_types: z.array(zFileType).optional(), + allowed_file_upload_methods: z.array(zFileTransferMethod).optional(), + number_limits: z.int().gte(0).optional().default(0), + output_variable_name: z.string(), + type: z.literal('file-list').optional().default('file-list'), +}) + +/** + * AgentFileUploadImageFeatureConfig + */ +export const zAgentFileUploadImageFeatureConfig = z.object({ + enabled: z.boolean().optional().default(true), +}) + +/** + * AgentFileUploadFeatureConfig + */ +export const zAgentFileUploadFeatureConfig = z.object({ + allowed_file_extensions: z.array(z.string()).optional(), + allowed_file_types: z.array(zFileType).optional(), + allowed_file_upload_methods: z.array(zFileTransferMethod).optional(), + enabled: z.boolean().optional().default(true), + image: zAgentFileUploadImageFeatureConfig.optional(), + number_limits: z.int().optional().default(3), +}) + /** * AgentSuggestedQuestionsAfterAnswerModelConfig * @@ -3662,44 +3710,6 @@ export const zAgentSoulToolsConfig = z.object({ dify_tools: z.array(zAgentSoulDifyToolConfig).optional(), }) -/** - * FileType - */ -export const zFileType = z.enum(['audio', 'custom', 'document', 'image', 'video']) - -/** - * FileTransferMethod - */ -export const zFileTransferMethod = z.enum([ - 'datasource_file', - 'local_file', - 'remote_url', - 'tool_file', -]) - -/** - * FileInputConfig - */ -export const zFileInputConfig = z.object({ - allowed_file_extensions: z.array(z.string()).optional(), - allowed_file_types: z.array(zFileType).optional(), - allowed_file_upload_methods: z.array(zFileTransferMethod).optional(), - output_variable_name: z.string(), - type: z.literal('file').optional().default('file'), -}) - -/** - * FileListInputConfig - */ -export const zFileListInputConfig = z.object({ - allowed_file_extensions: z.array(z.string()).optional(), - allowed_file_types: z.array(zFileType).optional(), - allowed_file_upload_methods: z.array(zFileTransferMethod).optional(), - number_limits: z.int().gte(0).optional().default(0), - output_variable_name: z.string(), - type: z.literal('file-list').optional().default('file-list'), -}) - /** * AgentModerationIOConfig */ @@ -3731,6 +3741,7 @@ export const zAgentSensitiveWordAvoidanceFeatureConfig = z.object({ * AgentSoulAppFeaturesConfig */ export const zAgentSoulAppFeaturesConfig = z.object({ + file_upload: zAgentFileUploadFeatureConfig.optional(), opening_statement: z.string().nullish(), retriever_resource: zAgentFeatureToggleConfig.nullish(), sensitive_word_avoidance: zAgentSensitiveWordAvoidanceFeatureConfig.nullish(), @@ -3762,8 +3773,9 @@ export const zAgentKnowledgeQueryMode = z.enum(['generated_query', 'user_query'] * * Agent v2 stores knowledge as explicit ``knowledge.sets`` rather than the * legacy flat ``datasets`` / ``query_mode`` / ``query_config`` shape. Each - * set owns its own query policy, so ``user_query`` must carry an explicit - * ``value`` while ``generated_query`` leaves that value empty. + * set owns its own query policy. Mode-dependent completeness, such as + * requiring ``value`` for ``user_query``, is enforced by composer publish + * validation so draft saves can persist partially configured knowledge sets. */ export const zAgentKnowledgeQueryConfig = z.object({ mode: zAgentKnowledgeQueryMode, @@ -3793,8 +3805,9 @@ export const zAgentKnowledgeWeightedScoreConfig = z.object({ * Per-set retrieval policy for Agent v2 knowledge retrieval. * * Retrieval settings now live on each knowledge set instead of one shared - * flat config. A set may use either ``multiple`` retrieval with ``top_k`` or - * ``single`` retrieval with a required model config. + * flat config. Mode-dependent completeness, such as requiring ``top_k`` for + * ``multiple`` or a model for ``single``, is enforced by composer publish + * validation so draft saves can persist partially configured knowledge sets. */ export const zAgentKnowledgeRetrievalConfig = z.object({ mode: z.enum(['multiple', 'single']), @@ -3972,6 +3985,8 @@ export const zAgentKnowledgeMetadataConditions = z.object({ * The Python attribute uses ``metadata_model_config`` for clarity because the * model belongs to metadata filtering specifically, while the external API and * generated schema keep the historical ``model_config`` field name via alias. + * Mode-dependent completeness is enforced by composer publish validation so + * draft saves can persist partially configured metadata filters. */ export const zAgentKnowledgeMetadataFilteringConfig = z.object({ conditions: zAgentKnowledgeMetadataConditions.nullish(), diff --git a/packages/contracts/generated/api/openapi/types.gen.ts b/packages/contracts/generated/api/openapi/types.gen.ts index 74217608500..cd422cd1f92 100644 --- a/packages/contracts/generated/api/openapi/types.gen.ts +++ b/packages/contracts/generated/api/openapi/types.gen.ts @@ -315,7 +315,8 @@ export type MessageMetadata = { } export type OpenApiErrorCode - = | 'app_unavailable' + = | 'agent_not_published' + | 'app_unavailable' | 'bad_gateway' | 'bad_request' | 'completion_request_error' diff --git a/packages/contracts/generated/api/openapi/zod.gen.ts b/packages/contracts/generated/api/openapi/zod.gen.ts index 3faf3395526..70ece880a68 100644 --- a/packages/contracts/generated/api/openapi/zod.gen.ts +++ b/packages/contracts/generated/api/openapi/zod.gen.ts @@ -398,6 +398,7 @@ export const zMemberRoleUpdatePayload = z.object({ * OpenApiErrorCode */ export const zOpenApiErrorCode = z.enum([ + 'agent_not_published', 'app_unavailable', 'bad_gateway', 'bad_request', diff --git a/web/app/components/base/chat/chat/chat-input-area/index.tsx b/web/app/components/base/chat/chat/chat-input-area/index.tsx index 54a579540a0..8f3119b32d6 100644 --- a/web/app/components/base/chat/chat/chat-input-area/index.tsx +++ b/web/app/components/base/chat/chat/chat-input-area/index.tsx @@ -4,8 +4,8 @@ import type { EnableType, OnSend } from '../../types' import type { InputForm } from '../type' import type { FileUpload } from '@/app/components/base/features/types' import { cn } from '@langgenius/dify-ui/cn' +import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' import { toast } from '@langgenius/dify-ui/toast' -import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' import { noop } from 'es-toolkit/function' import { decode } from 'html-entities' import Recorder from 'js-audio-recorder' @@ -211,25 +211,32 @@ const ChatInputArea = ({ readonly, botName, customPlaceholder, showFeatureBar, s {shouldShowFooterNotice && (
+ +
{footerNotice}
{shouldShowFooterNoticeTooltip && ( - - + - + )} /> - + {footerNoticeTooltip} - - + + )} -
{footerNotice}
)} diff --git a/web/app/components/workflow/block-selector/__tests__/blocks.spec.tsx b/web/app/components/workflow/block-selector/__tests__/blocks.spec.tsx index 75c1b30d434..ac93a9ef407 100644 --- a/web/app/components/workflow/block-selector/__tests__/blocks.spec.tsx +++ b/web/app/components/workflow/block-selector/__tests__/blocks.spec.tsx @@ -1,3 +1,7 @@ +import type { + AgentInviteOptionResponse, + AgentInviteOptionsResponse, +} from '@dify/contracts/api/console/agent/types.gen' import type { NodeDefault } from '../../types' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { render, screen, waitFor } from '@testing-library/react' @@ -15,7 +19,7 @@ const runtimeState = vi.hoisted(() => ({ })) const queryMocks = vi.hoisted(() => ({ - inviteOptionsQueryFn: vi.fn(), + request: vi.fn(), toastError: vi.fn(), })) @@ -35,19 +39,8 @@ vi.mock('@/app/components/app/store', () => ({ }), })) -vi.mock('@/service/client', () => ({ - consoleQuery: { - agent: { - inviteOptions: { - get: { - queryOptions: (options: unknown) => ({ - queryKey: ['agents', 'invite-options', options], - queryFn: () => queryMocks.inviteOptionsQueryFn(options), - }), - }, - }, - }, - }, +vi.mock('@/service/base', () => ({ + request: (...args: unknown[]) => queryMocks.request(...args), })) vi.mock('@langgenius/dify-ui/toast', () => ({ @@ -74,6 +67,58 @@ const createBlock = ( checkValid: () => ({ isValid: true }), }) +const createInviteOption = ( + overrides: Partial & Pick, +): AgentInviteOptionResponse => { + const { id, name, ...rest } = overrides + + return { + id, + name, + description: rest.description ?? 'Clarification Drafter', + active_config_snapshot_id: rest.active_config_snapshot_id ?? 'version-1', + role: rest.role ?? 'Researcher', + agent_kind: rest.agent_kind ?? 'dify_agent', + icon: rest.icon ?? 'A', + icon_background: rest.icon_background ?? '#E9D7FE', + icon_type: rest.icon_type ?? 'emoji', + scope: rest.scope ?? 'roster', + source: rest.source ?? 'workflow', + status: rest.status ?? 'active', + ...rest, + } +} + +const createInviteOptionsResponse = ( + agents: AgentInviteOptionResponse[], +): AgentInviteOptionsResponse => ({ + data: agents, + has_more: false, + limit: 8, + page: 1, + total: agents.length, +}) + +const createJsonResponse = (body: unknown) => + new Response(JSON.stringify(body), { + status: 200, + headers: { + 'Content-Type': 'application/json', + }, + }) + +const mockInviteOptionsResponse = (agents: AgentInviteOptionResponse[]) => { + queryMocks.request.mockImplementation(() => Promise.resolve(createJsonResponse(createInviteOptionsResponse(agents)))) +} + +const expectLastInviteOptionsRequest = () => { + const [url] = queryMocks.request.mock.calls.at(-1) ?? [] + const requestURL = new URL(String(url), window.location.origin) + + expect(requestURL.pathname).toBe('/console/api/agent/invite-options') + return requestURL +} + describe('Blocks', () => { beforeEach(() => { vi.clearAllMocks() @@ -125,13 +170,7 @@ describe('Blocks', () => { it('opens the agent selector on Agent block hover', async () => { const user = userEvent.setup() - queryMocks.inviteOptionsQueryFn.mockResolvedValue({ - data: [], - has_more: false, - limit: 8, - page: 1, - total: 0, - }) + mockInviteOptionsResponse([]) const queryClient = new QueryClient({ defaultOptions: { queries: { @@ -171,28 +210,12 @@ describe('Blocks', () => { it('opens the agent selector from the Agent block and selects an agent', async () => { const user = userEvent.setup() const onSelect = vi.fn() - queryMocks.inviteOptionsQueryFn.mockResolvedValue({ - data: [ - { - id: 'agent-1', - name: 'Nadia', - description: 'Clarification Drafter', - active_config_snapshot_id: 'version-1', - role: 'Researcher', - agent_kind: 'dify_agent', - icon: 'A', - icon_background: '#E9D7FE', - icon_type: 'emoji', - scope: 'roster', - source: 'workflow', - status: 'active', - }, - ], - has_more: false, - limit: 8, - page: 1, - total: 1, - }) + mockInviteOptionsResponse([ + createInviteOption({ + id: 'agent-1', + name: 'Nadia', + }), + ]) const queryClient = new QueryClient({ defaultOptions: { @@ -246,42 +269,84 @@ describe('Blocks', () => { agent_node_kind: 'dify_agent', version: '2', }) - expect(queryMocks.inviteOptionsQueryFn).toHaveBeenCalledWith({ - input: { - query: { - app_id: 'app-1', - limit: 8, - page: 1, + const requestURL = expectLastInviteOptionsRequest() + expect(requestURL.searchParams.get('app_id')).toBe('app-1') + expect(requestURL.searchParams.get('limit')).toBe('8') + expect(requestURL.searchParams.get('page')).toBe('1') + }) + + it('should refresh Agent v2 roster options when the selector is reopened', async () => { + const user = userEvent.setup() + queryMocks.request + .mockImplementationOnce(() => Promise.resolve(createJsonResponse(createInviteOptionsResponse([ + createInviteOption({ + id: 'agent-1', + name: 'Nadia', + }), + ])))) + .mockImplementation(() => Promise.resolve(createJsonResponse(createInviteOptionsResponse([ + createInviteOption({ + id: 'agent-2', + name: 'Bruno', + role: 'Planner', + }), + ])))) + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + staleTime: 5 * 60 * 1000, }, }, }) + const hooksStore = createHooksStore({ + configsMap: { + flowId: 'app-1', + flowType: FlowType.appFlow, + fileSettings: {} as never, + }, + }) + + render( + + + + + , + ) + + await user.click(screen.getByRole('button', { name: /Agent/ })) + expect(await screen.findByText('Nadia')).toBeInTheDocument() + + await user.click(screen.getByRole('combobox', { name: 'agentV2.roster.searchLabel' })) + await user.keyboard('{Escape}') + await waitFor(() => { + expect(screen.queryByRole('dialog', { name: 'agentV2.roster.nodeSelector.dialogLabel' })).not.toBeInTheDocument() + }) + + await user.click(screen.getByRole('button', { name: /Agent/ })) + + expect(await screen.findByText('Bruno')).toBeInTheDocument() + expect(screen.getByText('Planner')).toBeInTheDocument() + await waitFor(() => expect(queryMocks.request).toHaveBeenCalledTimes(2)) + expect(screen.queryByText('Nadia')).not.toBeInTheDocument() }) it('does not select an Agent v2 roster agent without active config snapshot', async () => { const user = userEvent.setup() const onSelect = vi.fn() - queryMocks.inviteOptionsQueryFn.mockResolvedValue({ - data: [ - { - id: 'agent-1', - name: 'Nadia', - description: 'Clarification Drafter', - active_config_snapshot_id: null, - role: 'Researcher', - agent_kind: 'dify_agent', - icon: 'A', - icon_background: '#E9D7FE', - icon_type: 'emoji', - scope: 'roster', - source: 'workflow', - status: 'active', - }, - ], - has_more: false, - limit: 8, - page: 1, - total: 1, - }) + mockInviteOptionsResponse([ + createInviteOption({ + id: 'agent-1', + name: 'Nadia', + active_config_snapshot_id: null, + }), + ]) const queryClient = new QueryClient({ defaultOptions: { @@ -323,13 +388,7 @@ describe('Blocks', () => { it('inserts an inline Agent v2 node from the selector start action', async () => { const user = userEvent.setup() const onSelect = vi.fn() - queryMocks.inviteOptionsQueryFn.mockResolvedValue({ - data: [], - has_more: false, - limit: 8, - page: 1, - total: 0, - }) + mockInviteOptionsResponse([]) const queryClient = new QueryClient({ defaultOptions: { queries: { @@ -376,13 +435,7 @@ describe('Blocks', () => { it('closes the agent selector when Escape closes the combobox', async () => { const user = userEvent.setup() - queryMocks.inviteOptionsQueryFn.mockResolvedValue({ - data: [], - has_more: false, - limit: 8, - page: 1, - total: 0, - }) + mockInviteOptionsResponse([]) const queryClient = new QueryClient({ defaultOptions: { queries: { diff --git a/web/app/components/workflow/block-selector/agent-selector.tsx b/web/app/components/workflow/block-selector/agent-selector.tsx index f6d7c6db5d0..e3877355e69 100644 --- a/web/app/components/workflow/block-selector/agent-selector.tsx +++ b/web/app/components/workflow/block-selector/agent-selector.tsx @@ -66,6 +66,7 @@ export function AgentSelectorContent({ }, }, }), + staleTime: 0, }) const agents = agentsQuery.data?.data ?? [] const actionOptions: AgentSelectorActionOption[] = onStartFromScratch diff --git a/web/app/components/workflow/nodes/agent-v2/__tests__/default.spec.ts b/web/app/components/workflow/nodes/agent-v2/__tests__/default.spec.ts index 7460739778f..d040321886e 100644 --- a/web/app/components/workflow/nodes/agent-v2/__tests__/default.spec.ts +++ b/web/app/components/workflow/nodes/agent-v2/__tests__/default.spec.ts @@ -101,6 +101,10 @@ describe('agent/default', () => { }) }) + it('reuses the legacy agent node help document', () => { + expect(nodeDefault.metaData.helpLinkUri).toBe('agent') + }) + it('identifies version 2 agent data as Agent v2', () => { expect(isAgentV2NodeData(createPayload({ type: BlockEnum.Agent }))).toBe(true) expect(isAgentV2NodeData({ diff --git a/web/app/components/workflow/nodes/agent-v2/default.ts b/web/app/components/workflow/nodes/agent-v2/default.ts index b1273030112..c2b0c557839 100644 --- a/web/app/components/workflow/nodes/agent-v2/default.ts +++ b/web/app/components/workflow/nodes/agent-v2/default.ts @@ -7,6 +7,7 @@ import { hasValidAgentBinding } from './types' const metaData = genNodeMetaData({ sort: 3, type: BlockEnum.AgentV2, + helpLinkUri: 'agent', }) const nodeDefault: NodeDefault = { diff --git a/web/features/agent-v2/agent-composer/store-modules/__tests__/env.spec.ts b/web/features/agent-v2/agent-composer/store-modules/__tests__/env.spec.ts new file mode 100644 index 00000000000..a545055a0ba --- /dev/null +++ b/web/features/agent-v2/agent-composer/store-modules/__tests__/env.spec.ts @@ -0,0 +1,84 @@ +import { createStore } from 'jotai' +import { describe, expect, it } from 'vitest' +import { defaultAgentSoulConfigFormState } from '../../form-state' +import { agentComposerDraftAtom } from '../../store' +import { + addEnvVariableAtom, + importEnvVariablesAtom, + removeEnvVariableAtom, + setEnvVariableKeyAtom, + setEnvVariableValueAtom, +} from '../env' + +const starterVariable = { + id: 'starter', + key: '', + value: '', + scope: 'plain', +} as const + +describe('agent composer env store', () => { + it('should promote the starter variable when editing an empty env list', () => { + const store = createStore() + store.set(agentComposerDraftAtom, defaultAgentSoulConfigFormState) + + store.set(setEnvVariableKeyAtom, { + id: starterVariable.id, + key: 'API_KEY', + starterVariable, + }) + store.set(setEnvVariableValueAtom, { + id: starterVariable.id, + starterVariable, + value: 'secret-value', + }) + + expect(store.get(agentComposerDraftAtom).envVariables).toEqual([ + { + id: 'starter', + key: 'API_KEY', + value: 'secret-value', + scope: 'plain', + }, + ]) + }) + + it('should add, import, and remove variables from the latest draft state', () => { + const store = createStore() + store.set(agentComposerDraftAtom, defaultAgentSoulConfigFormState) + + store.set(addEnvVariableAtom, { + starterVariable, + variable: { + id: 'env-1', + key: 'FIRST_KEY', + value: '', + scope: 'plain', + }, + }) + store.set(importEnvVariablesAtom, [ + { + id: 'env-2', + key: 'SECOND_KEY', + value: 'enabled', + scope: 'plain', + }, + ]) + store.set(removeEnvVariableAtom, 'starter') + + expect(store.get(agentComposerDraftAtom).envVariables).toEqual([ + { + id: 'env-1', + key: 'FIRST_KEY', + value: '', + scope: 'plain', + }, + { + id: 'env-2', + key: 'SECOND_KEY', + value: 'enabled', + scope: 'plain', + }, + ]) + }) +}) diff --git a/web/features/agent-v2/agent-composer/store-modules/__tests__/files.spec.ts b/web/features/agent-v2/agent-composer/store-modules/__tests__/files.spec.ts new file mode 100644 index 00000000000..50f477b2a18 --- /dev/null +++ b/web/features/agent-v2/agent-composer/store-modules/__tests__/files.spec.ts @@ -0,0 +1,70 @@ +import { createStore } from 'jotai' +import { describe, expect, it } from 'vitest' +import { defaultAgentSoulConfigFormState } from '../../form-state' +import { agentComposerDraftAtom } from '../../store' +import { + clearAgentConfigNoteAtom, + removeAgentFileAtom, + upsertAgentFileAtom, +} from '../files' + +describe('agent composer files store', () => { + it('should upsert and remove files from the latest draft state', () => { + const store = createStore() + store.set(agentComposerDraftAtom, { + ...defaultAgentSoulConfigFormState, + files: [ + { + id: 'folder', + icon: 'folder', + name: 'Folder', + children: [ + { + id: 'brief.md', + icon: 'markdown', + name: 'brief.md', + }, + ], + }, + { + id: 'diagram.png', + icon: 'image', + name: 'diagram.png', + }, + ], + }) + + store.set(upsertAgentFileAtom, { + id: 'diagram.png', + icon: 'image', + name: 'updated-diagram.png', + }) + store.set(removeAgentFileAtom, 'brief.md') + + expect(store.get(agentComposerDraftAtom).files).toEqual([ + { + id: 'folder', + icon: 'folder', + name: 'Folder', + children: [], + }, + { + id: 'diagram.png', + icon: 'image', + name: 'updated-diagram.png', + }, + ]) + }) + + it('should clear config note through the file action surface', () => { + const store = createStore() + store.set(agentComposerDraftAtom, { + ...defaultAgentSoulConfigFormState, + configNote: 'Build note', + }) + + store.set(clearAgentConfigNoteAtom) + + expect(store.get(agentComposerDraftAtom).configNote).toBe('') + }) +}) diff --git a/web/features/agent-v2/agent-composer/store-modules/__tests__/knowledge.spec.ts b/web/features/agent-v2/agent-composer/store-modules/__tests__/knowledge.spec.ts new file mode 100644 index 00000000000..782e188e931 --- /dev/null +++ b/web/features/agent-v2/agent-composer/store-modules/__tests__/knowledge.spec.ts @@ -0,0 +1,41 @@ +import { createStore } from 'jotai' +import { describe, expect, it } from 'vitest' +import { defaultAgentSoulConfigFormState } from '../../form-state' +import { agentComposerDraftAtom } from '../../store' +import { + addKnowledgeRetrievalAtom, + removeKnowledgeRetrievalAtom, + updateKnowledgeRetrievalAtom, +} from '../knowledge' + +describe('agent composer knowledge store', () => { + it('should apply retrieval list actions against the latest draft state', () => { + const store = createStore() + store.set(agentComposerDraftAtom, { + ...defaultAgentSoulConfigFormState, + knowledgeRetrievals: [ + { + id: 'retrieval-1', + name: 'Docs Search', + }, + ], + }) + + store.set(addKnowledgeRetrievalAtom, { + id: 'retrieval-2', + name: 'Release Search', + }) + store.set(updateKnowledgeRetrievalAtom, { + id: 'retrieval-1', + name: 'Updated Docs Search', + }) + store.set(removeKnowledgeRetrievalAtom, 'retrieval-2') + + expect(store.get(agentComposerDraftAtom).knowledgeRetrievals).toEqual([ + { + id: 'retrieval-1', + name: 'Updated Docs Search', + }, + ]) + }) +}) diff --git a/web/features/agent-v2/agent-composer/store-modules/__tests__/skills.spec.ts b/web/features/agent-v2/agent-composer/store-modules/__tests__/skills.spec.ts new file mode 100644 index 00000000000..7127c944694 --- /dev/null +++ b/web/features/agent-v2/agent-composer/store-modules/__tests__/skills.spec.ts @@ -0,0 +1,46 @@ +import { createStore } from 'jotai' +import { describe, expect, it } from 'vitest' +import { defaultAgentSoulConfigFormState } from '../../form-state' +import { agentComposerDraftAtom } from '../../store' +import { + removeAgentSkillAtom, + upsertAgentSkillAtom, +} from '../skills' + +describe('agent composer skills store', () => { + it('should upsert and remove skills from the latest draft state', () => { + const store = createStore() + store.set(agentComposerDraftAtom, { + ...defaultAgentSoulConfigFormState, + skills: [ + { + id: 'Tender Analyzer', + name: 'Tender Analyzer', + description: 'Extracts tender requirements.', + fileId: 'tool-file-1', + }, + ], + }) + + store.set(upsertAgentSkillAtom, { + id: 'Tender Analyzer', + name: 'Tender Analyzer', + description: 'Updated skill.', + fileId: 'tool-file-1', + }) + store.set(upsertAgentSkillAtom, { + id: 'Invoice Helper', + name: 'Invoice Helper', + fileId: 'tool-file-2', + }) + store.set(removeAgentSkillAtom, 'Tender Analyzer') + + expect(store.get(agentComposerDraftAtom).skills).toEqual([ + { + id: 'Invoice Helper', + name: 'Invoice Helper', + fileId: 'tool-file-2', + }, + ]) + }) +}) diff --git a/web/features/agent-v2/agent-composer/store-modules/__tests__/tools.spec.ts b/web/features/agent-v2/agent-composer/store-modules/__tests__/tools.spec.ts new file mode 100644 index 00000000000..06723304a3f --- /dev/null +++ b/web/features/agent-v2/agent-composer/store-modules/__tests__/tools.spec.ts @@ -0,0 +1,181 @@ +import type { AgentProviderToolDefaultValue } from '../tools' +import { createStore } from 'jotai' +import { describe, expect, it } from 'vitest' +import { defaultAgentSoulConfigFormState } from '../../form-state' +import { agentComposerDraftAtom } from '../../store' +import { + addProviderTools, + addProviderToolsAtom, + removeProviderToolActionAtom, + saveCliToolAtom, +} from '../tools' + +const noCredentialTool = { + provider_id: 'duckduckgo', + provider_type: 'builtin', + provider_name: 'DuckDuckGo', + provider_show_name: 'DuckDuckGo', + tool_name: 'ddg_search', + tool_label: 'DuckDuckGo Search', + tool_description: 'Search the web.', + title: 'DuckDuckGo Search', + is_team_authorization: true, + params: {}, + paramSchemas: [], + allowDelete: false, + credentialRequired: false, +} satisfies AgentProviderToolDefaultValue + +const unauthorizedCredentialTool = { + ...noCredentialTool, + provider_id: 'google', + provider_name: 'google', + provider_show_name: 'Google', + tool_name: 'search', + tool_label: 'Google Search', + title: 'Google Search', + is_team_authorization: false, + credentialRequired: true, +} satisfies AgentProviderToolDefaultValue + +const unauthorizedOAuthTool = { + ...unauthorizedCredentialTool, + provider_id: 'slack', + provider_name: 'slack', + provider_show_name: 'Slack', + credentialType: 'oauth2', +} satisfies AgentProviderToolDefaultValue + +describe('agent composer tools store', () => { + describe('addProviderTools', () => { + it('should not mark tools that do not need credentials as unauthorized', () => { + const nextTools = addProviderTools([], [noCredentialTool]) + + expect(nextTools).toEqual([ + expect.objectContaining({ + credentialId: undefined, + credentialType: undefined, + credentialVariant: 'none', + }), + ]) + }) + + it('should mark credential-required tools without credentials as unauthorized', () => { + const nextTools = addProviderTools([], [unauthorizedCredentialTool]) + + expect(nextTools).toEqual([ + expect.objectContaining({ + credentialId: undefined, + credentialType: 'unauthorized', + credentialVariant: 'unauthorized', + }), + ]) + }) + + it('should preserve oauth credential type for credential-required OAuth tools', () => { + const nextTools = addProviderTools([], [unauthorizedOAuthTool]) + + expect(nextTools).toEqual([ + expect.objectContaining({ + credentialId: undefined, + credentialType: 'oauth2', + credentialVariant: 'unauthorized', + }), + ]) + }) + }) + + describe('write actions', () => { + it('should apply provider and CLI updates against the latest draft tools', () => { + const store = createStore() + store.set(agentComposerDraftAtom, defaultAgentSoulConfigFormState) + + store.set(addProviderToolsAtom, [noCredentialTool]) + store.set(saveCliToolAtom, { + id: 'cli-tool', + kind: 'cli', + name: 'CLI Tool', + installCommand: 'pnpm install', + }) + store.set(addProviderToolsAtom, [unauthorizedCredentialTool]) + + expect(store.get(agentComposerDraftAtom).tools).toEqual([ + expect.objectContaining({ + id: 'duckduckgo', + kind: 'provider', + }), + expect.objectContaining({ + id: 'cli-tool', + kind: 'cli', + }), + expect.objectContaining({ + id: 'google', + kind: 'provider', + }), + ]) + }) + + it('should update existing CLI tools instead of appending duplicates', () => { + const store = createStore() + store.set(agentComposerDraftAtom, defaultAgentSoulConfigFormState) + + store.set(saveCliToolAtom, { + id: 'cli-tool', + kind: 'cli', + name: 'CLI Tool', + }) + store.set(saveCliToolAtom, { + id: 'cli-tool', + kind: 'cli', + name: 'Updated CLI Tool', + installCommand: 'pnpm install', + }) + + expect(store.get(agentComposerDraftAtom).tools).toEqual([ + { + id: 'cli-tool', + kind: 'cli', + name: 'Updated CLI Tool', + installCommand: 'pnpm install', + }, + ]) + }) + + it('should remove provider action settings with the action', () => { + const store = createStore() + store.set(agentComposerDraftAtom, { + ...defaultAgentSoulConfigFormState, + tools: [ + { + id: 'duckduckgo', + kind: 'provider', + name: 'DuckDuckGo', + iconClassName: 'i-simple-icons-duckduckgo', + credentialVariant: 'none', + actions: [ + { + id: 'duckduckgo:ddg_search', + name: 'DuckDuckGo Search', + toolName: 'ddg_search', + description: 'Search the web.', + }, + ], + }, + ], + toolSettings: { + 'duckduckgo:ddg_search': { + query: 'docs', + }, + }, + }) + + store.set(removeProviderToolActionAtom, { + toolId: 'duckduckgo', + actionId: 'duckduckgo:ddg_search', + }) + + expect(store.get(agentComposerDraftAtom).tools).toEqual([]) + expect(store.get(agentComposerDraftAtom).toolSettings).toEqual({}) + }) + }) +}) diff --git a/web/features/agent-v2/agent-composer/store-modules/env.ts b/web/features/agent-v2/agent-composer/store-modules/env.ts index 8fcdd75b499..0945c01dd15 100644 --- a/web/features/agent-v2/agent-composer/store-modules/env.ts +++ b/web/features/agent-v2/agent-composer/store-modules/env.ts @@ -1,4 +1,4 @@ -import type { EnvVariable } from '../form-state' +import type { EnvScope, EnvVariable } from '../form-state' import type { DraftFieldUpdate } from './utils' import { atom } from 'jotai' import { agentComposerDraftAtom } from '../store' @@ -15,3 +15,95 @@ export const agentComposerEnvVariablesAtom = atom( }) }, ) + +const updateEnvVariable = ( + envVariables: EnvVariable[], + starterVariable: EnvVariable, + id: string, + updater: (variable: EnvVariable) => EnvVariable, +) => { + const existingVariable = envVariables.find(variable => variable.id === id) + + if (existingVariable) { + return envVariables.map(variable => ( + variable.id === id ? updater(variable) : variable + )) + } + + if (id === starterVariable.id) + return [updater(starterVariable)] + + return envVariables +} + +export const setEnvVariableKeyAtom = atom(null, (_get, set, { + id, + key, + starterVariable, +}: { + id: string + key: string + starterVariable: EnvVariable +}) => { + set(agentComposerEnvVariablesAtom, envVariables => updateEnvVariable( + envVariables, + starterVariable, + id, + variable => ({ ...variable, key }), + )) +}) + +export const setEnvVariableScopeAtom = atom(null, (_get, set, { + id, + scope, + starterVariable, +}: { + id: string + scope: EnvScope + starterVariable: EnvVariable +}) => { + set(agentComposerEnvVariablesAtom, envVariables => updateEnvVariable( + envVariables, + starterVariable, + id, + variable => ({ ...variable, scope }), + )) +}) + +export const setEnvVariableValueAtom = atom(null, (_get, set, { + id, + starterVariable, + value, +}: { + id: string + starterVariable: EnvVariable + value: string +}) => { + set(agentComposerEnvVariablesAtom, envVariables => updateEnvVariable( + envVariables, + starterVariable, + id, + variable => ({ ...variable, value }), + )) +}) + +export const addEnvVariableAtom = atom(null, (_get, set, { + starterVariable, + variable, +}: { + starterVariable: EnvVariable + variable: EnvVariable +}) => { + set(agentComposerEnvVariablesAtom, envVariables => [ + ...(envVariables.length > 0 ? envVariables : [starterVariable]), + variable, + ]) +}) + +export const importEnvVariablesAtom = atom(null, (_get, set, variables: EnvVariable[]) => { + set(agentComposerEnvVariablesAtom, envVariables => [...envVariables, ...variables]) +}) + +export const removeEnvVariableAtom = atom(null, (_get, set, id: string) => { + set(agentComposerEnvVariablesAtom, envVariables => envVariables.filter(variable => variable.id !== id)) +}) diff --git a/web/features/agent-v2/agent-composer/store-modules/files.ts b/web/features/agent-v2/agent-composer/store-modules/files.ts index e686116a8ff..1a931a4ac4b 100644 --- a/web/features/agent-v2/agent-composer/store-modules/files.ts +++ b/web/features/agent-v2/agent-composer/store-modules/files.ts @@ -22,3 +22,33 @@ export const agentComposerFilesAtom = atom files.flatMap((file) => { + if (file.id === fileId) + return [] + + if (file.children) + return [{ ...file, children: removeAgentFileNode(file.children, fileId) }] + + return [file] +}) + +export const upsertAgentFileAtom = atom(null, (_get, set, file: AgentFileNode) => { + set(agentComposerFilesAtom, files => [ + ...removeAgentFileNode(files, file.id), + file, + ]) +}) + +export const removeAgentFileAtom = atom(null, (_get, set, fileId: string) => { + set(agentComposerFilesAtom, files => removeAgentFileNode(files, fileId)) +}) + +export const clearAgentConfigNoteAtom = atom(null, (get, set) => { + const draft = get(agentComposerDraftAtom) + + set(agentComposerDraftAtom, { + ...draft, + configNote: '', + }) +}) diff --git a/web/features/agent-v2/agent-composer/store-modules/knowledge.ts b/web/features/agent-v2/agent-composer/store-modules/knowledge.ts index b33b4f6e951..53d25f74b78 100644 --- a/web/features/agent-v2/agent-composer/store-modules/knowledge.ts +++ b/web/features/agent-v2/agent-composer/store-modules/knowledge.ts @@ -22,3 +22,17 @@ export const agentComposerKnowledgeRetrievalsAtom = atom( }) }, ) + +export const addKnowledgeRetrievalAtom = atom(null, (_get, set, retrieval: AgentKnowledgeRetrievalItem) => { + set(agentComposerKnowledgeRetrievalsAtom, retrievals => [...retrievals, retrieval]) +}) + +export const updateKnowledgeRetrievalAtom = atom(null, (_get, set, retrieval: AgentKnowledgeRetrievalItem) => { + set(agentComposerKnowledgeRetrievalsAtom, retrievals => retrievals.map(currentRetrieval => ( + currentRetrieval.id === retrieval.id ? retrieval : currentRetrieval + ))) +}) + +export const removeKnowledgeRetrievalAtom = atom(null, (_get, set, retrievalId: string) => { + set(agentComposerKnowledgeRetrievalsAtom, retrievals => retrievals.filter(retrieval => retrieval.id !== retrievalId)) +}) diff --git a/web/features/agent-v2/agent-composer/store-modules/skills.ts b/web/features/agent-v2/agent-composer/store-modules/skills.ts index 2fa8bd47740..4fd342cf1d1 100644 --- a/web/features/agent-v2/agent-composer/store-modules/skills.ts +++ b/web/features/agent-v2/agent-composer/store-modules/skills.ts @@ -22,3 +22,14 @@ export const agentComposerSkillsAtom = atom { + set(agentComposerSkillsAtom, skills => [ + ...skills.filter(item => item.id !== skill.id), + skill, + ]) +}) + +export const removeAgentSkillAtom = atom(null, (_get, set, skillId: string) => { + set(agentComposerSkillsAtom, skills => skills.filter(item => item.id !== skillId)) +}) diff --git a/web/features/agent-v2/agent-composer/store-modules/tools.ts b/web/features/agent-v2/agent-composer/store-modules/tools.ts index 804f758b4a7..1f20efefdae 100644 --- a/web/features/agent-v2/agent-composer/store-modules/tools.ts +++ b/web/features/agent-v2/agent-composer/store-modules/tools.ts @@ -1,11 +1,17 @@ -import type { AgentProviderTool, AgentSoulConfigFormState, AgentTool } from '../form-state' +import type { AgentCliTool, AgentProviderTool, AgentSoulConfigFormState, AgentTool } from '../form-state' import type { DraftFieldUpdate } from './utils' -import { atom, useSetAtom } from 'jotai' -import { useCallback } from 'react' +import type { ToolDefaultValue } from '@/app/components/workflow/block-selector/types' +import { atom } from 'jotai' import { syncCliToolReferenceLabels } from '../reference-labels' import { agentComposerDraftAtom } from '../store' import { resolveDraftFieldUpdate } from './utils' +export type AgentProviderToolDefaultValue = ToolDefaultValue & { + allowDelete?: boolean + credentialType?: AgentProviderTool['credentialType'] + credentialRequired?: boolean +} + export const agentComposerToolsAtom = atom( get => get(agentComposerDraftAtom).tools, (get, set, toolsUpdate: DraftFieldUpdate) => { @@ -24,6 +30,104 @@ export const agentComposerToolsAtom = atom( }, ) +const toProviderToolAction = (tool: AgentProviderToolDefaultValue) => ({ + id: `${tool.provider_id}:${tool.tool_name}`, + name: tool.tool_label || tool.title || tool.tool_name, + toolName: tool.tool_name, + description: tool.tool_description || '', +}) + +const getCredentialVariant = (tool: AgentProviderToolDefaultValue) => { + if (!tool.credentialRequired) + return 'none' as const + + if (!tool.allowDelete) + return tool.credential_id ? 'authorized' as const : 'unauthorized' as const + + return tool.is_team_authorization ? 'authorized' as const : 'unauthorized' as const +} + +const getCredentialType = (tool: AgentProviderToolDefaultValue) => { + if (!tool.credentialRequired) + return undefined + + if (tool.credentialType === 'oauth2') + return 'oauth2' as const + + if (!tool.allowDelete) + return tool.credential_id ? 'api-key' as const : 'unauthorized' as const + + return tool.is_team_authorization ? 'api-key' as const : 'unauthorized' as const +} + +export const addProviderTools = ( + currentTools: AgentTool[], + selectedTools: AgentProviderToolDefaultValue[], +): AgentTool[] => { + if (selectedTools.length === 0) + return currentTools + + const nextTools = [...currentTools] + + selectedTools.forEach((selectedTool) => { + const action = toProviderToolAction(selectedTool) + const existingToolIndex = nextTools.findIndex(tool => tool.kind === 'provider' && tool.id === selectedTool.provider_id) + const existingTool = nextTools[existingToolIndex] + + if (existingTool?.kind === 'provider') { + if (existingTool.actions.some(existingAction => existingAction.toolName === action.toolName)) + return + + nextTools[existingToolIndex] = { + ...existingTool, + displayName: existingTool.displayName ?? selectedTool.provider_show_name, + icon: existingTool.icon ?? selectedTool.provider_icon, + iconDark: existingTool.iconDark ?? selectedTool.provider_icon_dark, + allowDelete: existingTool.allowDelete ?? selectedTool.allowDelete, + actions: [...existingTool.actions, action], + } + return + } + + nextTools.push({ + id: selectedTool.provider_id, + name: selectedTool.provider_name, + kind: 'provider', + displayName: selectedTool.provider_show_name, + iconClassName: 'i-custom-public-other-default-tool-icon text-text-tertiary', + icon: selectedTool.provider_icon, + iconDark: selectedTool.provider_icon_dark, + providerType: selectedTool.provider_type, + allowDelete: selectedTool.allowDelete, + credentialId: selectedTool.credential_id, + credentialKey: selectedTool.is_team_authorization + ? 'agentDetail.configure.tools.credential.authOne' + : undefined, + credentialType: getCredentialType(selectedTool), + credentialVariant: getCredentialVariant(selectedTool), + actions: [action], + }) + }) + + return nextTools +} + +export const addProviderToolsAtom = atom(null, (_get, set, selectedTools: AgentProviderToolDefaultValue[]) => { + set(agentComposerToolsAtom, tools => addProviderTools(tools, selectedTools)) +}) + +export const saveCliToolAtom = atom(null, (_get, set, cliTool: AgentCliTool) => { + set(agentComposerToolsAtom, tools => ( + tools.some(tool => tool.kind === 'cli' && tool.id === cliTool.id) + ? tools.map(tool => tool.id === cliTool.id ? cliTool : tool) + : [...tools, cliTool] + )) +}) + +export const removeCliToolAtom = atom(null, (_get, set, toolId: string) => { + set(agentComposerToolsAtom, tools => tools.filter(tool => tool.id !== toolId)) +}) + export const agentComposerToolSettingsAtom = atom( get => get(agentComposerDraftAtom).toolSettings, (get, set, toolSettingsUpdate: DraftFieldUpdate>>) => { @@ -49,63 +153,79 @@ const omitToolSettings = ( return nextToolSettings } -export function useRemoveProviderTool() { - const setDraft = useSetAtom(agentComposerDraftAtom) +export const removeProviderToolAtom = atom(null, (get, set, toolId: string) => { + const draft = get(agentComposerDraftAtom) + const toolToRemove = draft.tools.find(tool => tool.kind === 'provider' && tool.id === toolId) + const actionIds = toolToRemove?.kind === 'provider' + ? toolToRemove.actions.map(action => action.id) + : [] - return useCallback((toolId: string) => { - setDraft((draft) => { - const toolToRemove = draft.tools.find(tool => tool.kind === 'provider' && tool.id === toolId) - const actionIds = toolToRemove?.kind === 'provider' - ? toolToRemove.actions.map(action => action.id) - : [] + set(agentComposerDraftAtom, { + ...draft, + tools: draft.tools.filter(tool => tool.id !== toolId), + toolSettings: omitToolSettings(draft.toolSettings, actionIds), + }) +}) - return { - ...draft, - tools: draft.tools.filter(tool => tool.id !== toolId), - toolSettings: omitToolSettings(draft.toolSettings, actionIds), - } - }) - }, [setDraft]) -} +export const removeProviderToolActionAtom = atom(null, (get, set, { + toolId, + actionId, +}: { + toolId: string + actionId: string +}) => { + const draft = get(agentComposerDraftAtom) -export function useRemoveProviderToolAction() { - const setDraft = useSetAtom(agentComposerDraftAtom) - - return useCallback((toolId: string, actionId: string) => { - setDraft(draft => ({ - ...draft, - tools: draft.tools.flatMap((tool) => { - if (tool.kind !== 'provider' || tool.id !== toolId) - return [tool] - - const nextActions = tool.actions.filter(action => action.id !== actionId) - return nextActions.length > 0 - ? [{ ...tool, actions: nextActions }] - : [] - }), - toolSettings: omitToolSettings(draft.toolSettings, [actionId]), - })) - }, [setDraft]) -} - -export function useSetProviderToolCredential() { - const setTools = useSetAtom(agentComposerToolsAtom) - - return useCallback((toolId: string, credentialId?: string, credentialType?: AgentProviderTool['credentialType']) => { - setTools(tools => tools.map((tool) => { + set(agentComposerDraftAtom, { + ...draft, + tools: draft.tools.flatMap((tool) => { if (tool.kind !== 'provider' || tool.id !== toolId) - return tool + return [tool] - const nextCredentialType = credentialType === 'oauth2' || tool.credentialType === 'oauth2' - ? 'oauth2' - : 'api-key' + const nextActions = tool.actions.filter(action => action.id !== actionId) + return nextActions.length > 0 + ? [{ ...tool, actions: nextActions }] + : [] + }), + toolSettings: omitToolSettings(draft.toolSettings, [actionId]), + }) +}) - return { - ...tool, - credentialId, - credentialType: nextCredentialType, - credentialVariant: 'authorized', - } - })) - }, [setTools]) -} +export const setProviderToolCredentialAtom = atom(null, (_get, set, { + toolId, + credentialId, + credentialType, +}: { + toolId: string + credentialId?: string + credentialType?: AgentProviderTool['credentialType'] +}) => { + set(agentComposerToolsAtom, tools => tools.map((tool) => { + if (tool.kind !== 'provider' || tool.id !== toolId) + return tool + + const nextCredentialType = credentialType === 'oauth2' || tool.credentialType === 'oauth2' + ? 'oauth2' + : 'api-key' + + return { + ...tool, + credentialId, + credentialType: nextCredentialType, + credentialVariant: 'authorized', + } + })) +}) + +export const saveProviderToolActionSettingsAtom = atom(null, (_get, set, { + actionId, + value, +}: { + actionId: string + value: Record +}) => { + set(agentComposerToolSettingsAtom, toolSettings => ({ + ...toolSettings, + [actionId]: value, + })) +}) diff --git a/web/features/agent-v2/agent-detail/__tests__/layout.spec.tsx b/web/features/agent-v2/agent-detail/__tests__/layout.spec.tsx index 90d2b34166f..e1c5fe6258c 100644 --- a/web/features/agent-v2/agent-detail/__tests__/layout.spec.tsx +++ b/web/features/agent-v2/agent-detail/__tests__/layout.spec.tsx @@ -1,18 +1,33 @@ -import { render, screen } from '@testing-library/react' +import { render, screen, waitFor } from '@testing-library/react' import { AgentDetailLayout } from '../layout' +const mockReplace = vi.hoisted(() => vi.fn()) +const mockAgentQuery = vi.hoisted(() => ({ + data: { + name: 'Agent', + } as { name: string } | undefined, + error: null as unknown, +})) + vi.mock('@tanstack/react-query', async (importOriginal) => { const actual = await importOriginal() return { ...actual, - useQuery: vi.fn(() => ({ - data: { - name: 'Agent', - }, - })), + useQuery: vi.fn(() => mockAgentQuery), } }) +vi.mock('@/next/navigation', () => ({ + useRouter: () => ({ + back: vi.fn(), + forward: vi.fn(), + refresh: vi.fn(), + push: vi.fn(), + replace: mockReplace, + prefetch: vi.fn(), + }), +})) + vi.mock('@/hooks/use-document-title', () => ({ default: vi.fn(), })) @@ -32,6 +47,10 @@ vi.mock('@/service/client', () => ({ describe('AgentDetailLayout', () => { beforeEach(() => { vi.clearAllMocks() + mockAgentQuery.data = { + name: 'Agent', + } + mockAgentQuery.error = null }) it('should render detail content without owning navigation landmarks', () => { @@ -45,4 +64,20 @@ describe('AgentDetailLayout', () => { expect(screen.queryByRole('main')).not.toBeInTheDocument() expect(screen.queryByRole('complementary', { name: 'Detail sidebar' })).not.toBeInTheDocument() }) + + it('should redirect to roster when agent detail returns 404', async () => { + mockAgentQuery.data = undefined + mockAgentQuery.error = new Response(null, { status: 404 }) + + render( + +
Agent detail content
+
, + ) + + await waitFor(() => { + expect(mockReplace).toHaveBeenCalledWith('/roster') + }) + expect(screen.queryByText('Agent detail content')).not.toBeInTheDocument() + }) }) diff --git a/web/features/agent-v2/agent-detail/access/components/__tests__/workflow-references-table.spec.tsx b/web/features/agent-v2/agent-detail/access/components/__tests__/workflow-references-table.spec.tsx index e8f012397fb..eda95721bbf 100644 --- a/web/features/agent-v2/agent-detail/access/components/__tests__/workflow-references-table.spec.tsx +++ b/web/features/agent-v2/agent-detail/access/components/__tests__/workflow-references-table.spec.tsx @@ -5,9 +5,10 @@ import { WorkflowReferencesTable } from '../workflow-references-table' const mocks = vi.hoisted(() => ({ queryFn: vi.fn(), - queryOptions: vi.fn((input: unknown) => ({ + queryOptions: vi.fn(({ enabled = true, input }: { enabled?: boolean, input: unknown }) => ({ queryKey: ['agent-referencing-workflows', input], queryFn: () => mocks.queryFn(input), + enabled, })), })) @@ -31,7 +32,7 @@ vi.mock('@/hooks/use-timestamp', () => ({ }), })) -const renderTable = () => { +const renderTable = ({ enabled }: { enabled?: boolean } = {}) => { const queryClient = new QueryClient({ defaultOptions: { queries: { @@ -42,7 +43,7 @@ const renderTable = () => { render( - + , ) @@ -67,9 +68,27 @@ describe('WorkflowReferencesTable', () => { agent_id: 'agent-1', }, }, + enabled: true, }) }) }) + + it('should not fetch workflow references when disabled', async () => { + renderTable({ enabled: false }) + + await waitFor(() => { + expect(mocks.queryOptions).toHaveBeenCalledWith({ + input: { + params: { + agent_id: 'agent-1', + }, + }, + enabled: false, + }) + }) + expect(mocks.queryFn).not.toHaveBeenCalled() + expect(screen.queryByText('agentV2.agentDetail.access.workflow.loading')).not.toBeInTheDocument() + }) }) describe('Rendering', () => { diff --git a/web/features/agent-v2/agent-detail/access/components/workflow-references-table.tsx b/web/features/agent-v2/agent-detail/access/components/workflow-references-table.tsx index 41029edc93a..9cfa89a6167 100644 --- a/web/features/agent-v2/agent-detail/access/components/workflow-references-table.tsx +++ b/web/features/agent-v2/agent-detail/access/components/workflow-references-table.tsx @@ -12,6 +12,7 @@ import { consoleQuery } from '@/service/client' type WorkflowReferencesTableProps = { agentId: string + enabled?: boolean } const workflowTableColSpan = 5 @@ -20,6 +21,7 @@ const getWorkflowReferenceHref = (reference: AgentReferencingWorkflowResponse) = export function WorkflowReferencesTable({ agentId, + enabled = true, }: WorkflowReferencesTableProps) { const { t } = useTranslation('agentV2') const { t: tCommon } = useTranslation('common') @@ -29,6 +31,7 @@ export function WorkflowReferencesTable({ agent_id: agentId, }, }, + enabled, })) const workflowReferences = workflowReferencesQuery.data?.data ?? [] @@ -62,12 +65,12 @@ export function WorkflowReferencesTable({ - {workflowReferencesQuery.isPending && ( + {enabled && workflowReferencesQuery.isPending && ( {t('agentDetail.access.workflow.loading')} )} - {workflowReferencesQuery.isError && ( + {enabled && workflowReferencesQuery.isError && (
{t('agentDetail.access.workflow.loadFailed')} @@ -83,12 +86,12 @@ export function WorkflowReferencesTable({
)} - {workflowReferencesQuery.isSuccess && workflowReferences.length === 0 && ( + {enabled && workflowReferencesQuery.isSuccess && workflowReferences.length === 0 && ( {t('agentDetail.access.workflow.empty')} )} - {workflowReferencesQuery.isSuccess && workflowReferences.map(reference => ( + {enabled && workflowReferencesQuery.isSuccess && workflowReferences.map(reference => ( - + diff --git a/web/features/agent-v2/agent-detail/configure/__tests__/page.spec.tsx b/web/features/agent-v2/agent-detail/configure/__tests__/page.spec.tsx index cdce3ee690a..0dbe6f0354e 100644 --- a/web/features/agent-v2/agent-detail/configure/__tests__/page.spec.tsx +++ b/web/features/agent-v2/agent-detail/configure/__tests__/page.spec.tsx @@ -54,6 +54,19 @@ const mocks = vi.hoisted(() => ({ }, })) +const toastMock = vi.hoisted(() => ({ + error: vi.fn(), +})) + +const modelHooksState = vi.hoisted(() => ({ + defaultTextGenerationModel: { + provider: { + provider: 'langgenius/openai/openai', + }, + model: 'gpt-4o-mini', + } as { provider: { provider: string }, model: string } | undefined, +})) + function createDeferredPromise() { let resolve!: (value: T) => void const promise = new Promise((promiseResolve) => { @@ -101,6 +114,10 @@ vi.mock('@tanstack/react-query', async (importOriginal) => { } }) +vi.mock('@langgenius/dify-ui/toast', () => ({ + toast: toastMock, +})) + vi.mock('@/service/client', () => ({ consoleQuery: { agent: { @@ -194,7 +211,7 @@ vi.mock('@/service/client', () => ({ })) vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({ - useDefaultModel: () => ({ data: undefined }), + useDefaultModel: () => ({ data: modelHooksState.defaultTextGenerationModel }), useTextGenerationCurrentProviderAndModelAndModelList: () => ({ textGenerationModelList: [], }), @@ -271,7 +288,7 @@ vi.mock('../components/preview/build-chat', async () => { void props.onSaveDraftBeforeRun?.().then(() => { setMessageSent(true) props.onConversationIdChange?.('build-conversation-new') - }) + }).catch(() => undefined) }} > send build message @@ -359,6 +376,12 @@ vi.mock('../components/preview/versions-panel', () => ({ describe('AgentConfigurePage', () => { beforeEach(() => { vi.clearAllMocks() + modelHooksState.defaultTextGenerationModel = { + provider: { + provider: 'langgenius/openai/openai', + }, + model: 'gpt-4o-mini', + } mocks.refreshDebugConversation.mockResolvedValue({ debug_conversation_has_messages: false, debug_conversation_id: 'debug-conversation-new', @@ -1036,6 +1059,50 @@ describe('AgentConfigurePage', () => { expect(screen.getByRole('button', { name: 'discard build draft' })).toBeDisabled() }) + it('should block build chat checkout when no model is configured', async () => { + const queryClient = new QueryClient() + modelHooksState.defaultTextGenerationModel = undefined + mocks.queryState.composer = { + data: { + agent_soul: { + prompt: { + system_prompt: 'draft prompt', + }, + }, + }, + isFetching: false, + isError: false, + isPending: false, + isSuccess: true, + refetch: vi.fn(), + } + mocks.queryState.buildDraft = { + data: undefined as unknown, + dataUpdatedAt: 0, + error: new Response(null, { status: 404 }), + isFetching: false, + isError: true, + isPending: false, + isSuccess: false, + refetch: vi.fn(), + } + + render( + + + , + ) + + fireEvent.click(screen.getByRole('button', { name: 'send build message' })) + + await waitFor(() => { + expect(toastMock.error).toHaveBeenCalledWith('common.modelProvider.selectModel') + }) + expect(mocks.checkoutBuildDraft).not.toHaveBeenCalled() + expect(screen.getByRole('region', { name: 'build-chat' })).toHaveTextContent('sent:no') + expect(screen.getByRole('region', { name: 'orchestrate-panel' })).toHaveTextContent('buildDraft:no') + }) + it('should keep the build draft bar disabled while a build conversation is responding', async () => { vi.useFakeTimers() const queryClient = new QueryClient() diff --git a/web/features/agent-v2/agent-detail/configure/__tests__/use-agent-configure-sync.spec.tsx b/web/features/agent-v2/agent-detail/configure/__tests__/use-agent-configure-sync.spec.tsx index ad402fb2e38..55148b316b3 100644 --- a/web/features/agent-v2/agent-detail/configure/__tests__/use-agent-configure-sync.spec.tsx +++ b/web/features/agent-v2/agent-detail/configure/__tests__/use-agent-configure-sync.spec.tsx @@ -91,6 +91,11 @@ function setDocumentVisibilityState(visibilityState: DocumentVisibilityState) { }) } +const configuredModel = { + provider: 'langgenius/openai/openai', + model: 'gpt-4o-mini', +} + vi.mock('@langgenius/dify-ui/toast', () => ({ toast: toastMock, })) @@ -607,7 +612,9 @@ describe('useAgentConfigureSync', () => { }) it('should publish only when publishDraft is called explicitly', async () => { - const { queryClient, result, store } = renderUseAgentConfigureSync() + const { queryClient, result, store } = renderUseAgentConfigureSync({ + currentModel: configuredModel, + }) const invalidateQueries = vi.spyOn(queryClient, 'invalidateQueries') queryClient.setQueryData(['agent-detail', 'agent-1'], { active_config_is_published: false, @@ -654,12 +661,28 @@ describe('useAgentConfigureSync', () => { expect(toastMock.success).toHaveBeenCalledWith('common.api.actionSuccess') }) + it('should toast and skip publish when no model is configured', async () => { + const { result, store } = renderUseAgentConfigureSync() + + act(() => { + store.set(agentComposerDraftAtom, { + ...defaultAgentSoulConfigFormState, + prompt: 'Published prompt', + }) + }) + + await act(async () => { + await result.current.publishDraft() + }) + + expect(composerPutMutationFn).not.toHaveBeenCalled() + expect(publishAgentMutationFn).not.toHaveBeenCalled() + expect(toastMock.error).toHaveBeenCalledWith('common.modelProvider.selectModel') + }) + it('should keep default model fallback from creating unpublished changes after publish', async () => { const { result, store } = renderUseAgentConfigureSync({ - currentModel: { - provider: 'langgenius/openai/openai', - model: 'gpt-4o-mini', - }, + currentModel: configuredModel, }) act(() => { store.set(agentComposerDraftAtom, { @@ -681,6 +704,7 @@ describe('useAgentConfigureSync', () => { it('should keep base config fallback fields from creating unpublished changes after publish', async () => { const { result, store } = renderUseAgentConfigureSync({ + currentModel: configuredModel, baseConfig: { app_features: { file_upload: { @@ -708,7 +732,9 @@ describe('useAgentConfigureSync', () => { }) it('should publish the current draft snapshot instead of a stale caller payload', async () => { - const { result, store } = renderUseAgentConfigureSync() + const { result, store } = renderUseAgentConfigureSync({ + currentModel: configuredModel, + }) act(() => { store.set(agentComposerDraftAtom, { @@ -736,7 +762,9 @@ describe('useAgentConfigureSync', () => { it('should reject publish and keep the publish mutation untouched when saving the draft fails', async () => { composerPutMutationFn.mockRejectedValueOnce(new Error('save failed')) - const { queryClient, result, store } = renderUseAgentConfigureSync() + const { queryClient, result, store } = renderUseAgentConfigureSync({ + currentModel: configuredModel, + }) queryClient.setQueryData(['agent-detail', 'agent-1'], { active_config_is_published: false, name: 'Agent', @@ -760,7 +788,9 @@ describe('useAgentConfigureSync', () => { }) it('should toast and skip publish when knowledge retrieval validation fails', async () => { - const { result, store } = renderUseAgentConfigureSync() + const { result, store } = renderUseAgentConfigureSync({ + currentModel: configuredModel, + }) act(() => { store.set(agentComposerDraftAtom, { @@ -785,7 +815,9 @@ describe('useAgentConfigureSync', () => { }) it('should toast metadata filtering model error when publishing with automatic metadata filtering and no model', async () => { - const { result, store } = renderUseAgentConfigureSync() + const { result, store } = renderUseAgentConfigureSync({ + currentModel: configuredModel, + }) act(() => { store.set(agentComposerDraftAtom, { @@ -813,7 +845,9 @@ describe('useAgentConfigureSync', () => { it('should expose publishing status from the publish mutation while publish is pending', async () => { const publishDeferred = createDeferredPromise() publishAgentMutationFn.mockReturnValueOnce(publishDeferred.promise) - const { result } = renderUseAgentConfigureSync() + const { result } = renderUseAgentConfigureSync({ + currentModel: configuredModel, + }) let publishPromise!: Promise act(() => { publishPromise = result.current.publishDraft() diff --git a/web/features/agent-v2/agent-detail/configure/components/__tests__/agent-prompt-editor.spec.tsx b/web/features/agent-v2/agent-detail/configure/components/__tests__/agent-prompt-editor.spec.tsx index e2165c288c5..5447ab28e4e 100644 --- a/web/features/agent-v2/agent-detail/configure/components/__tests__/agent-prompt-editor.spec.tsx +++ b/web/features/agent-v2/agent-detail/configure/components/__tests__/agent-prompt-editor.spec.tsx @@ -16,6 +16,42 @@ const mockPromptEditor = vi.hoisted(() => vi.fn()) const mockCopy = vi.hoisted(() => vi.fn()) const mockReset = vi.hoisted(() => vi.fn()) const mockUseClipboard = vi.hoisted(() => vi.fn()) +const mockConfigFiles = vi.hoisted(() => ({ + current: [] as Array<{ + id: string + name: string + driveKey?: string + children?: Array<{ + id: string + name: string + driveKey?: string + }> + }>, +})) +const mockLexical = vi.hoisted(() => ({ + selection: null as null | { + __range: true + isCollapsed: () => boolean + anchor: { + getNode: () => { + __text: true + getKey: () => string + getTextContent: () => string + getTextContentSize: () => number + select: (anchorOffset: number, focusOffset: number) => void + } + offset: number + } + }, + rootChildren: [] as Array<{ + __text: true + getKey: () => string + getTextContent: () => string + getTextContentSize: () => number + select: (anchorOffset: number, focusOffset: number) => void + }>, + rootSelectEnd: vi.fn(), +})) const mockBuiltInTools = vi.hoisted(() => [ { id: 'duckduckgo', @@ -62,11 +98,37 @@ vi.mock('@/app/components/base/prompt-editor', () => ({ return (
+ {props.children}
) }, })) +vi.mock('@lexical/react/LexicalComposerContext', () => ({ + useLexicalComposerContext: () => [{ + focus: (callback: () => void) => callback(), + getEditorState: () => ({ + read: (callback: () => void) => callback(), + }), + registerCommand: () => vi.fn(), + registerUpdateListener: () => vi.fn(), + update: (callback: () => void) => callback(), + }], +})) + +vi.mock('lexical', () => ({ + $getRoot: () => ({ + getChildren: () => mockLexical.rootChildren, + selectEnd: mockLexical.rootSelectEnd, + }), + $getSelection: () => mockLexical.selection, + $isElementNode: (node: { __element?: boolean } | null | undefined) => !!node?.__element, + $isRangeSelection: (selection: { __range?: boolean } | null | undefined) => !!selection?.__range, + $isTextNode: (node: { __text?: boolean } | null | undefined) => !!node?.__text, + COMMAND_PRIORITY_LOW: 1, + SELECTION_CHANGE_COMMAND: Symbol('selection-change-command'), +})) + vi.mock('@/app/components/base/infotip', () => ({ Infotip: ({ children }: { children: ReactNode }) => {children}, })) @@ -103,10 +165,11 @@ vi.mock('../orchestrate/config-context', () => ({ { id: 'playwright', name: 'Playwright', + skillMdKey: 'skills/playwright/SKILL.md', }, ], }), - useAgentConfigFiles: () => ({ files: [] }), + useAgentConfigFiles: () => ({ files: mockConfigFiles.current }), })) const duckDuckGoSearchAction = { @@ -167,6 +230,10 @@ const renderAgentPromptEditor = ( describe('AgentPromptEditor', () => { beforeEach(() => { vi.clearAllMocks() + mockConfigFiles.current = [] + mockLexical.selection = null + mockLexical.rootChildren = [] + mockLexical.rootSelectEnd.mockClear() mockUseClipboard.mockReturnValue({ copied: false, copy: mockCopy, @@ -290,6 +357,40 @@ describe('AgentPromptEditor', () => { expect(container.querySelector('.i-ri-terminal-box-line')).not.toBeInTheDocument() }) + + it('should warn only for prompt references missing from the current configuration', () => { + mockConfigFiles.current = [{ + id: 'folder', + name: 'Folder', + children: [{ + id: 'file-1', + name: 'Spec.md', + driveKey: 'drive/spec.md', + }], + }] + renderAgentPromptEditor('Review these tenders', { + knowledgeRetrievals: [{ id: 'retrieval-1', name: 'Release Notes' }], + tools: [ + duckDuckGoProviderTool, + { id: 'cli-1', kind: 'cli', name: 'Lark CLI' }, + ], + }) + + const promptEditorProps = mockPromptEditor.mock.calls.at(-1)?.[0] as PromptEditorProps + const getWarning = promptEditorProps.rosterReferenceBlock?.getWarning + expect(getWarning).toBeDefined() + + expect(getWarning?.({ kind: 'skill', id: 'skills%2Fplaywright%2FSKILL.md', label: 'Playwright' })).toBeUndefined() + expect(getWarning?.({ kind: 'file', id: 'drive%2Fspec.md', label: 'Spec.md' })).toBeUndefined() + expect(getWarning?.({ kind: 'knowledge', id: 'retrieval-1', label: 'Release Notes' })).toBeUndefined() + expect(getWarning?.({ kind: 'tool', id: 'duckduckgo/ddg_search', label: 'DuckDuckGo Search' })).toBeUndefined() + expect(getWarning?.({ kind: 'tool-all', id: 'duckduckgo/*', label: 'DuckDuckGo' })).toBeUndefined() + + expect(getWarning?.({ kind: 'skill', id: 'missing-skill', label: 'Missing Skill' })).toContain('agentDetail.configure.prompt.referenceMissing') + expect(getWarning?.({ kind: 'file', id: 'missing-file', label: 'Missing File' })).toContain('agentDetail.configure.prompt.referenceMissing') + expect(getWarning?.({ kind: 'knowledge', id: 'missing-retrieval', label: 'Missing Retrieval' })).toContain('agentDetail.configure.prompt.referenceMissing') + expect(getWarning?.({ kind: 'tool', id: 'missing/action', label: 'Missing Tool' })).toContain('agentDetail.configure.prompt.referenceMissing') + }) }) // Prompt slash commands should use the Agent Roster category menu and replace it with submenus. @@ -320,6 +421,35 @@ describe('AgentPromptEditor', () => { }) }) + it('should replace the slash at the current lexical selection instead of appending', async () => { + const textNode = { + __text: true as const, + getKey: () => 'text-node', + getTextContent: () => 'Review / now', + getTextContentSize: () => 'Review / now'.length, + select: vi.fn(), + } + mockLexical.rootChildren = [textNode] + mockLexical.selection = { + __range: true, + isCollapsed: () => true, + anchor: { + getNode: () => textNode, + offset: 'Review /'.length, + }, + } + const { store } = renderAgentPromptEditor('Review / now') + + fireEvent.keyDown(screen.getByRole('textbox'), { key: '/' }) + fireEvent.click(screen.getByRole('button', { name: /agentDetail\.configure\.skills\.label/i })) + fireEvent.click(screen.getByRole('button', { name: /Playwright/i })) + + expect(store.get(agentComposerPromptAtom)).toBe('Review [§skill:playwright:Playwright§] now') + await waitFor(() => { + expect(mockLexical.rootSelectEnd).toHaveBeenCalled() + }) + }) + it('should insert slash from the focused footer insert action', () => { const { store } = renderAgentPromptEditor('Review these tenders') @@ -350,7 +480,7 @@ describe('AgentPromptEditor', () => { skills={[]} files={[]} tools={[]} - onToolsChange={vi.fn()} + onAddProviderTools={vi.fn()} onAddSkill={options => options?.onAdded?.({ id: 'skill-1', name: 'Skill One' })} retrievals={[]} onBack={vi.fn()} @@ -368,7 +498,7 @@ describe('AgentPromptEditor', () => { skills={[]} files={[]} tools={[]} - onToolsChange={vi.fn()} + onAddProviderTools={vi.fn()} onAddFile={options => options?.onAdded?.({ id: 'file-1', name: 'Guide.md', icon: 'markdown', configName: 'Guide.md' })} retrievals={[]} onBack={vi.fn()} @@ -386,7 +516,7 @@ describe('AgentPromptEditor', () => { skills={[]} files={[]} tools={[]} - onToolsChange={vi.fn()} + onAddProviderTools={vi.fn()} onAddKnowledge={options => options?.onAdded?.({ id: 'retrieval-1', name: 'Retrieval One', queryMode: 'agent' })} retrievals={[]} onBack={vi.fn()} @@ -404,7 +534,7 @@ describe('AgentPromptEditor', () => { skills={[]} files={[]} tools={[]} - onToolsChange={vi.fn()} + onAddProviderTools={vi.fn()} onAddCliTool={options => options?.onAdded?.({ id: 'cli-1', kind: 'cli', name: 'Lark CLI' })} retrievals={[]} onBack={vi.fn()} diff --git a/web/features/agent-v2/agent-detail/configure/components/composer-session.tsx b/web/features/agent-v2/agent-detail/configure/components/composer-session.tsx index ac5e8b886ea..9ddf1986d7c 100644 --- a/web/features/agent-v2/agent-detail/configure/components/composer-session.tsx +++ b/web/features/agent-v2/agent-detail/configure/components/composer-session.tsx @@ -2,6 +2,7 @@ import type { AgentAppDetailWithSite, AgentIconType, AgentSoulConfig } from '@dify/contracts/api/console/agent/types.gen' import type { useAgentConfigureData } from '../hooks' +import { toast } from '@langgenius/dify-ui/toast' import { useMutation, useQueryClient } from '@tanstack/react-query' import { useAtomValue, useSetAtom } from 'jotai' import { ScopeProvider } from 'jotai-scope' @@ -214,6 +215,7 @@ function AgentConfigurePageComposerContent({ activeConfigSnapshot, agentSoulConfig, } = configureData + const { t: tCommon } = useTranslation('common') const [buildDraftActionsDisabled, setBuildDraftActionsDisabled] = useState(false) const [clearPreviewChat, setClearPreviewChat] = useState(false) const [completedBuildConversationId, setCompletedBuildConversationId] = useState(null) @@ -333,6 +335,7 @@ function AgentConfigurePageComposerContent({ isBuildDraftActive={buildDraft.isActive} buildDraftChangedKeys={buildDraft.changedKeys} showPublishBar={!buildDraft.isActive} + workflowReferencesEnabled={agentQuery.isSuccess} bottomAction={showBuildDraftBar ? ( { + if (!currentModel?.provider || !currentModel.model) { + toast.error(tCommon('modelProvider.selectModel')) + throw new Error('Agent model is required.') + } + setBuildDraftActionsDisabled(true) try { return await buildDraftActions.prepareBuildDraftBeforeRun() diff --git a/web/features/agent-v2/agent-detail/configure/components/orchestrate/__tests__/publish-bar.spec.tsx b/web/features/agent-v2/agent-detail/configure/components/orchestrate/__tests__/publish-bar.spec.tsx index a2400bc8e72..b5782ad795f 100644 --- a/web/features/agent-v2/agent-detail/configure/components/orchestrate/__tests__/publish-bar.spec.tsx +++ b/web/features/agent-v2/agent-detail/configure/components/orchestrate/__tests__/publish-bar.spec.tsx @@ -74,8 +74,9 @@ vi.mock('@/service/client', () => ({ }, referencingWorkflows: { get: { - queryOptions: ({ input }: { input: { params: { agent_id: string } } }) => ({ + queryOptions: ({ enabled = true, input }: { enabled?: boolean, input: { params: { agent_id: string } } }) => ({ queryKey: ['agent-referencing-workflows', input], + enabled, queryFn: async () => ({ data: (workflowReferences.fetchCount++, workflowReferences.data), }), @@ -162,6 +163,7 @@ function renderPublishBar({ selectedVersionSnapshot, setupStore, usedByAppReferences = [], + workflowReferencesEnabled, }: { activeConfigIsPublished?: boolean activeConfigSnapshot?: AgentConfigSnapshotSummaryResponse | null @@ -173,6 +175,7 @@ function renderPublishBar({ selectedVersionSnapshot?: AgentConfigSnapshotSummaryResponse | null setupStore?: (store: ReturnType) => void usedByAppReferences?: AgentReferencingWorkflowResponse[] + workflowReferencesEnabled?: boolean } = {}) { workflowReferences.data = usedByAppReferences const queryClient = new QueryClient({ @@ -200,6 +203,7 @@ function renderPublishBar({ agentName="Iris" isPublishing={nextProps?.isPublishing ?? isPublishing} selectedVersionSnapshot={selectedVersionSnapshot} + workflowReferencesEnabled={workflowReferencesEnabled} onPublish={onPublish} onExitVersions={onExitVersions} onOpenVersions={vi.fn()} @@ -407,6 +411,28 @@ describe('AgentConfigurePublishBar', () => { }) }) + it('should publish without loading workflow references when references are disabled', async () => { + const { onPublish } = renderPublishBar({ + activeConfigSnapshot, + prompt: 'Updated system prompt', + usedByAppReferences: publishedReferences, + workflowReferencesEnabled: false, + }) + + await waitFor(() => { + expect(workflowReferences.fetchCount).toBe(0) + }) + fireEvent.click(screen.getByRole('button', { name: /agentV2\.agentDetail\.configure\.publishBar\.publishUpdate/ })) + + await waitFor(() => { + expect(onPublish).toHaveBeenCalledTimes(1) + }) + expect(workflowReferences.fetchCount).toBe(0) + expect(screen.queryByRole('region', { + name: /agentV2\.agentDetail\.configure\.publishImpact\.title/, + })).not.toBeInTheDocument() + }) + it('should mark non-prompt draft changes as unpublished', () => { renderPublishBar({ activeConfigSnapshot, diff --git a/web/features/agent-v2/agent-detail/configure/components/orchestrate/advanced/env.tsx b/web/features/agent-v2/agent-detail/configure/components/orchestrate/advanced/env.tsx index 555c8e5185d..ddd7da61d09 100644 --- a/web/features/agent-v2/agent-detail/configure/components/orchestrate/advanced/env.tsx +++ b/web/features/agent-v2/agent-detail/configure/components/orchestrate/advanced/env.tsx @@ -7,10 +7,18 @@ import { Input } from '@langgenius/dify-ui/input' import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select' import { toast } from '@langgenius/dify-ui/toast' import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' -import { useAtom } from 'jotai' +import { useAtomValue, useSetAtom } from 'jotai' import { useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' -import { agentComposerEnvVariablesAtom } from '@/features/agent-v2/agent-composer/store-modules/env' +import { + addEnvVariableAtom, + agentComposerEnvVariablesAtom, + importEnvVariablesAtom, + removeEnvVariableAtom, + setEnvVariableKeyAtom, + setEnvVariableScopeAtom, + setEnvVariableValueAtom, +} from '@/features/agent-v2/agent-composer/store-modules/env' import { checkKeys } from '@/utils/var' import { ConfigureSection } from '../common/section' import { AgentConfigureTipContent } from '../common/tip-content' @@ -410,7 +418,13 @@ export function EnvVariablesTable({ export function AgentEnvEditor() { const { t } = useTranslation('agentV2') const readOnly = useAgentOrchestrateReadOnly() - const [envVariables, setEnvVariables] = useAtom(agentComposerEnvVariablesAtom) + const envVariables = useAtomValue(agentComposerEnvVariablesAtom) + const addEnvVariable = useSetAtom(addEnvVariableAtom) + const importEnvVariables = useSetAtom(importEnvVariablesAtom) + const removeEnvVariable = useSetAtom(removeEnvVariableAtom) + const setEnvVariableKey = useSetAtom(setEnvVariableKeyAtom) + const setEnvVariableScope = useSetAtom(setEnvVariableScopeAtom) + const setEnvVariableValue = useSetAtom(setEnvVariableValueAtom) const starterVariableRef = useRef(undefined) if (!starterVariableRef.current) starterVariableRef.current = createEnvVariable() @@ -422,20 +436,6 @@ export function AgentEnvEditor() { const envEditorTableId = 'agent-configure-env-editor-table' const visibleEnvVariables = envVariables.length > 0 ? envVariables : [starterVariable] - const updateVariable = (id: string, updater: (variable: EnvVariable) => EnvVariable) => { - const existingVariable = envVariables.find(variable => variable.id === id) - - if (existingVariable) { - setEnvVariables(envVariables.map(variable => ( - variable.id === id ? updater(variable) : variable - ))) - return - } - - if (id === starterVariable.id) - setEnvVariables([updater(starterVariable)]) - } - const addVariable = ({ focusField = 'key', scope, @@ -448,13 +448,13 @@ export function AgentEnvEditor() { ...(scope ? { scope } : {}), } - setEnvVariables([ - ...(envVariables.length > 0 ? envVariables : [starterVariable]), + addEnvVariable({ + starterVariable, variable, - ]) + }) setFocusedVariable({ id: variable.id, field: focusField }) } - const importEnvVariables = async (file: File) => { + const handleImportEnvVariables = async (file: File) => { const { invalidLineCount, variables, @@ -470,19 +470,19 @@ export function AgentEnvEditor() { if (importedVariables.length === 0) return - setEnvVariables([...envVariables, ...importedVariables]) + importEnvVariables(importedVariables) } const updateVariableKey = (id: string, key: string) => { - updateVariable(id, variable => ({ ...variable, key })) + setEnvVariableKey({ id, key, starterVariable }) } const updateVariableScope = (id: string, scope: EnvScope) => { - updateVariable(id, variable => ({ ...variable, scope })) + setEnvVariableScope({ id, scope, starterVariable }) } const updateVariableValue = (id: string, value: string) => { - updateVariable(id, variable => ({ ...variable, value })) + setEnvVariableValue({ id, starterVariable, value }) } const deleteVariable = (id: string) => { - setEnvVariables(envVariables.filter(variable => variable.id !== id)) + removeEnvVariable(id) } return ( @@ -508,7 +508,7 @@ export function AgentEnvEditor() { event.target.value = '' if (file) - void importEnvVariables(file) + void handleImportEnvVariables(file) }} /> diff --git a/web/features/agent-v2/agent-detail/configure/components/orchestrate/files/__tests__/index.spec.tsx b/web/features/agent-v2/agent-detail/configure/components/orchestrate/files/__tests__/index.spec.tsx index 028bd17f304..3919244a6bf 100644 --- a/web/features/agent-v2/agent-detail/configure/components/orchestrate/files/__tests__/index.spec.tsx +++ b/web/features/agent-v2/agent-detail/configure/components/orchestrate/files/__tests__/index.spec.tsx @@ -3,7 +3,7 @@ import type { AgentConfigApiContext } from '../../config-context' import type { AgentSoulConfigFormState } from '@/features/agent-v2/agent-composer/form-state' import { toast } from '@langgenius/dify-ui/toast' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' -import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { fireEvent, render, screen, waitFor, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { useAtomValue } from 'jotai' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -39,6 +39,8 @@ const mocks = vi.hoisted(() => ({ deleteFileMutationFn: vi.fn(async (_input: unknown) => ({ removed_names: ['brief.md'], result: 'success' })), previewQueryOptions: vi.fn((_options: ConfigFileQueryOptionsInput) => ({})), downloadQueryOptions: vi.fn((_options: ConfigFileQueryOptionsInput) => ({})), + downloadBlob: vi.fn(), + downloadUrl: vi.fn(), })) vi.mock('@langgenius/dify-ui/toast', () => ({ @@ -48,6 +50,11 @@ vi.mock('@langgenius/dify-ui/toast', () => ({ }, })) +vi.mock('@/utils/download', () => ({ + downloadBlob: mocks.downloadBlob, + downloadUrl: mocks.downloadUrl, +})) + vi.mock('@/service/client', () => ({ consoleQuery: { agent: { @@ -368,6 +375,57 @@ describe('AgentFiles', () => { }) }) + it('should download configured files from the row action by config name', async () => { + const user = userEvent.setup() + renderAgentFiles() + + await user.click(screen.getByRole('button', { + name: /agentV2\.agentDetail\.configure\.files\.download.*diagram\.png/, + })) + + await waitFor(() => { + expect(mocks.downloadQueryOptions).toHaveBeenCalledWith(expect.objectContaining({ + input: expect.objectContaining({ + params: { + agent_id: 'agent-1', + name: 'diagram.png', + }, + }), + })) + }) + expect(mocks.downloadUrl).toHaveBeenCalledWith({ + url: 'https://example.com/diagram.png', + fileName: 'diagram.png', + }) + }) + + it('should download the selected file from the preview header action', async () => { + const user = userEvent.setup() + renderAgentFiles() + + await user.click(screen.getByText('diagram.png').closest('button')!) + const dialog = await screen.findByRole('dialog') + + await user.click(within(dialog).getByRole('button', { + name: /common\.operation\.download.*diagram\.png/, + })) + + await waitFor(() => { + expect(mocks.downloadQueryOptions).toHaveBeenCalledWith(expect.objectContaining({ + input: expect.objectContaining({ + params: { + agent_id: 'agent-1', + name: 'diagram.png', + }, + }), + })) + }) + expect(mocks.downloadUrl).toHaveBeenCalledWith({ + url: 'https://example.com/diagram.png', + fileName: 'diagram.png', + }) + }) + it('should show config note as a virtual build note file and preview its content locally', async () => { const user = userEvent.setup() renderAgentFiles({ @@ -399,15 +457,65 @@ describe('AgentFiles', () => { })) }) + it('should download the virtual build note file as markdown content', async () => { + const user = userEvent.setup() + renderAgentFiles({ + initialDraft: createInitialDraft({ configNote: 'Build context from the latest build chat.' }), + }) + + await user.click(screen.getByRole('button', { + name: /agentV2\.agentDetail\.configure\.files\.download.*build_note\.md/, + })) + + expect(mocks.downloadBlob).toHaveBeenCalledWith({ + data: expect.any(Blob), + fileName: 'build_note.md', + }) + const blob = mocks.downloadBlob.mock.calls[0]?.[0].data as Blob + await expect(blob.text()).resolves.toBe('Build context from the latest build chat.') + expect(mocks.downloadQueryOptions).not.toHaveBeenCalledWith(expect.objectContaining({ + input: expect.objectContaining({ + params: expect.objectContaining({ + name: 'build_note.md', + }), + }), + })) + }) + + it('should download the virtual build note from the preview header action', async () => { + const user = userEvent.setup() + renderAgentFiles({ + initialDraft: createInitialDraft({ configNote: 'Build context from the latest build chat.' }), + }) + + await user.click(screen.getByText('build_note.md').closest('button')!) + const dialog = await screen.findByRole('dialog') + + await user.click(within(dialog).getByRole('button', { + name: /common\.operation\.download.*build_note\.md/, + })) + + expect(mocks.downloadBlob).toHaveBeenCalledWith({ + data: expect.any(Blob), + fileName: 'build_note.md', + }) + const blob = mocks.downloadBlob.mock.calls[0]?.[0].data as Blob + await expect(blob.text()).resolves.toBe('Build context from the latest build chat.') + }) + it('should show generated build note metadata with an explanatory infotip', async () => { const user = userEvent.setup() renderAgentFiles({ initialDraft: createInitialDraft({ configNote: 'Build context from the latest build chat.' }), }) - expect(screen.getByText('agentV2.agentDetail.configure.files.buildNote.generated')).toBeInTheDocument() + const generatedBadge = screen.getByText('agentV2.agentDetail.configure.files.buildNote.generated') + const buildNoteRow = generatedBadge.closest('li') - await user.click(screen.getByRole('button', { name: 'agentV2.agentDetail.configure.files.buildNote.tooltip' })) + expect(generatedBadge).toBeInTheDocument() + expect(buildNoteRow).not.toBeNull() + + await user.click(within(buildNoteRow!).getByRole('button', { name: 'agentV2.agentDetail.configure.files.buildNote.tooltip' })) expect(await screen.findByText('agentDetail.configure.files.buildNote.richTooltip')).toBeInTheDocument() }) diff --git a/web/features/agent-v2/agent-detail/configure/components/orchestrate/files/index.tsx b/web/features/agent-v2/agent-detail/configure/components/orchestrate/files/index.tsx index da9fa0bbc35..1c13730d78b 100644 --- a/web/features/agent-v2/agent-detail/configure/components/orchestrate/files/index.tsx +++ b/web/features/agent-v2/agent-detail/configure/components/orchestrate/files/index.tsx @@ -1,10 +1,9 @@ 'use client' -import type { ReactNode } from 'react' +import type { MouseEvent, ReactNode } from 'react' import type { AgentOrchestrateAddActionOptions } from '../add-actions-context' import type { AgentConfigApiContext } from '../config-context' import type { AgentFileNode } from '@/features/agent-v2/agent-composer/form-state' -import { cn } from '@langgenius/dify-ui/cn' import { Dialog, DialogTrigger, @@ -15,15 +14,21 @@ import { FileTreeIcon, FileTreeLabel, } from '@langgenius/dify-ui/file-tree' -import { useMutation, useQuery } from '@tanstack/react-query' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useAtomValue, useSetAtom } from 'jotai' import { useCallback, useRef, useState } from 'react' import { Trans, useTranslation } from 'react-i18next' import { Infotip } from '@/app/components/base/infotip' import { useDocLink } from '@/context/i18n' import { agentComposerDraftAtom } from '@/features/agent-v2/agent-composer/store' -import { agentComposerFilesAtom } from '@/features/agent-v2/agent-composer/store-modules/files' +import { + agentComposerFilesAtom, + clearAgentConfigNoteAtom, + removeAgentFileAtom, + upsertAgentFileAtom, +} from '@/features/agent-v2/agent-composer/store-modules/files' import { consoleQuery } from '@/service/client' +import { downloadBlob, downloadUrl } from '@/utils/download' import { useRegisterAgentOrchestrateAddAction } from '../add-actions-context' import { ConfigureSectionAddButton } from '../common/add-button' import { DocsLink } from '../common/docs-link' @@ -64,16 +69,6 @@ const findAgentFileNode = (files: AgentFileNode[], fileId: string): AgentFileNod } } -const removeAgentFileNode = (files: AgentFileNode[], fileId: string): AgentFileNode[] => files.flatMap((file) => { - if (file.id === fileId) - return [] - - if (file.children) - return [{ ...file, children: removeAgentFileNode(file.children, fileId) }] - - return [file] -}) - function AgentFileItem({ children, depth, @@ -93,6 +88,7 @@ function AgentFileItem({ }) { const { t } = useTranslation('agentV2') const readOnly = useAgentOrchestrateReadOnly() + const queryClient = useQueryClient() const [isPreviewOpen, setIsPreviewOpen] = useState(false) const [selectedFileId, setSelectedFileId] = useState() const selectedFile = selectedFileId ? findAgentFileNode(files, selectedFileId) : undefined @@ -169,25 +165,71 @@ function AgentFileItem({ const handleRemove = useCallback(() => { onRemove(file.id) }, [file.id, onRemove]) + const downloadFile = useCallback(async (targetFile: AgentFileNode) => { + if (targetFile.virtualContent !== undefined) { + downloadBlob({ + data: new Blob([targetFile.virtualContent], { type: 'text/markdown;charset=utf-8' }), + fileName: targetFile.name, + }) + return + } + + const fileName = getAgentFilePreviewKey(targetFile) + if (apiContext.workflow) { + const result = await queryClient.fetchQuery(consoleQuery.apps.byAppId.agent.config.files.byName.download.get.queryOptions({ + input: { + params: { + app_id: apiContext.workflow.appId, + name: fileName, + }, + query: { + node_id: apiContext.workflow.nodeId, + draft_type: apiContext.draftType, + version_id: apiContext.versionId, + }, + }, + })) + downloadUrl({ url: result.url, fileName: targetFile.name }) + return + } + + const result = await queryClient.fetchQuery(consoleQuery.agent.byAgentId.config.files.byName.download.get.queryOptions({ + input: { + params: { + agent_id: apiContext.agentId, + name: fileName, + }, + query: { + draft_type: apiContext.draftType, + version_id: apiContext.versionId, + }, + }, + })) + downloadUrl({ url: result.url, fileName: targetFile.name }) + }, [apiContext, queryClient]) + const handleDownload = useCallback(async (event: MouseEvent) => { + event.stopPropagation() + await downloadFile(file) + }, [downloadFile, file]) const handlePreviewOpenChange = useCallback((open: boolean) => { if (open) setSelectedFileId(file.id) setIsPreviewOpen(open) }, [file.id]) + const canRemoveFile = !readOnly && (!file.virtualContent || isBuildNoteFile) return ( -
  • +
  • )} > @@ -214,62 +256,73 @@ function AgentFileItem({ isImage: isImagePreviewFile, isLoading: !isVirtualPreviewFile && previewQuery.isPending, }, + onDownloadFile: () => downloadFile(selectedPreviewFile), onSelectFile: selectedFile => setSelectedFileId(selectedFile.id), selectedFileId: selectedFileId ?? file.id, sections: [], }} /> - {isBuildNoteFile && ( - - )} - {!readOnly && (!file.virtualContent || isBuildNoteFile) && ( +
    - )} + {canRemoveFile && ( + + )} +
  • ) } function AgentBuildNoteFileRow() { - const { t } = useTranslation('agentV2') - return ( <> - + {BUILD_NOTE_FILE_NAME} - - - {t('agentDetail.configure.files.buildNote.generated')} - +
    + + +
    ) } -function AgentBuildNoteInfotip({ - className, -}: { - className?: string -}) { +function AgentBuildNoteBadge() { + const { t } = useTranslation('agentV2') + + return ( + + + {t('agentDetail.configure.files.buildNote.generated')} + + ) +} + +function AgentBuildNoteInfotip() { const { t } = useTranslation('agentV2') const docLink = useDocLink() return (

    @@ -293,19 +346,17 @@ export function AgentFiles() { const promptAddCallbackRef = useRef(undefined) const apiContext = useAgentConfigApiContext() const draft = useAtomValue(agentComposerDraftAtom) - const setDraft = useSetAtom(agentComposerDraftAtom) const files = useAtomValue(agentComposerFilesAtom) - const setFiles = useSetAtom(agentComposerFilesAtom) + const clearAgentConfigNote = useSetAtom(clearAgentConfigNoteAtom) + const removeAgentFile = useSetAtom(removeAgentFileAtom) + const upsertAgentFile = useSetAtom(upsertAgentFileAtom) const buildNoteFile = getBuildNoteFile(draft.configNote) const visibleFiles = buildNoteFile ? [buildNoteFile, ...files] : files const { mutate: deleteAgentFile } = useMutation(consoleQuery.agent.byAgentId.config.files.byName.delete.mutationOptions()) const { mutate: deleteWorkflowAgentFile } = useMutation(consoleQuery.apps.byAppId.agent.config.files.byName.delete.mutationOptions()) const removeFile = useCallback((fileId: string) => { if (fileId === BUILD_NOTE_FILE_ID) { - setDraft(draft => ({ - ...draft, - configNote: '', - })) + clearAgentConfigNote() return } @@ -316,7 +367,7 @@ export function AgentFiles() { return const onSuccess = () => { - setFiles(files => removeAgentFileNode(files, fileId)) + removeAgentFile(fileId) } if (apiContext.workflow) { deleteWorkflowAgentFile({ @@ -343,20 +394,17 @@ export function AgentFiles() { version_id: apiContext.versionId, }, }, { onSuccess }) - }, [apiContext, deleteAgentFile, deleteWorkflowAgentFile, files, setDraft, setFiles]) + }, [apiContext, clearAgentConfigNote, deleteAgentFile, deleteWorkflowAgentFile, files, removeAgentFile]) const handleOpenUpload = useCallback((options?: AgentOrchestrateAddActionOptions) => { promptAddCallbackRef.current = options?.onAdded setIsUploadOpen(true) }, []) useRegisterAgentOrchestrateAddAction('files', handleOpenUpload) const handleUploaded = useCallback((file: AgentFileNode) => { - setFiles(files => [ - ...removeAgentFileNode(files, file.id), - file, - ]) + upsertAgentFile(file) promptAddCallbackRef.current?.(file) promptAddCallbackRef.current = undefined - }, [setFiles]) + }, [upsertAgentFile]) const handleUploadOpenChange = useCallback((open: boolean) => { if (!open) promptAddCallbackRef.current = undefined diff --git a/web/features/agent-v2/agent-detail/configure/components/orchestrate/files/tree.tsx b/web/features/agent-v2/agent-detail/configure/components/orchestrate/files/tree.tsx index c1fb78524ea..8f0ddb4421c 100644 --- a/web/features/agent-v2/agent-detail/configure/components/orchestrate/files/tree.tsx +++ b/web/features/agent-v2/agent-detail/configure/components/orchestrate/files/tree.tsx @@ -182,7 +182,7 @@ export function AgentFileTree({ label={label} labelledBy={labelledBy} slotClassNames={{ - viewport: 'max-h-[inherit] overscroll-contain', + viewport: 'max-h-[inherit]', content: 'w-full max-w-full min-w-0!', scrollbar: 'hidden', }} diff --git a/web/features/agent-v2/agent-detail/configure/components/orchestrate/header.tsx b/web/features/agent-v2/agent-detail/configure/components/orchestrate/header.tsx index 269a453028e..85607335da8 100644 --- a/web/features/agent-v2/agent-detail/configure/components/orchestrate/header.tsx +++ b/web/features/agent-v2/agent-detail/configure/components/orchestrate/header.tsx @@ -1,6 +1,7 @@ 'use client' import type { ReactNode } from 'react' +import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' import { useTranslation } from 'react-i18next' type AgentOrchestrateHeaderProps = { @@ -15,6 +16,7 @@ export function AgentOrchestrateHeader({ isBuildDraftActive = false, }: AgentOrchestrateHeaderProps) { const { t } = useTranslation('agentV2') + const communityEditionIsolationTip = t('agentDetail.configure.communityEditionIsolationTip') return (

    @@ -23,6 +25,28 @@ export function AgentOrchestrateHeader({

    {t('agentDetail.configure.title')}

    + + + + + )} + /> + + {communityEditionIsolationTip} + + {isBuildDraftActive && ( {t('agentDetail.configure.buildDraft.modeBadge')} diff --git a/web/features/agent-v2/agent-detail/configure/components/orchestrate/index.tsx b/web/features/agent-v2/agent-detail/configure/components/orchestrate/index.tsx index 894265d0ad6..18869d2b762 100644 --- a/web/features/agent-v2/agent-detail/configure/components/orchestrate/index.tsx +++ b/web/features/agent-v2/agent-detail/configure/components/orchestrate/index.tsx @@ -41,6 +41,7 @@ type AgentOrchestratePanelProps = { className?: string readOnly?: boolean selectedVersionSnapshot?: AgentConfigSnapshotSummaryResponse | null + workflowReferencesEnabled?: boolean isBuildDraftActive?: boolean buildDraftChangedKeys?: readonly AgentBuildDraftChangedKey[] showHeader?: boolean @@ -68,6 +69,7 @@ export function AgentOrchestratePanel({ className, readOnly = false, selectedVersionSnapshot, + workflowReferencesEnabled, isBuildDraftActive = false, buildDraftChangedKeys = [], showHeader = true, @@ -92,6 +94,7 @@ export function AgentOrchestratePanel({ draftSavedAt={draftSavedAt} isPublishing={isPublishing} selectedVersionSnapshot={selectedVersionSnapshot} + workflowReferencesEnabled={workflowReferencesEnabled} onPublish={onPublish} onExitVersions={onExitVersions} onOpenVersions={onOpenVersions} diff --git a/web/features/agent-v2/agent-detail/configure/components/orchestrate/knowledge/index.tsx b/web/features/agent-v2/agent-detail/configure/components/orchestrate/knowledge/index.tsx index c0feb0ed44f..fd95479099c 100644 --- a/web/features/agent-v2/agent-detail/configure/components/orchestrate/knowledge/index.tsx +++ b/web/features/agent-v2/agent-detail/configure/components/orchestrate/knowledge/index.tsx @@ -2,10 +2,15 @@ import type { AgentOrchestrateAddActionOptions } from '../add-actions-context' import type { AgentKnowledgeRetrievalItem } from '@/features/agent-v2/agent-composer/form-state' -import { useAtom } from 'jotai' +import { useAtomValue, useSetAtom } from 'jotai' import { useRef, useState } from 'react' import { useTranslation } from 'react-i18next' -import { agentComposerKnowledgeRetrievalsAtom } from '@/features/agent-v2/agent-composer/store-modules/knowledge' +import { + addKnowledgeRetrievalAtom, + agentComposerKnowledgeRetrievalsAtom, + removeKnowledgeRetrievalAtom, + updateKnowledgeRetrievalAtom, +} from '@/features/agent-v2/agent-composer/store-modules/knowledge' import { useRegisterAgentOrchestrateAddAction } from '../add-actions-context' import { ConfigureSectionAddButton } from '../common/add-button' import { ConfigureSectionConfigurableItem } from '../common/configurable-item' @@ -48,7 +53,10 @@ function AgentKnowledgeRetrievalRow({ export function AgentKnowledgeRetrieval() { const { t } = useTranslation('agentV2') - const [retrievals, setRetrievals] = useAtom(agentComposerKnowledgeRetrievalsAtom) + const retrievals = useAtomValue(agentComposerKnowledgeRetrievalsAtom) + const addKnowledgeRetrieval = useSetAtom(addKnowledgeRetrievalAtom) + const updateKnowledgeRetrieval = useSetAtom(updateKnowledgeRetrievalAtom) + const removeKnowledgeRetrieval = useSetAtom(removeKnowledgeRetrievalAtom) const [isAddDialogOpen, setIsAddDialogOpen] = useState(false) const [addDialogName, setAddDialogName] = useState() const [editingRetrieval, setEditingRetrieval] = useState(null) @@ -57,7 +65,7 @@ export function AgentKnowledgeRetrieval() { const retrievalListId = 'agent-configure-knowledge-retrieval-list' const isDialogOpen = isAddDialogOpen || !!editingRetrieval const updateRetrieval = (nextRetrieval: AgentKnowledgeRetrievalItem) => { - setRetrievals(retrievals.map(retrieval => retrieval.id === nextRetrieval.id ? nextRetrieval : retrieval)) + updateKnowledgeRetrieval(nextRetrieval) setEditingRetrieval(nextRetrieval) } const getDefaultRetrievalName = (index: number) => { @@ -74,7 +82,7 @@ export function AgentKnowledgeRetrieval() { setIsAddDialogOpen(true) } const createRetrieval = (nextRetrieval: AgentKnowledgeRetrievalItem) => { - setRetrievals(current => [...current, nextRetrieval]) + addKnowledgeRetrieval(nextRetrieval) setEditingRetrieval(nextRetrieval) setIsAddDialogOpen(false) addOptionsRef.current?.onAdded?.(nextRetrieval) @@ -110,7 +118,7 @@ export function AgentKnowledgeRetrieval() { setRetrievals(retrievals.filter(retrieval => retrieval.id !== item.id))} + onDelete={() => removeKnowledgeRetrieval(item.id)} onEdit={() => setEditingRetrieval(item)} /> ))} diff --git a/web/features/agent-v2/agent-detail/configure/components/orchestrate/prompt-editor/__tests__/options.spec.ts b/web/features/agent-v2/agent-detail/configure/components/orchestrate/prompt-editor/__tests__/options.spec.ts new file mode 100644 index 00000000000..12af5c3af62 --- /dev/null +++ b/web/features/agent-v2/agent-detail/configure/components/orchestrate/prompt-editor/__tests__/options.spec.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from 'vitest' +import { insertTokenAtTextRange, replaceTrailingSlashWithToken } from '../options' + +describe('prompt editor token replacement', () => { + // Replacing the tracked slash range keeps insertion at the user's caret instead of appending. + describe('insertTokenAtTextRange', () => { + it('should replace a slash in the middle of the prompt and place the cursor after the token', () => { + expect(insertTokenAtTextRange( + 'Review / before replying', + { start: 7, end: 8 }, + '[§file:file-1:Spec§]', + )).toEqual({ + value: 'Review [§file:file-1:Spec§] before replying', + cursorOffset: 'Review [§file:file-1:Spec§]'.length, + }) + }) + + it('should add spacing when the slash is adjacent to text and place the cursor after the spacer', () => { + expect(insertTokenAtTextRange( + 'Review/now', + { start: 6, end: 7 }, + '[§skill:analysis:Analysis§]', + )).toEqual({ + value: 'Review [§skill:analysis:Analysis§] now', + cursorOffset: 'Review [§skill:analysis:Analysis§] '.length, + }) + }) + + it('should clamp out-of-bound ranges before replacing', () => { + expect(insertTokenAtTextRange( + 'Review/', + { start: 6, end: 99 }, + '[§knowledge:kb-1:KB§]', + )).toEqual({ + value: 'Review [§knowledge:kb-1:KB§]', + cursorOffset: 'Review [§knowledge:kb-1:KB§]'.length, + }) + }) + }) + + // Existing fallback behavior is retained for callers that only know about a trailing slash. + describe('replaceTrailingSlashWithToken', () => { + it('should replace a trailing slash', () => { + expect(replaceTrailingSlashWithToken( + 'Review /', + '[§file:file-1:Spec§]', + )).toBe('Review [§file:file-1:Spec§]') + }) + + it('should append when no trailing slash exists', () => { + expect(replaceTrailingSlashWithToken( + 'Review', + '[§file:file-1:Spec§]', + )).toBe('Review [§file:file-1:Spec§]') + }) + }) +}) diff --git a/web/features/agent-v2/agent-detail/configure/components/orchestrate/prompt-editor/index.tsx b/web/features/agent-v2/agent-detail/configure/components/orchestrate/prompt-editor/index.tsx index aef94ad0593..639ebed2b63 100644 --- a/web/features/agent-v2/agent-detail/configure/components/orchestrate/prompt-editor/index.tsx +++ b/web/features/agent-v2/agent-detail/configure/components/orchestrate/prompt-editor/index.tsx @@ -1,6 +1,8 @@ 'use client' +import type { LexicalNode } from 'lexical' import type { KeyboardEvent, MouseEvent, PointerEvent as ReactPointerEvent } from 'react' +import type { TextRange } from './options' import type { SlashMenuCategory, SlashMenuView } from './slash' import type { RosterReferenceToken } from '@/app/components/base/prompt-editor/plugins/roster-reference-block/utils' import type { AgentFileNode, AgentProviderTool, AgentTool } from '@/features/agent-v2/agent-composer/form-state' @@ -8,9 +10,21 @@ import { cn } from '@langgenius/dify-ui/cn' import { Kbd } from '@langgenius/dify-ui/kbd' import { toast } from '@langgenius/dify-ui/toast' import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' +import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext' +import { mergeRegister } from '@lexical/utils' import { useClipboard } from 'foxact/use-clipboard' -import { useAtom, useAtomValue } from 'jotai' +import { useAtom, useAtomValue, useSetAtom } from 'jotai' +import { + $getRoot, + $getSelection, + $isElementNode, + $isRangeSelection, + $isTextNode, + COMMAND_PRIORITY_LOW, + SELECTION_CHANGE_COMMAND, +} from 'lexical' import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react' +import { createPortal } from 'react-dom' import { useTranslation } from 'react-i18next' import { Infotip } from '@/app/components/base/infotip' import PromptEditor from '@/app/components/base/prompt-editor' @@ -18,14 +32,17 @@ import BlockIcon from '@/app/components/workflow/block-icon' import { BlockEnum } from '@/app/components/workflow/types' import { agentComposerKnowledgeRetrievalsAtom } from '@/features/agent-v2/agent-composer/store-modules/knowledge' import { agentComposerPromptAtom } from '@/features/agent-v2/agent-composer/store-modules/prompt' -import { agentComposerToolsAtom } from '@/features/agent-v2/agent-composer/store-modules/tools' +import { + addProviderToolsAtom, + agentComposerToolsAtom, +} from '@/features/agent-v2/agent-composer/store-modules/tools' import { ENABLE_AGENT_CLI_TOOLS } from '@/features/agent-v2/agent-detail/configure/feature-flags' import { useAgentOrchestrateAddActions } from '../add-actions-context' import { AgentConfigureTipContent } from '../common/tip-content' import { useAgentConfigFiles, useAgentConfigSkills } from '../config-context' import { useAgentOrchestrateReadOnly } from '../read-only-context' import { useAgentPromptToolIconResolver } from './hooks' -import { replaceTrailingSlashWithToken } from './options' +import { insertTokenAtTextRange, replaceTrailingSlashWithToken } from './options' import { AgentPromptSlashMenu } from './slash' const subscribeHydrationState = () => () => {} @@ -152,13 +169,232 @@ const isSelectionAfterSlash = (rootElement: HTMLElement | null, fallbackValue: s return previousChild ? getLastTextContent(previousChild).endsWith('/') : false } +/* v8 ignore start -- Lexical selection offsets and DOM range geometry are browser-editor integration glue; user-visible slash insertion behavior is covered by AgentPromptEditor tests. @preserve */ +const getNodeOffset = ( + node: LexicalNode, + anchorNode: LexicalNode, + anchorOffset: number, +): { found: boolean, offset: number } => { + if (node.getKey() === anchorNode.getKey()) + return { found: true, offset: anchorOffset } + + if (!$isElementNode(node)) + return { found: false, offset: node.getTextContent().length } + + let offset = 0 + for (const child of node.getChildren()) { + const childOffset = getNodeOffset(child, anchorNode, anchorOffset) + if (childOffset.found) + return { found: true, offset: offset + childOffset.offset } + + offset += childOffset.offset + } + + return { found: false, offset } +} + +const getSelectionTextOffset = () => { + const selection = $getSelection() + if (!$isRangeSelection(selection) || !selection.isCollapsed()) + return null + + const anchor = selection.anchor + const anchorNode = anchor.getNode() + const root = $getRoot() + let offset = 0 + + for (const child of root.getChildren()) { + const childOffset = getNodeOffset(child, anchorNode, anchor.offset) + if (childOffset.found) + return offset + childOffset.offset + + offset += childOffset.offset + 1 + } + + return null +} + +const readSlashInsertRange = (): TextRange | null => { + const offset = getSelectionTextOffset() + if (!offset) + return null + + const value = $getRoot().getChildren().map(node => node.getTextContent()).join('\n') + if (value[offset - 1] !== '/') + return null + + return { + start: offset - 1, + end: offset, + } +} + +const selectNodeTextOffset = (node: LexicalNode, textOffset: number): boolean => { + if ($isTextNode(node)) { + const offset = Math.max(0, Math.min(textOffset, node.getTextContentSize())) + node.select(offset, offset) + return true + } + + if (!$isElementNode(node)) + return false + + const children = node.getChildren() + let currentOffset = 0 + + for (let index = 0; index < children.length; index++) { + const child = children[index]! + const childLength = child.getTextContent().length + if (textOffset > currentOffset + childLength) { + currentOffset += childLength + continue + } + + if ($isElementNode(child) || $isTextNode(child)) + return selectNodeTextOffset(child, textOffset - currentOffset) + + const childSelectionOffset = textOffset <= currentOffset ? index : index + 1 + node.select(childSelectionOffset, childSelectionOffset) + return true + } + + node.select(children.length, children.length) + return true +} + +const selectTextOffset = (textOffset: number) => { + const root = $getRoot() + let currentOffset = 0 + + for (const child of root.getChildren()) { + const childLength = child.getTextContent().length + if (textOffset <= currentOffset + childLength) { + selectNodeTextOffset(child, textOffset - currentOffset) + return + } + + currentOffset += childLength + 1 + } + + root.selectEnd() +} + +type SelectionRestoreRequest = { + id: number + offset: number +} + +type SlashMenuPosition = { + left: number + top: number +} + +const slashMenuViewportPadding = 8 +const slashMenuMainWidth = 200 +const slashMenuSubmenuWidth = 360 + +const getSlashMenuPosition = (editorElement: HTMLElement): SlashMenuPosition | null => { + const selection = window.getSelection() + if (!selection || !selection.isCollapsed || selection.rangeCount === 0) + return null + + const anchorNode = selection.anchorNode + if (!anchorNode || !editorElement.contains(anchorNode)) + return null + + const range = selection.getRangeAt(0).cloneRange() + let rect: DOMRect | null = null + const rects = range.getClientRects() + if (rects.length) + rect = rects[rects.length - 1]! + else + rect = range.getBoundingClientRect() + + if (!rect || (rect.top === 0 && rect.left === 0 && rect.width === 0 && rect.height === 0)) { + const node = anchorNode.nodeType === Node.ELEMENT_NODE + ? anchorNode as Element + : anchorNode.parentElement + + rect = node?.getBoundingClientRect() ?? editorElement.getBoundingClientRect() + } + + const editorRect = editorElement.getBoundingClientRect() + if (!rect || rect.bottom < editorRect.top || rect.top > editorRect.bottom) + return null + + return { + left: rect.right, + top: rect.bottom + 4, + } +} + +const getSlashMenuLeft = (position: SlashMenuPosition, width: number) => { + if (typeof window === 'undefined') + return position.left + + return Math.max( + slashMenuViewportPadding, + Math.min(position.left, window.innerWidth - width - slashMenuViewportPadding), + ) +} +/* v8 ignore stop */ + +function AgentPromptSelectionBridge({ + restoreRequest, + onSlashRangeChange, +}: { + restoreRequest: SelectionRestoreRequest | null + onSlashRangeChange: (range: TextRange | null) => void +}) { + const [editor] = useLexicalComposerContext() + + useEffect(() => { + const updateSlashRange = () => { + editor.getEditorState().read(() => { + onSlashRangeChange(readSlashInsertRange()) + }) + + return false + } + + updateSlashRange() + + return mergeRegister( + editor.registerCommand( + SELECTION_CHANGE_COMMAND, + updateSlashRange, + COMMAND_PRIORITY_LOW, + ), + editor.registerUpdateListener(({ editorState }) => { + editorState.read(() => { + onSlashRangeChange(readSlashInsertRange()) + }) + }), + ) + }, [editor, onSlashRangeChange]) + + useEffect(() => { + if (!restoreRequest) + return + + editor.focus(() => { + editor.update(() => { + selectTextOffset(restoreRequest.offset) + }) + }) + }, [editor, restoreRequest]) + + return null +} + export function AgentPromptEditor() { const { t } = useTranslation('agentV2') const readOnly = useAgentOrchestrateReadOnly() const [value, setValue] = useAtom(agentComposerPromptAtom) const { skills } = useAgentConfigSkills() const { files } = useAgentConfigFiles() - const [tools, setTools] = useAtom(agentComposerToolsAtom) + const tools = useAtomValue(agentComposerToolsAtom) + const addProviderTools = useSetAtom(addProviderToolsAtom) const { getConfiguredToolIcon } = useAgentPromptToolIconResolver() const retrievals = useAtomValue(agentComposerKnowledgeRetrievalsAtom) const addActions = useAgentOrchestrateAddActions() @@ -178,8 +414,12 @@ export function AgentPromptEditor() { }) const [slashMenuView, setSlashMenuView] = useState('main') const [isSlashMenuOpen, setIsSlashMenuOpen] = useState(false) + const [slashMenuPosition, setSlashMenuPosition] = useState(null) + const [selectionRestoreRequest, setSelectionRestoreRequest] = useState(null) const rootRef = useRef(null) const editorRef = useRef(null) + const slashInsertRangeRef = useRef(null) + const selectionRestoreRequestIdRef = useRef(0) const configuredReferenceIds = useMemo(() => { const skillIds = new Set() skills.forEach((skill) => { @@ -215,23 +455,46 @@ export function AgentPromptEditor() { const closeSlashMenu = () => { setIsSlashMenuOpen(false) + setSlashMenuPosition(null) setSlashMenuView('main') } - const openSlashMenu = () => { + const updateSlashMenuPosition = useCallback(() => { + const editorElement = editorRef.current + if (!editorElement) + return + + const position = getSlashMenuPosition(editorElement) + if (!position) + return + + setSlashMenuPosition(position) + }, []) + + const openSlashMenu = useCallback(() => { setSlashMenuView('main') + updateSlashMenuPosition() setIsSlashMenuOpen(true) - } + }, [updateSlashMenuPosition]) const syncSlashMenuWithSelection = useCallback(() => { if (!isHydrated || readOnly) return - if (isSelectionAfterSlash(editorRef.current, value)) + if (isSelectionAfterSlash(editorRef.current, value)) { + updateSlashMenuPosition() openSlashMenu() - else + } + else { + slashInsertRangeRef.current = null closeSlashMenu() - }, [isHydrated, readOnly, value]) + } + }, [isHydrated, openSlashMenu, readOnly, updateSlashMenuPosition, value]) + + const handleSlashRangeChange = useCallback((range: TextRange | null) => { + if (range) + slashInsertRangeRef.current = range + }, []) const handleEditorKeyDown = (event: KeyboardEvent) => { if (!isHydrated || readOnly) @@ -287,7 +550,25 @@ export function AgentPromptEditor() { } const handleSlashSelect = (token: string) => { - setValue(replaceTrailingSlashWithToken(value, token)) + const slashRange = slashInsertRangeRef.current + let insertionResult + if (slashRange) { + insertionResult = insertTokenAtTextRange(value, slashRange, token) + } + else { + const nextValue = replaceTrailingSlashWithToken(value, token) + insertionResult = { + value: nextValue, + cursorOffset: nextValue.length, + } + } + setValue(insertionResult.value) + slashInsertRangeRef.current = null + selectionRestoreRequestIdRef.current += 1 + setSelectionRestoreRequest({ + id: selectionRestoreRequestIdRef.current, + offset: insertionResult.cursorOffset, + }) closeSlashMenu() } @@ -343,6 +624,13 @@ export function AgentPromptEditor() { if (!(target instanceof Node)) return + if ( + target instanceof Element + && target.closest('[data-agent-prompt-slash-menu]') + ) { + return + } + if (!rootRef.current?.contains(target)) closeSlashMenu() } @@ -375,6 +663,37 @@ export function AgentPromptEditor() { icon: 'i-ri-book-open-line', }, ] + const slashMenuWidth = slashMenuView === 'main' ? slashMenuMainWidth : slashMenuSubmenuWidth + const slashMenu = isHydrated && !readOnly && isSlashMenuOpen + ? createPortal( +
    + setSlashMenuView('main')} + onOpenCategory={setSlashMenuView} + onSelect={handleSlashSelect} + /> +
    , + document.body, + ) + : null return (
    @@ -442,7 +761,12 @@ export function AgentPromptEditor() { }} disableSlashPicker disableBracePicker - /> + > + +
    {!readOnly && (
    - {isHydrated && !readOnly && isSlashMenuOpen && ( -
    - setSlashMenuView('main')} - onOpenCategory={setSlashMenuView} - onSelect={handleSlashSelect} - /> -
    - )} + {slashMenu}
    ) diff --git a/web/features/agent-v2/agent-detail/configure/components/orchestrate/prompt-editor/options.ts b/web/features/agent-v2/agent-detail/configure/components/orchestrate/prompt-editor/options.ts index bc4e0631f2f..79b29430504 100644 --- a/web/features/agent-v2/agent-detail/configure/components/orchestrate/prompt-editor/options.ts +++ b/web/features/agent-v2/agent-detail/configure/components/orchestrate/prompt-editor/options.ts @@ -71,6 +71,20 @@ const appendToken = (value: string, token: string) => { return `${value}${value.endsWith(' ') || value.endsWith('\n') ? '' : ' '}${token}` } +export type TextRange = { + start: number + end: number +} + +export type TokenInsertionResult = { + value: string + cursorOffset: number +} + +const hasTrailingSpace = (value: string) => value.endsWith(' ') || value.endsWith('\n') + +const hasLeadingSpace = (value: string) => value.startsWith(' ') || value.startsWith('\n') + export const replaceTrailingSlashWithToken = (value: string, token: string) => { if (!value.endsWith('/')) return appendToken(value, token) @@ -79,5 +93,19 @@ export const replaceTrailingSlashWithToken = (value: string, token: string) => { if (!valueWithoutSlash) return token - return `${valueWithoutSlash}${valueWithoutSlash.endsWith(' ') || valueWithoutSlash.endsWith('\n') ? '' : ' '}${token}` + return `${valueWithoutSlash}${hasTrailingSpace(valueWithoutSlash) ? '' : ' '}${token}` +} + +export const insertTokenAtTextRange = (value: string, range: TextRange, token: string): TokenInsertionResult => { + const start = Math.max(0, Math.min(range.start, value.length)) + const end = Math.max(start, Math.min(range.end, value.length)) + const prefix = value.slice(0, start) + const suffix = value.slice(end) + const beforeToken = prefix && !hasTrailingSpace(prefix) ? ' ' : '' + const afterToken = suffix && !hasLeadingSpace(suffix) ? ' ' : '' + + return { + value: `${prefix}${beforeToken}${token}${afterToken}${suffix}`, + cursorOffset: prefix.length + beforeToken.length + token.length + afterToken.length, + } } diff --git a/web/features/agent-v2/agent-detail/configure/components/orchestrate/prompt-editor/slash.tsx b/web/features/agent-v2/agent-detail/configure/components/orchestrate/prompt-editor/slash.tsx index 41bd5254582..40e2df9babb 100644 --- a/web/features/agent-v2/agent-detail/configure/components/orchestrate/prompt-editor/slash.tsx +++ b/web/features/agent-v2/agent-detail/configure/components/orchestrate/prompt-editor/slash.tsx @@ -2,11 +2,11 @@ import type { ReactNode } from 'react' import type { AgentOrchestrateAddAction, AgentOrchestrateAddedItem } from '../add-actions-context' -import type { AgentProviderToolDefaultValue } from '../tools/types' import type { Tool } from '@/app/components/tools/types' import type { ToolTypeEnum, ToolValue } from '@/app/components/workflow/block-selector/types' import type { ToolWithProvider } from '@/app/components/workflow/types' import type { AgentFileNode, AgentKnowledgeRetrievalItem, AgentSkill, AgentTool } from '@/features/agent-v2/agent-composer/form-state' +import type { AgentProviderToolDefaultValue } from '@/features/agent-v2/agent-composer/store-modules/tools' import { cn } from '@langgenius/dify-ui/cn' import { FileTreeIcon } from '@langgenius/dify-ui/file-tree' import { useMemo, useState } from 'react' @@ -25,7 +25,6 @@ import { useAllMCPTools, useAllWorkflowTools, } from '@/service/use-tools' -import { addProviderTools } from '../tools/hooks' import { useAgentPromptToolIconResolver } from './hooks' export type SlashMenuView = 'main' | 'skills' | 'files' | 'tools' | 'knowledge' @@ -42,7 +41,7 @@ type AgentPromptSlashMenuProps = { skills: AgentSkill[] files: AgentFileNode[] tools: AgentTool[] - onToolsChange: (tools: AgentTool[]) => void + onAddProviderTools: (tools: AgentProviderToolDefaultValue[]) => void onAddCliTool?: AgentOrchestrateAddAction onAddFile?: AgentOrchestrateAddAction onAddKnowledge?: AgentOrchestrateAddAction @@ -83,7 +82,7 @@ export function AgentPromptSlashMenu({ skills, files, tools, - onToolsChange, + onAddProviderTools, onAddCliTool, onAddFile, onAddKnowledge, @@ -167,7 +166,7 @@ export function AgentPromptSlashMenu({ {view === 'tools' && ( )} @@ -279,11 +278,11 @@ function AgentPromptFileRows({ function AgentPromptToolRows({ configuredTools, - onConfiguredToolsChange, + onAddProviderTools, onSelect, }: { configuredTools: AgentTool[] - onConfiguredToolsChange: (tools: AgentTool[]) => void + onAddProviderTools: (tools: AgentProviderToolDefaultValue[]) => void onSelect: (token: string) => void }) { const { t } = useTranslation('agentV2') @@ -329,7 +328,7 @@ function AgentPromptToolRows({ ] const selectTools = (tools: AgentProviderToolDefaultValue[]) => { - onConfiguredToolsChange(addProviderTools(configuredTools, tools)) + onAddProviderTools(tools) } const toggleProvider = (providerId: string) => { diff --git a/web/features/agent-v2/agent-detail/configure/components/orchestrate/publish-bar/index.tsx b/web/features/agent-v2/agent-detail/configure/components/orchestrate/publish-bar/index.tsx index 9ce15bed43c..2d24fcbf119 100644 --- a/web/features/agent-v2/agent-detail/configure/components/orchestrate/publish-bar/index.tsx +++ b/web/features/agent-v2/agent-detail/configure/components/orchestrate/publish-bar/index.tsx @@ -33,6 +33,7 @@ type AgentConfigurePublishBarProps = { draftSavedAt?: number isPublishing?: boolean selectedVersionSnapshot?: AgentConfigSnapshotSummaryResponse | null + workflowReferencesEnabled?: boolean onPublish?: () => void | Promise onExitVersions?: () => void onOpenVersions?: () => void @@ -90,6 +91,7 @@ export function AgentConfigurePublishBar({ draftSavedAt, isPublishing = false, selectedVersionSnapshot, + workflowReferencesEnabled = true, onPublish, onExitVersions, onOpenVersions, @@ -128,11 +130,9 @@ export function AgentConfigurePublishBar({ agent_id: agentId, }, }, + enabled: workflowReferencesEnabled && publishIsAvailable && !selectedVersionSnapshot, }) - const workflowReferencesQuery = useQuery({ - ...workflowReferencesQueryOptions, - enabled: publishIsAvailable && !selectedVersionSnapshot, - }) + const workflowReferencesQuery = useQuery(workflowReferencesQueryOptions) const restoreVersionMutation = useMutation(consoleQuery.agent.byAgentId.versions.byVersionId.restore.post.mutationOptions()) const canPublish = publishIsAvailable @@ -195,7 +195,9 @@ export function AgentConfigurePublishBar({ } const cachedReferences = queryClient.getQueryData(workflowReferencesQueryOptions.queryKey) - const references = (cachedReferences ?? workflowReferencesQuery.data ?? await queryClient.ensureQueryData(workflowReferencesQueryOptions))?.data ?? [] + const references = workflowReferencesEnabled + ? (cachedReferences ?? workflowReferencesQuery.data ?? await queryClient.ensureQueryData(workflowReferencesQueryOptions))?.data ?? [] + : [] if (references.length > 0) { setPublishBarMode({ status: 'confirmingImpact', references }) diff --git a/web/features/agent-v2/agent-detail/configure/components/orchestrate/skills/__tests__/index.spec.tsx b/web/features/agent-v2/agent-detail/configure/components/orchestrate/skills/__tests__/index.spec.tsx index 3c3815e625d..a9951b441cd 100644 --- a/web/features/agent-v2/agent-detail/configure/components/orchestrate/skills/__tests__/index.spec.tsx +++ b/web/features/agent-v2/agent-detail/configure/components/orchestrate/skills/__tests__/index.spec.tsx @@ -30,6 +30,14 @@ type ConfigSkillFileQueryOptionsInput = { } } +type ConfigSkillDownloadQueryOptionsInput = { + input: { + params: { + name: string + } + } +} + const mocks = vi.hoisted(() => ({ deleteSkillMutationFn: vi.fn(async (_input: unknown) => ({ removed_names: ['Tender Analyzer'], result: 'success' })), uploadSkillMutationFn: vi.fn(async (_input: unknown) => ({ @@ -44,9 +52,12 @@ const mocks = vi.hoisted(() => ({ size: 128, }, })), + skillDownloadQueryOptions: vi.fn((_options: ConfigSkillDownloadQueryOptionsInput) => ({})), inspectQueryOptions: vi.fn((_options: ConfigSkillInspectQueryOptionsInput) => ({})), previewQueryOptions: vi.fn((_options: ConfigSkillFileQueryOptionsInput) => ({})), downloadQueryOptions: vi.fn((_options: ConfigSkillFileQueryOptionsInput) => ({})), + downloadBlob: vi.fn(), + downloadUrl: vi.fn(), })) vi.mock('@langgenius/dify-ui/toast', () => ({ @@ -56,6 +67,11 @@ vi.mock('@langgenius/dify-ui/toast', () => ({ }, })) +vi.mock('@/utils/download', () => ({ + downloadBlob: mocks.downloadBlob, + downloadUrl: mocks.downloadUrl, +})) + vi.mock('@/service/client', () => ({ consoleQuery: { agent: { @@ -71,6 +87,11 @@ vi.mock('@/service/client', () => ({ delete: { mutationOptions: () => ({ mutationFn: mocks.deleteSkillMutationFn }), }, + download: { + get: { + queryOptions: mocks.skillDownloadQueryOptions, + }, + }, inspect: { get: { queryOptions: mocks.inspectQueryOptions, @@ -107,6 +128,11 @@ vi.mock('@/service/client', () => ({ delete: { mutationOptions: () => ({ mutationFn: mocks.deleteSkillMutationFn }), }, + download: { + get: { + queryOptions: mocks.skillDownloadQueryOptions, + }, + }, inspect: { get: { queryOptions: mocks.inspectQueryOptions, @@ -235,6 +261,12 @@ describe('AgentSkills', () => { url: `https://example.com/${input.query.path}`, }), })) + mocks.skillDownloadQueryOptions.mockImplementation(({ input }) => ({ + queryKey: ['download-skill', input], + queryFn: async () => ({ + url: `https://example.com/${input.params.name}.skill`, + }), + })) }) it('should delete a configured skill by config name', async () => { @@ -390,6 +422,69 @@ describe('AgentSkills', () => { }) }) + it('should download a whole skill package from the row action', async () => { + const user = userEvent.setup() + renderAgentSkills() + + await user.click(screen.getByRole('button', { + name: /common\.operation\.download.*Tender Analyzer/, + })) + + await waitFor(() => { + expect(mocks.skillDownloadQueryOptions).toHaveBeenCalledWith(expect.objectContaining({ + input: expect.objectContaining({ + params: { + agent_id: 'agent-1', + name: 'Tender Analyzer', + }, + query: { + draft_type: 'draft', + version_id: undefined, + }, + }), + })) + }) + expect(mocks.downloadUrl).toHaveBeenCalledWith({ + url: 'https://example.com/Tender Analyzer.skill', + fileName: 'Tender Analyzer', + }) + }) + + it('should download a whole workflow skill package with node_id', async () => { + const user = userEvent.setup() + renderAgentSkills({ + apiContext: { + agentId: 'agent-1', + draftType: 'draft', + versionId: 'draft-1', + workflow: { + appId: 'app-1', + nodeId: 'node-1', + }, + }, + }) + + await user.click(screen.getByRole('button', { + name: /common\.operation\.download.*Tender Analyzer/, + })) + + await waitFor(() => { + expect(mocks.skillDownloadQueryOptions).toHaveBeenCalledWith(expect.objectContaining({ + input: expect.objectContaining({ + params: { + app_id: 'app-1', + name: 'Tender Analyzer', + }, + query: { + draft_type: 'draft', + node_id: 'node-1', + version_id: 'draft-1', + }, + }), + })) + }) + }) + it('should inspect skills by config name and preview package members by member path', async () => { const user = userEvent.setup() renderAgentSkills() @@ -425,6 +520,75 @@ describe('AgentSkills', () => { }) }) + it('should wrap long preview lines instead of forcing a horizontal code block', async () => { + const user = userEvent.setup() + renderAgentSkills() + + await user.click(screen.getByText('Tender Analyzer').closest('button')!) + + const skillMdCode = await screen.findByText('# Skill') + expect(skillMdCode.tagName).toBe('CODE') + expect(skillMdCode).toHaveClass('[overflow-wrap:anywhere]') + expect(skillMdCode).toHaveClass('break-words') + expect(skillMdCode).toHaveClass('whitespace-pre-wrap') + expect(skillMdCode).not.toHaveClass('whitespace-pre') + expect(skillMdCode).not.toHaveClass('min-w-max') + }) + + it('should download skill package members from the detail file tree', async () => { + const user = userEvent.setup() + renderAgentSkills() + + await user.click(screen.getByText('Tender Analyzer').closest('button')!) + await user.click(await screen.findByText('references')) + await user.click(screen.getByText('guide.md').closest('button')!) + await user.click(screen.getByRole('button', { + name: /common\.operation\.download.*guide\.md/, + })) + + await waitFor(() => { + expect(mocks.downloadQueryOptions).toHaveBeenCalledWith(expect.objectContaining({ + input: expect.objectContaining({ + params: { + agent_id: 'agent-1', + name: 'Tender Analyzer', + }, + query: expect.objectContaining({ + path: 'references/guide.md', + }), + }), + })) + }) + expect(mocks.downloadUrl).toHaveBeenCalledWith({ + url: 'https://example.com/references/guide.md', + fileName: 'guide.md', + }) + }) + + it('should download inspected SKILL.md content as markdown', async () => { + const user = userEvent.setup() + renderAgentSkills() + + await user.click(screen.getByText('Tender Analyzer').closest('button')!) + await user.click(await screen.findByRole('button', { + name: /common\.operation\.download.*SKILL\.md/, + })) + + expect(mocks.downloadBlob).toHaveBeenCalledWith({ + data: expect.any(Blob), + fileName: 'SKILL.md', + }) + const blob = mocks.downloadBlob.mock.calls[0]?.[0].data as Blob + await expect(blob.text()).resolves.toBe('# Skill\n') + expect(mocks.downloadQueryOptions).not.toHaveBeenCalledWith(expect.objectContaining({ + input: expect.objectContaining({ + query: expect.objectContaining({ + path: 'SKILL.md', + }), + }), + })) + }) + it('should disable add and remove actions when the section is read only', () => { const { container } = renderAgentSkills({ readOnly: true }) diff --git a/web/features/agent-v2/agent-detail/configure/components/orchestrate/skills/detail-dialog.tsx b/web/features/agent-v2/agent-detail/configure/components/orchestrate/skills/detail-dialog.tsx index 36be64434e9..4275689c69d 100644 --- a/web/features/agent-v2/agent-detail/configure/components/orchestrate/skills/detail-dialog.tsx +++ b/web/features/agent-v2/agent-detail/configure/components/orchestrate/skills/detail-dialog.tsx @@ -48,6 +48,7 @@ export type AgentSkillDetail = { } onFolderOpenChange?: (context: { file: AgentSkillFileNode, depth: number, open: boolean }) => void onFolderDoubleClick?: (context: { file: AgentSkillFileNode, depth: number }) => void + onDownloadFile?: () => void onSelectFile?: (file: AgentSkillFileNode) => void renderFolderSuffix?: (context: { file: AgentSkillFileNode, depth: number }) => ReactNode selectedFileId?: string @@ -210,29 +211,25 @@ function AgentFilePreviewContent({ } if (binary) { - if (downloadUrl) { - return ( -
    - - {t('agentDetail.configure.files.preview.unsupported')} - - - - {tCommon('operation.download')} - -
    - ) - } - return ( -

    - {t('agentDetail.configure.files.preview.empty')} -

    + ) } @@ -244,19 +241,27 @@ function AgentFilePreviewContent({ ) } - const lines = content.split('\n') + const lines = content.split('\n').map((line, index) => ({ + content: line, + key: `${index}:${line}`, + lineNumber: String(index + 1).padStart(2, '0'), + })) return ( -
    - -
    -        {content}
    -      
    +
    + {lines.map(line => ( +
    + + + {line.content} + +
    + ))}
    ) } @@ -269,6 +274,7 @@ export function AgentSkillDetailDialog({ detail: AgentSkillDetail }) { const { t } = useTranslation('agentV2') + const { t: tCommon } = useTranslation('common') const previewTitle = detail.filePreview?.fileName return ( @@ -306,7 +312,19 @@ export function AgentSkillDetailDialog({ )}
    - +
    + {detail.onDownloadFile && previewTitle && ( + + )} + +
    (undefined) const apiContext = useAgentConfigApiContext() const skills = useAtomValue(agentComposerSkillsAtom) - const setSkills = useSetAtom(agentComposerSkillsAtom) + const upsertAgentSkill = useSetAtom(upsertAgentSkillAtom) + const removeAgentSkill = useSetAtom(removeAgentSkillAtom) const { mutate: deleteAgentSkill } = useMutation(consoleQuery.agent.byAgentId.config.skills.byName.delete.mutationOptions()) const { mutate: deleteAppSkill } = useMutation(consoleQuery.apps.byAppId.agent.config.skills.byName.delete.mutationOptions()) @@ -36,13 +41,10 @@ export function AgentSkills() { useRegisterAgentOrchestrateAddAction('skills', handleOpenUpload) const handleUploaded = useCallback((skill: AgentSkill) => { - setSkills(skills => [ - ...skills.filter(item => item.id !== skill.id), - skill, - ]) + upsertAgentSkill(skill) promptAddCallbackRef.current?.(skill) promptAddCallbackRef.current = undefined - }, [setSkills]) + }, [upsertAgentSkill]) const handleUploadOpenChange = useCallback((open: boolean) => { if (!open) @@ -56,7 +58,7 @@ export function AgentSkills() { return const onSuccess = () => { - setSkills(skills => skills.filter(item => item.id !== skillId)) + removeAgentSkill(skillId) } if (apiContext.workflow) { deleteAppSkill({ @@ -83,7 +85,7 @@ export function AgentSkills() { version_id: apiContext.versionId, }, }, { onSuccess }) - }, [apiContext, deleteAgentSkill, deleteAppSkill, setSkills, skills]) + }, [apiContext, deleteAgentSkill, deleteAppSkill, removeAgentSkill, skills]) return ( <> diff --git a/web/features/agent-v2/agent-detail/configure/components/orchestrate/skills/item.tsx b/web/features/agent-v2/agent-detail/configure/components/orchestrate/skills/item.tsx index f81e9d5d111..8209782b160 100644 --- a/web/features/agent-v2/agent-detail/configure/components/orchestrate/skills/item.tsx +++ b/web/features/agent-v2/agent-detail/configure/components/orchestrate/skills/item.tsx @@ -6,8 +6,11 @@ import { cn } from '@langgenius/dify-ui/cn' import { Dialog, } from '@langgenius/dify-ui/dialog' +import { useQueryClient } from '@tanstack/react-query' import { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' +import { consoleQuery } from '@/service/client' +import { downloadUrl } from '@/utils/download' import { useAgentOrchestrateReadOnly } from '../read-only-context' import { AgentSkillDetailDialog } from './detail-dialog' import { useAgentSkillDetail } from './use-skill-detail' @@ -22,11 +25,46 @@ export function AgentSkillItem({ onRemove: (skillId: string) => void }) { const { t } = useTranslation('agentV2') + const { t: tCommon } = useTranslation('common') + const queryClient = useQueryClient() const readOnly = useAgentOrchestrateReadOnly() const [isPreviewOpen, setIsPreviewOpen] = useState(false) const handleRemove = useCallback(() => { onRemove(skill.id) }, [onRemove, skill.id]) + const handleDownload = useCallback(async () => { + if (apiContext.workflow) { + const result = await queryClient.fetchQuery(consoleQuery.apps.byAppId.agent.config.skills.byName.download.get.queryOptions({ + input: { + params: { + app_id: apiContext.workflow.appId, + name: skill.name, + }, + query: { + node_id: apiContext.workflow.nodeId, + draft_type: apiContext.draftType, + version_id: apiContext.versionId, + }, + }, + })) + downloadUrl({ url: result.url, fileName: skill.name }) + return + } + + const result = await queryClient.fetchQuery(consoleQuery.agent.byAgentId.config.skills.byName.download.get.queryOptions({ + input: { + params: { + agent_id: apiContext.agentId, + name: skill.name, + }, + query: { + draft_type: apiContext.draftType, + version_id: apiContext.versionId, + }, + }, + })) + downloadUrl({ url: result.url, fileName: skill.name }) + }, [apiContext, queryClient, skill.name]) const handleOpenPreview = useCallback(() => { setIsPreviewOpen(true) }, []) @@ -53,12 +91,23 @@ export function AgentSkillItem({ {t('agentDetail.configure.skills.itemType')} + {!readOnly && ( + )} + /> + + {communityEditionBuildModeTip} + +

    {t('agentDetail.configure.build.empty.description')} diff --git a/web/features/agent-v2/agent-detail/configure/components/preview/chat-features-panel.tsx b/web/features/agent-v2/agent-detail/configure/components/preview/chat-features-panel.tsx index 61d7259d665..92d8c9025ae 100644 --- a/web/features/agent-v2/agent-detail/configure/components/preview/chat-features-panel.tsx +++ b/web/features/agent-v2/agent-detail/configure/components/preview/chat-features-panel.tsx @@ -1,6 +1,6 @@ 'use client' -import type { AgentSoulAppFeaturesConfig } from '@dify/contracts/api/console/agent/types.gen' +import type { AgentSoulAppFeaturesConfig, FileTransferMethod, FileType } from '@dify/contracts/api/console/agent/types.gen' import type { Features } from '@/app/components/base/features/types' import { useCallback, useMemo } from 'react' import { useTranslation } from 'react-i18next' @@ -37,6 +37,41 @@ const defaultFeatureState: Features = { annotationReply: { enabled: false }, } +const agentFileTypes = new Set(['audio', 'custom', 'document', 'image', 'video']) +const agentFileTransferMethods = new Set(['datasource_file', 'local_file', 'remote_url', 'tool_file']) + +function isAgentFileType(value: string): value is FileType { + return agentFileTypes.has(value) +} + +function isAgentFileTransferMethod(value: string): value is FileTransferMethod { + return agentFileTransferMethods.has(value) +} + +function toAgentFileTransferMethods(values?: readonly string[]): FileTransferMethod[] | undefined { + return values?.filter(isAgentFileTransferMethod) +} + +function toAgentFileUploadFeatureConfig(file: Features['file']): AgentSoulAppFeaturesConfig['file_upload'] { + if (!file) + return undefined + + const { allowed_file_types, allowed_file_upload_methods } = file + const fileUpload: Record = { ...file } + delete fileUpload.allowed_file_types + delete fileUpload.allowed_file_upload_methods + + return { + ...fileUpload, + ...(allowed_file_types + ? { allowed_file_types: allowed_file_types.filter(isAgentFileType) } + : {}), + ...(allowed_file_upload_methods + ? { allowed_file_upload_methods: toAgentFileTransferMethods(allowed_file_upload_methods) } + : {}), + } +} + function toPanelFeatures(appFeatures?: AgentSoulAppFeaturesConfig): Features { return { ...defaultFeatureState, @@ -65,7 +100,7 @@ function toAppFeatures(features: Features, appFeatures?: AgentSoulAppFeaturesCon speech_to_text: features.speech2text, retriever_resource: features.citation, sensitive_word_avoidance: features.moderation as AgentSoulAppFeaturesConfig['sensitive_word_avoidance'], - file_upload: features.file, + file_upload: toAgentFileUploadFeatureConfig(features.file), annotation_reply: features.annotationReply, } } diff --git a/web/features/agent-v2/agent-detail/configure/components/preview/working-directory-panel.tsx b/web/features/agent-v2/agent-detail/configure/components/preview/working-directory-panel.tsx index 7e327e9c276..3599a6958b5 100644 --- a/web/features/agent-v2/agent-detail/configure/components/preview/working-directory-panel.tsx +++ b/web/features/agent-v2/agent-detail/configure/components/preview/working-directory-panel.tsx @@ -6,9 +6,10 @@ import type { AgentFileNode } from '@/features/agent-v2/agent-composer/form-stat import { Dialog } from '@langgenius/dify-ui/dialog' import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' import { skipToken, useQueries, useQuery } from '@tanstack/react-query' -import { useState } from 'react' +import { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' import { consoleQuery } from '@/service/client' +import { downloadBlob } from '@/utils/download' import { getFileIconType } from '../orchestrate/files/file-icon' import { AgentSkillDetailDialog } from '../orchestrate/skills/detail-dialog' import { AgentWorkingDirectoryBreadcrumb } from './working-directory-breadcrumb' @@ -431,6 +432,20 @@ export function AgentWorkingDirectoryPanel({ retry: false, }) const isFileReadLoading = !!selectedWorkingDirectoryFile && fileReadQuery.isPending + const { data: fileReadData, refetch: refetchFileRead } = fileReadQuery + const handleDownloadFile = useCallback(async () => { + if (!selectedWorkingDirectoryFile) + return + + const readResult = fileReadData ?? (await refetchFileRead()).data + if (readResult?.binary || readResult?.text === undefined || readResult.text === null) + return + + downloadBlob({ + data: new Blob([readResult.text], { type: 'text/plain;charset=utf-8' }), + fileName: selectedWorkingDirectoryFile.name, + }) + }, [fileReadData, refetchFileRead, selectedWorkingDirectoryFile]) return (

    @@ -485,6 +500,9 @@ export function AgentWorkingDirectoryPanel({ isError: fileListQuery.isError || fileReadQuery.isError, isLoading: isFileListLoading || isFileReadLoading, }, + onDownloadFile: selectedWorkingDirectoryFile && !fileReadQuery.data?.binary + ? handleDownloadFile + : undefined, folderOpenState: ({ file }) => { const queryIndex = loadedFolderPathIndexes.get(file.id) const folderLoaded = queryIndex !== undefined && expandedFolderQueries[queryIndex]?.isSuccess diff --git a/web/features/agent-v2/agent-detail/configure/use-agent-configure-sync.ts b/web/features/agent-v2/agent-detail/configure/use-agent-configure-sync.ts index a3493702cf2..96ee788e0f9 100644 --- a/web/features/agent-v2/agent-detail/configure/use-agent-configure-sync.ts +++ b/web/features/agent-v2/agent-detail/configure/use-agent-configure-sync.ts @@ -237,6 +237,16 @@ export function useAgentConfigureSync({ return const draft = store.get(agentComposerDraftAtom) + const configSnapshot = formStateToAgentSoulConfig({ + baseConfig: baseConfigRef.current, + formState: draft, + currentModel: currentModelRef.current, + }) + if (!configSnapshot.model?.model_provider || !configSnapshot.model.model) { + toast.error(tCommon('modelProvider.selectModel')) + return + } + const knowledgeValidation = validateKnowledgeRetrievals(draft.knowledgeRetrievals) if (!knowledgeValidation.isValid) { toast.error(getKnowledgeValidationMessage(knowledgeValidation.firstIssue?.code) ?? tCommon('api.actionFailed')) @@ -247,11 +257,6 @@ export function useAgentConfigureSync({ setIsPublishInFlight(true) try { debouncedSaveDraft.cancel?.() - const configSnapshot = formStateToAgentSoulConfig({ - baseConfig: baseConfigRef.current, - formState: draft, - currentModel: currentModelRef.current, - }) const saved = await saveComposer({ configSnapshot, draftBaseline: draft, diff --git a/web/features/agent-v2/agent-detail/layout.tsx b/web/features/agent-v2/agent-detail/layout.tsx index b7247064516..1dad110f8af 100644 --- a/web/features/agent-v2/agent-detail/layout.tsx +++ b/web/features/agent-v2/agent-detail/layout.tsx @@ -2,8 +2,10 @@ import type { ReactNode } from 'react' import { useQuery } from '@tanstack/react-query' +import { useEffect } from 'react' import { useTranslation } from 'react-i18next' import useDocumentTitle from '@/hooks/use-document-title' +import { useRouter } from '@/next/navigation' import { consoleQuery } from '@/service/client' type AgentDetailLayoutProps = { @@ -11,11 +13,14 @@ type AgentDetailLayoutProps = { children: ReactNode } +const isNotFoundResponse = (error: unknown) => error instanceof Response && error.status === 404 + export function AgentDetailLayout({ agentId, children, }: AgentDetailLayoutProps) { const { t } = useTranslation('agentV2') + const router = useRouter() const agentQuery = useQuery(consoleQuery.agent.byAgentId.get.queryOptions({ input: { params: { @@ -23,9 +28,18 @@ export function AgentDetailLayout({ }, }, })) + const shouldRedirectToRoster = isNotFoundResponse(agentQuery.error) useDocumentTitle(agentQuery.data?.name ?? t('agentDetail.documentTitle')) + useEffect(() => { + if (shouldRedirectToRoster) + router.replace('/roster') + }, [router, shouldRedirectToRoster]) + + if (shouldRedirectToRoster) + return null + return (
    diff --git a/web/i18n/ar-TN/agent-v-2.json b/web/i18n/ar-TN/agent-v-2.json index c32f0b0dac2..56c75195270 100644 --- a/web/i18n/ar-TN/agent-v-2.json +++ b/web/i18n/ar-TN/agent-v-2.json @@ -63,6 +63,7 @@ "agentDetail.configure.advancedSettings.envEditor.valuePlaceholder": "Value", "agentDetail.configure.advancedSettings.label": "الإعدادات المتقدمة", "agentDetail.configure.advancedSettings.toggle": "تبديل الإعدادات المتقدمة", + "agentDetail.configure.build.empty.communityEditionTip": "توفر Community Edition إدارة إصدارات للتكوينات المستخرجة، لكنها لا تدعم إدارة إصدارات نظام الملفات نفسه. كن حذراً في إجراءاتك ضمن وضع Build، لأن التغييرات التي تُجرى على نظام الملفات تحدث في الوقت الفعلي ولا يمكن دائماً التراجع عنها بشكل منظم. Community Edition ليست الخيار المثالي لخدمة جماهير خارجية واسعة.", "agentDetail.configure.build.empty.description": "صِف ما تريده وسيتم ملء النموذج على اليسار أثناء المحادثة.", "agentDetail.configure.build.empty.title": "ابنِ وكيلك عبر الدردشة", "agentDetail.configure.build.inputPlaceholder": "صِف ما يجب أن يفعله وكيلك", @@ -72,16 +73,18 @@ "agentDetail.configure.buildDraft.changesToApply_other": "{{count}} تغييرات للتطبيق", "agentDetail.configure.buildDraft.discard": "تجاهل", "agentDetail.configure.buildDraft.modeBadge": "وضع البناء", - "agentDetail.configure.buildDraft.modeDescription": "أنت في وضع البناء. شكّل هذا الإعداد عبر الدردشة على اليمين، ثم طبّق.", + "agentDetail.configure.buildDraft.modeDescription": "أنت في وضع البناء. لا يمكن تحديث Configure في هذا الوضع إلا بواسطة الوكيل. شكّل هذا الإعداد عبر الدردشة على اليمين، ثم طبّق.", "agentDetail.configure.buildDraft.rewritten": "أُعيدت صياغته", "agentDetail.configure.buildDraft.title": "مسودة البناء", "agentDetail.configure.buildDraft.updated": "تم التحديث", "agentDetail.configure.chatFeatures.description": "شكّل تجربة الدردشة للمستخدم النهائي على Web app وأسطح الدردشة.", "agentDetail.configure.chatFeatures.title": "ميزات الدردشة", + "agentDetail.configure.communityEditionIsolationTip": "لا توفر Community Edition عزلاً صارماً لنظام الملفات بين المستخدمين النهائيين أو بين عمليات التشغيل. لا تعرض وكيل CE نفسه لعدة مستخدمين نهائيين مستقلين عندما يكون عزل البيانات أو الامتثال الصارم مطلوباً.", "agentDetail.configure.files.add": "إضافة ملف", "agentDetail.configure.files.buildNote.generated": "تم إنشاؤه", "agentDetail.configure.files.buildNote.richTooltip": "سجل الوكيل لما أعدّه في وضع Build. يقرأه في بداية كل محادثة إلى جانب Prompt الخاص بك. معرفة المزيد", "agentDetail.configure.files.buildNote.tooltip": "سجل الوكيل لما أعدّه في وضع Build. يقرأه في بداية كل محادثة إلى جانب Prompt الخاص بك. معرفة المزيد", + "agentDetail.configure.files.download": "تنزيل {{name}}", "agentDetail.configure.files.empty.description": "قم بتحميل المستندات التي يمكن للوكيل قراءتها، مثل المواصفات أو القوالب أو الإرشادات", "agentDetail.configure.files.empty.title": "لا توجد ملفات بعد", "agentDetail.configure.files.label": "الملفات", diff --git a/web/i18n/de-DE/agent-v-2.json b/web/i18n/de-DE/agent-v-2.json index ce433129037..b9c777680ce 100644 --- a/web/i18n/de-DE/agent-v-2.json +++ b/web/i18n/de-DE/agent-v-2.json @@ -63,6 +63,7 @@ "agentDetail.configure.advancedSettings.envEditor.valuePlaceholder": "Value", "agentDetail.configure.advancedSettings.label": "Erweiterte Einstellungen", "agentDetail.configure.advancedSettings.toggle": "Erweiterte Einstellungen umschalten", + "agentDetail.configure.build.empty.communityEditionTip": "Community Edition bietet zwar Versionierung für extrahierte Konfigurationen, unterstützt aber keine Versionierung des Dateisystems selbst. Seien Sie bei Aktionen im Build-Modus vorsichtig, da Änderungen am Dateisystem in Echtzeit erfolgen und nicht immer sauber rückgängig gemacht werden können. Community Edition ist nicht die ideale Wahl, um große externe Zielgruppen zu bedienen.", "agentDetail.configure.build.empty.description": "Beschreiben Sie, was Sie möchten, und das Formular links wird während des Chats ausgefüllt.", "agentDetail.configure.build.empty.title": "Agent per Chat erstellen", "agentDetail.configure.build.inputPlaceholder": "Beschreiben Sie, was Ihr Agent tun soll", @@ -72,16 +73,18 @@ "agentDetail.configure.buildDraft.changesToApply_other": "{{count}} Änderungen anzuwenden", "agentDetail.configure.buildDraft.discard": "Verwerfen", "agentDetail.configure.buildDraft.modeBadge": "Build-Modus", - "agentDetail.configure.buildDraft.modeDescription": "Sie sind im Build-Modus. Formen Sie diese Einrichtung über den Chat rechts und wenden Sie sie dann an.", + "agentDetail.configure.buildDraft.modeDescription": "Sie sind im Build-Modus. Configure kann in diesem Modus nur vom Agenten aktualisiert werden. Formen Sie diese Einrichtung über den Chat rechts und wenden Sie sie dann an.", "agentDetail.configure.buildDraft.rewritten": "Neu geschrieben", "agentDetail.configure.buildDraft.title": "Build-Entwurf", "agentDetail.configure.buildDraft.updated": "Aktualisiert", "agentDetail.configure.chatFeatures.description": "Gestalten Sie das Chat-Erlebnis für Endnutzer in Ihrer Webapp und in Chat-Oberflächen.", "agentDetail.configure.chatFeatures.title": "Chat-Funktionen", + "agentDetail.configure.communityEditionIsolationTip": "Community Edition bietet keine harte Dateisystemisolierung zwischen Endbenutzern oder Ausführungen. Stellen Sie denselben CE-Agenten nicht mehreren voneinander unabhängigen Endbenutzern bereit, wenn Datenisolierung oder strenge Compliance erforderlich ist.", "agentDetail.configure.files.add": "Datei hinzufügen", "agentDetail.configure.files.buildNote.generated": "Generiert", "agentDetail.configure.files.buildNote.richTooltip": "Die Aufzeichnung des Agenten darüber, was er im Build-Modus eingerichtet hat. Er liest sie zu Beginn jeder Unterhaltung zusammen mit Ihrem Prompt. Mehr erfahren", "agentDetail.configure.files.buildNote.tooltip": "Die Aufzeichnung des Agenten darüber, was er im Build-Modus eingerichtet hat. Er liest sie zu Beginn jeder Unterhaltung zusammen mit Ihrem Prompt. Mehr erfahren", + "agentDetail.configure.files.download": "{{name}} herunterladen", "agentDetail.configure.files.empty.description": "Laden Sie Dokumente hoch, die der Agent lesen kann, z. B. Spezifikationen, Vorlagen oder Richtlinien", "agentDetail.configure.files.empty.title": "Noch keine Dateien", "agentDetail.configure.files.label": "Dateien", diff --git a/web/i18n/en-US/agent-v-2.json b/web/i18n/en-US/agent-v-2.json index dd489624891..59c2e6f2172 100644 --- a/web/i18n/en-US/agent-v-2.json +++ b/web/i18n/en-US/agent-v-2.json @@ -63,6 +63,7 @@ "agentDetail.configure.advancedSettings.envEditor.valuePlaceholder": "Value", "agentDetail.configure.advancedSettings.label": "Advanced Settings", "agentDetail.configure.advancedSettings.toggle": "Toggle advanced settings", + "agentDetail.configure.build.empty.communityEditionTip": "Community Edition, while offering versioning to the extracted configs, does not support versioning on the file system itself. Be careful with your actions in Build mode, as changes made to the file system happen in real time and cannot always be neatly reverted. Community Edition is not your ideal choice for serving mass external audiences.", "agentDetail.configure.build.empty.description": "Describe what you want and it fills in the form on the left as you go.", "agentDetail.configure.build.empty.title": "Build your agent by chatting", "agentDetail.configure.build.inputPlaceholder": "Describe what your agent should do", @@ -72,16 +73,18 @@ "agentDetail.configure.buildDraft.changesToApply_other": "{{count}} changes to apply", "agentDetail.configure.buildDraft.discard": "Discard", "agentDetail.configure.buildDraft.modeBadge": "Build mode", - "agentDetail.configure.buildDraft.modeDescription": "You're in build mode. Shape this setup through the chat on the right, then Apply.", + "agentDetail.configure.buildDraft.modeDescription": "You’re in build mode. Configure can only be updated by the agent in this mode. Shape this setup through the chat on the right, then Apply.", "agentDetail.configure.buildDraft.rewritten": "Rewritten", "agentDetail.configure.buildDraft.title": "Build draft", "agentDetail.configure.buildDraft.updated": "Updated", "agentDetail.configure.chatFeatures.description": "Shape the end-user chat experience on your web app and chat surfaces.", "agentDetail.configure.chatFeatures.title": "Chat Features", + "agentDetail.configure.communityEditionIsolationTip": "Community Edition does not provide hard file system isolation between end users or runs. Do not expose the same CE agent to multiple independent end users where data isolation or strict compliance is required.", "agentDetail.configure.files.add": "Add file", "agentDetail.configure.files.buildNote.generated": "Generated", "agentDetail.configure.files.buildNote.richTooltip": "The agent's record of what it set up in Build mode. It reads this at the start of every conversation, alongside your Prompt. Learn more", "agentDetail.configure.files.buildNote.tooltip": "The agent's record of what it set up in Build mode. It reads this at the start of every conversation, alongside your Prompt. Learn more", + "agentDetail.configure.files.download": "Download {{name}}", "agentDetail.configure.files.empty.description": "Upload docs the agent can read, like specs, templates, or guidelines", "agentDetail.configure.files.empty.title": "No files yet", "agentDetail.configure.files.label": "Files", diff --git a/web/i18n/es-ES/agent-v-2.json b/web/i18n/es-ES/agent-v-2.json index 67394756bc7..9ec0bc588a4 100644 --- a/web/i18n/es-ES/agent-v-2.json +++ b/web/i18n/es-ES/agent-v-2.json @@ -63,6 +63,7 @@ "agentDetail.configure.advancedSettings.envEditor.valuePlaceholder": "Value", "agentDetail.configure.advancedSettings.label": "Configuración avanzada", "agentDetail.configure.advancedSettings.toggle": "Alternar configuración avanzada", + "agentDetail.configure.build.empty.communityEditionTip": "Community Edition, aunque ofrece versionado para las configuraciones extraídas, no admite versionado del propio sistema de archivos. Ten cuidado con tus acciones en el modo Build, ya que los cambios realizados en el sistema de archivos ocurren en tiempo real y no siempre se pueden revertir limpiamente. Community Edition no es la opción ideal para atender audiencias externas masivas.", "agentDetail.configure.build.empty.description": "Describe lo que quieres y se irá completando el formulario de la izquierda mientras avanzas.", "agentDetail.configure.build.empty.title": "Crea tu agente chateando", "agentDetail.configure.build.inputPlaceholder": "Describe qué debe hacer tu agente", @@ -72,16 +73,18 @@ "agentDetail.configure.buildDraft.changesToApply_other": "{{count}} cambios por aplicar", "agentDetail.configure.buildDraft.discard": "Descartar", "agentDetail.configure.buildDraft.modeBadge": "Modo build", - "agentDetail.configure.buildDraft.modeDescription": "Estás en modo build. Ajusta esta configuración con el chat de la derecha y luego aplica los cambios.", + "agentDetail.configure.buildDraft.modeDescription": "Estás en modo build. Configure solo puede ser actualizado por el agente en este modo. Ajusta esta configuración con el chat de la derecha y luego aplica los cambios.", "agentDetail.configure.buildDraft.rewritten": "Reescrito", "agentDetail.configure.buildDraft.title": "Borrador de compilación", "agentDetail.configure.buildDraft.updated": "Actualizado", "agentDetail.configure.chatFeatures.description": "Da forma a la experiencia de chat del usuario final en tu webapp y superficies de chat.", "agentDetail.configure.chatFeatures.title": "Funciones de chat", + "agentDetail.configure.communityEditionIsolationTip": "Community Edition no proporciona aislamiento estricto del sistema de archivos entre usuarios finales ni entre ejecuciones. No expongas el mismo agente CE a varios usuarios finales independientes cuando se requiera aislamiento de datos o cumplimiento estricto.", "agentDetail.configure.files.add": "Agregar archivo", "agentDetail.configure.files.buildNote.generated": "Generado", "agentDetail.configure.files.buildNote.richTooltip": "El registro del agente sobre lo que configuró en modo Build. Lo lee al inicio de cada conversación, junto con tu Prompt. Más información", "agentDetail.configure.files.buildNote.tooltip": "El registro del agente sobre lo que configuró en modo Build. Lo lee al inicio de cada conversación, junto con tu Prompt. Más información", + "agentDetail.configure.files.download": "Descargar {{name}}", "agentDetail.configure.files.empty.description": "Sube documentos que el agente pueda leer, como especificaciones, plantillas o guías", "agentDetail.configure.files.empty.title": "Aún no hay archivos", "agentDetail.configure.files.label": "Archivos", diff --git a/web/i18n/fa-IR/agent-v-2.json b/web/i18n/fa-IR/agent-v-2.json index 1dcab1bff12..d8565933495 100644 --- a/web/i18n/fa-IR/agent-v-2.json +++ b/web/i18n/fa-IR/agent-v-2.json @@ -63,6 +63,7 @@ "agentDetail.configure.advancedSettings.envEditor.valuePlaceholder": "Value", "agentDetail.configure.advancedSettings.label": "تنظیمات پیشرفته", "agentDetail.configure.advancedSettings.toggle": "تغییر تنظیمات پیشرفته", + "agentDetail.configure.build.empty.communityEditionTip": "Community Edition با اینکه برای پیکربندی‌های استخراج‌شده نسخه‌بندی ارائه می‌کند، از نسخه‌بندی خود سیستم فایل پشتیبانی نمی‌کند. در حالت Build با اقدامات خود محتاط باشید، زیرا تغییرات روی سیستم فایل به‌صورت بلادرنگ انجام می‌شوند و همیشه نمی‌توان آن‌ها را به‌صورت تمیز بازگرداند. Community Edition انتخاب ایده‌آلی برای خدمت‌رسانی به مخاطبان خارجی گسترده نیست.", "agentDetail.configure.build.empty.description": "آنچه می‌خواهید را توضیح دهید تا فرم سمت چپ در طول گفتگو تکمیل شود.", "agentDetail.configure.build.empty.title": "عامل خود را با چت بسازید", "agentDetail.configure.build.inputPlaceholder": "توضیح دهید عامل شما باید چه کاری انجام دهد", @@ -72,16 +73,18 @@ "agentDetail.configure.buildDraft.changesToApply_other": "{{count}} تغییر برای اعمال", "agentDetail.configure.buildDraft.discard": "رد کردن", "agentDetail.configure.buildDraft.modeBadge": "حالت ساخت", - "agentDetail.configure.buildDraft.modeDescription": "شما در حالت ساخت هستید. این تنظیمات را از طریق چت سمت راست شکل دهید، سپس اعمال کنید.", + "agentDetail.configure.buildDraft.modeDescription": "شما در حالت ساخت هستید. در این حالت Configure فقط می‌تواند توسط عامل به‌روزرسانی شود. این تنظیمات را از طریق چت سمت راست شکل دهید، سپس اعمال کنید.", "agentDetail.configure.buildDraft.rewritten": "بازنویسی شد", "agentDetail.configure.buildDraft.title": "پیش نویس ساخت", "agentDetail.configure.buildDraft.updated": "به‌روزرسانی شد", "agentDetail.configure.chatFeatures.description": "تجربه چت کاربر نهایی را در Web app و سطوح چت خود شکل دهید.", "agentDetail.configure.chatFeatures.title": "ویژگی‌های چت", + "agentDetail.configure.communityEditionIsolationTip": "Community Edition جداسازی سخت‌گیرانهٔ سیستم فایل را بین کاربران نهایی یا اجراها فراهم نمی‌کند. در جایی که جداسازی داده یا رعایت الزامات سخت‌گیرانه لازم است، همان عامل CE را در اختیار چند کاربر نهایی مستقل قرار ندهید.", "agentDetail.configure.files.add": "افزودن فایل", "agentDetail.configure.files.buildNote.generated": "تولید شده", "agentDetail.configure.files.buildNote.richTooltip": "رکورد عامل از چیزهایی که در حالت Build تنظیم کرده است. در آغاز هر گفتگو، آن را همراه با Prompt شما می‌خواند. بیشتر بدانید", "agentDetail.configure.files.buildNote.tooltip": "رکورد عامل از چیزهایی که در حالت Build تنظیم کرده است. در آغاز هر گفتگو، آن را همراه با Prompt شما می‌خواند. بیشتر بدانید", + "agentDetail.configure.files.download": "دانلود {{name}}", "agentDetail.configure.files.empty.description": "اسنادی را که عامل می‌تواند بخواند بارگذاری کنید، مانند مشخصات، قالب‌ها یا دستورالعمل‌ها", "agentDetail.configure.files.empty.title": "هنوز فایلی وجود ندارد", "agentDetail.configure.files.label": "فایل‌ها", diff --git a/web/i18n/fr-FR/agent-v-2.json b/web/i18n/fr-FR/agent-v-2.json index 697e50cca8c..1831035a2d0 100644 --- a/web/i18n/fr-FR/agent-v-2.json +++ b/web/i18n/fr-FR/agent-v-2.json @@ -63,6 +63,7 @@ "agentDetail.configure.advancedSettings.envEditor.valuePlaceholder": "Value", "agentDetail.configure.advancedSettings.label": "Paramètres avancés", "agentDetail.configure.advancedSettings.toggle": "Basculer les paramètres avancés", + "agentDetail.configure.build.empty.communityEditionTip": "Community Edition, bien qu’elle offre la gestion des versions pour les configurations extraites, ne prend pas en charge la gestion des versions du système de fichiers lui-même. Soyez prudent avec vos actions en mode Build, car les modifications apportées au système de fichiers se produisent en temps réel et ne peuvent pas toujours être annulées proprement. Community Edition n’est pas le choix idéal pour servir un public externe massif.", "agentDetail.configure.build.empty.description": "Décrivez ce que vous voulez et le formulaire de gauche se remplit au fil de la conversation.", "agentDetail.configure.build.empty.title": "Créez votre agent par chat", "agentDetail.configure.build.inputPlaceholder": "Décrivez ce que votre agent doit faire", @@ -72,16 +73,18 @@ "agentDetail.configure.buildDraft.changesToApply_other": "{{count}} modifications à appliquer", "agentDetail.configure.buildDraft.discard": "Ignorer", "agentDetail.configure.buildDraft.modeBadge": "Mode build", - "agentDetail.configure.buildDraft.modeDescription": "Vous êtes en mode build. Ajustez cette configuration avec le chat à droite, puis appliquez.", + "agentDetail.configure.buildDraft.modeDescription": "Vous êtes en mode build. Configure ne peut être mis à jour que par l’agent dans ce mode. Ajustez cette configuration avec le chat à droite, puis appliquez.", "agentDetail.configure.buildDraft.rewritten": "Réécrit", "agentDetail.configure.buildDraft.title": "Brouillon de build", "agentDetail.configure.buildDraft.updated": "Mis à jour", "agentDetail.configure.chatFeatures.description": "Façonnez l’expérience de chat de l’utilisateur final sur votre webapp et vos surfaces de chat.", "agentDetail.configure.chatFeatures.title": "Fonctionnalités de chat", + "agentDetail.configure.communityEditionIsolationTip": "Community Edition ne fournit pas d’isolation stricte du système de fichiers entre les utilisateurs finaux ni entre les exécutions. N’exposez pas le même agent CE à plusieurs utilisateurs finaux indépendants lorsque l’isolation des données ou une conformité stricte est requise.", "agentDetail.configure.files.add": "Ajouter un fichier", "agentDetail.configure.files.buildNote.generated": "Généré", "agentDetail.configure.files.buildNote.richTooltip": "Le registre de l'agent sur ce qu'il a configuré en mode Build. Il le lit au début de chaque conversation, avec votre Prompt. En savoir plus", "agentDetail.configure.files.buildNote.tooltip": "Le registre de l'agent sur ce qu'il a configuré en mode Build. Il le lit au début de chaque conversation, avec votre Prompt. En savoir plus", + "agentDetail.configure.files.download": "Télécharger {{name}}", "agentDetail.configure.files.empty.description": "Téléchargez des documents que l’agent peut lire, comme des spécifications, des modèles ou des directives", "agentDetail.configure.files.empty.title": "Pas encore de fichiers", "agentDetail.configure.files.label": "Fichiers", diff --git a/web/i18n/hi-IN/agent-v-2.json b/web/i18n/hi-IN/agent-v-2.json index 55acc170321..e605fab7e8b 100644 --- a/web/i18n/hi-IN/agent-v-2.json +++ b/web/i18n/hi-IN/agent-v-2.json @@ -63,6 +63,7 @@ "agentDetail.configure.advancedSettings.envEditor.valuePlaceholder": "Value", "agentDetail.configure.advancedSettings.label": "उन्नत सेटिंग्स", "agentDetail.configure.advancedSettings.toggle": "उन्नत सेटिंग्स टॉगल करें", + "agentDetail.configure.build.empty.communityEditionTip": "Community Edition निकाले गए कॉन्फ़िगरेशन के लिए वर्शनिंग प्रदान करता है, लेकिन फ़ाइल सिस्टम पर स्वयं वर्शनिंग का समर्थन नहीं करता। Build मोड में अपनी कार्रवाइयों के साथ सावधान रहें, क्योंकि फ़ाइल सिस्टम में किए गए बदलाव वास्तविक समय में होते हैं और हमेशा साफ़-सुथरे ढंग से वापस नहीं किए जा सकते। बड़े पैमाने पर बाहरी दर्शकों को सेवा देने के लिए Community Edition आदर्श विकल्प नहीं है।", "agentDetail.configure.build.empty.description": "आप जो चाहते हैं उसका वर्णन करें और बाईं ओर का फ़ॉर्म बातचीत के साथ भरता जाएगा।", "agentDetail.configure.build.empty.title": "चैट करके अपना एजेंट बनाएं", "agentDetail.configure.build.inputPlaceholder": "बताएं कि आपका एजेंट क्या करे", @@ -72,16 +73,18 @@ "agentDetail.configure.buildDraft.changesToApply_other": "लागू करने के लिए {{count}} बदलाव", "agentDetail.configure.buildDraft.discard": "छोड़ें", "agentDetail.configure.buildDraft.modeBadge": "बिल्ड मोड", - "agentDetail.configure.buildDraft.modeDescription": "आप बिल्ड मोड में हैं। दाईं ओर की चैट से इस सेटअप को आकार दें, फिर लागू करें।", + "agentDetail.configure.buildDraft.modeDescription": "आप बिल्ड मोड में हैं। इस मोड में Configure को केवल एजेंट ही अपडेट कर सकता है। दाईं ओर की चैट से इस सेटअप को आकार दें, फिर लागू करें।", "agentDetail.configure.buildDraft.rewritten": "फिर से लिखा गया", "agentDetail.configure.buildDraft.title": "बिल्ड ड्राफ्ट", "agentDetail.configure.buildDraft.updated": "अपडेट किया गया", "agentDetail.configure.chatFeatures.description": "अपने Web app और चैट सतहों पर अंतिम-उपयोगकर्ता चैट अनुभव को आकार दें।", "agentDetail.configure.chatFeatures.title": "चैट सुविधाएँ", + "agentDetail.configure.communityEditionIsolationTip": "Community Edition अंतिम उपयोगकर्ताओं या रन के बीच सख्त फ़ाइल सिस्टम आइसोलेशन प्रदान नहीं करता है। जहाँ डेटा आइसोलेशन या कड़े अनुपालन की आवश्यकता हो, वहाँ एक ही CE एजेंट को कई स्वतंत्र अंतिम उपयोगकर्ताओं के लिए उजागर न करें।", "agentDetail.configure.files.add": "फ़ाइल जोड़ें", "agentDetail.configure.files.buildNote.generated": "जनरेट किया गया", "agentDetail.configure.files.buildNote.richTooltip": "Build mode में एजेंट ने जो सेट अप किया उसका रिकॉर्ड। हर बातचीत की शुरुआत में यह इसे आपके Prompt के साथ पढ़ता है। और जानें", "agentDetail.configure.files.buildNote.tooltip": "Build mode में एजेंट ने जो सेट अप किया उसका रिकॉर्ड। हर बातचीत की शुरुआत में यह इसे आपके Prompt के साथ पढ़ता है। और जानें", + "agentDetail.configure.files.download": "{{name}} डाउनलोड करें", "agentDetail.configure.files.empty.description": "ऐसे दस्तावेज़ अपलोड करें जिन्हें एजेंट पढ़ सके, जैसे विनिर्देश, टेम्पलेट या दिशानिर्देश", "agentDetail.configure.files.empty.title": "अभी तक कोई फ़ाइल नहीं", "agentDetail.configure.files.label": "फ़ाइलें", diff --git a/web/i18n/id-ID/agent-v-2.json b/web/i18n/id-ID/agent-v-2.json index 983bea96f3e..33e473ead14 100644 --- a/web/i18n/id-ID/agent-v-2.json +++ b/web/i18n/id-ID/agent-v-2.json @@ -63,6 +63,7 @@ "agentDetail.configure.advancedSettings.envEditor.valuePlaceholder": "Value", "agentDetail.configure.advancedSettings.label": "Pengaturan Lanjutan", "agentDetail.configure.advancedSettings.toggle": "Alihkan pengaturan lanjutan", + "agentDetail.configure.build.empty.communityEditionTip": "Community Edition, meskipun menawarkan versioning untuk konfigurasi yang diekstrak, tidak mendukung versioning pada sistem file itu sendiri. Berhati-hatilah dengan tindakan Anda dalam mode Build, karena perubahan pada sistem file terjadi secara real time dan tidak selalu dapat dikembalikan dengan rapi. Community Edition bukan pilihan ideal untuk melayani audiens eksternal dalam skala besar.", "agentDetail.configure.build.empty.description": "Jelaskan yang Anda inginkan dan formulir di kiri akan terisi seiring percakapan.", "agentDetail.configure.build.empty.title": "Bangun agen Anda lewat chat", "agentDetail.configure.build.inputPlaceholder": "Jelaskan apa yang harus dilakukan agen Anda", @@ -72,16 +73,18 @@ "agentDetail.configure.buildDraft.changesToApply_other": "{{count}} perubahan untuk diterapkan", "agentDetail.configure.buildDraft.discard": "Buang", "agentDetail.configure.buildDraft.modeBadge": "Mode build", - "agentDetail.configure.buildDraft.modeDescription": "Anda berada dalam mode build. Bentuk pengaturan ini lewat chat di kanan, lalu terapkan.", + "agentDetail.configure.buildDraft.modeDescription": "Anda berada dalam mode build. Configure hanya dapat diperbarui oleh agen dalam mode ini. Bentuk pengaturan ini lewat chat di kanan, lalu terapkan.", "agentDetail.configure.buildDraft.rewritten": "Ditulis ulang", "agentDetail.configure.buildDraft.title": "Draf build", "agentDetail.configure.buildDraft.updated": "Diperbarui", "agentDetail.configure.chatFeatures.description": "Bentuk pengalaman chat pengguna akhir di Web app dan permukaan chat Anda.", "agentDetail.configure.chatFeatures.title": "Fitur Chat", + "agentDetail.configure.communityEditionIsolationTip": "Community Edition tidak menyediakan isolasi sistem file yang ketat antar pengguna akhir atau antar eksekusi. Jangan mengekspos agen CE yang sama kepada beberapa pengguna akhir independen ketika isolasi data atau kepatuhan ketat diperlukan.", "agentDetail.configure.files.add": "Tambahkan file", "agentDetail.configure.files.buildNote.generated": "Dihasilkan", "agentDetail.configure.files.buildNote.richTooltip": "Catatan agen tentang apa yang disiapkannya dalam mode Build. Agen membaca ini di awal setiap percakapan, bersama Prompt Anda. Pelajari selengkapnya", "agentDetail.configure.files.buildNote.tooltip": "Catatan agen tentang apa yang disiapkannya dalam mode Build. Agen membaca ini di awal setiap percakapan, bersama Prompt Anda. Pelajari selengkapnya", + "agentDetail.configure.files.download": "Unduh {{name}}", "agentDetail.configure.files.empty.description": "Unggah dokumen yang dapat dibaca agen, seperti spesifikasi, templat, atau pedoman", "agentDetail.configure.files.empty.title": "Belum ada file", "agentDetail.configure.files.label": "File", diff --git a/web/i18n/it-IT/agent-v-2.json b/web/i18n/it-IT/agent-v-2.json index 4f0bb90010a..d626ac1356e 100644 --- a/web/i18n/it-IT/agent-v-2.json +++ b/web/i18n/it-IT/agent-v-2.json @@ -63,6 +63,7 @@ "agentDetail.configure.advancedSettings.envEditor.valuePlaceholder": "Value", "agentDetail.configure.advancedSettings.label": "Impostazioni avanzate", "agentDetail.configure.advancedSettings.toggle": "Attiva/disattiva impostazioni avanzate", + "agentDetail.configure.build.empty.communityEditionTip": "Community Edition, pur offrendo il versionamento delle configurazioni estratte, non supporta il versionamento del file system stesso. Fai attenzione alle azioni in modalità Build, perché le modifiche al file system avvengono in tempo reale e non sempre possono essere annullate in modo pulito. Community Edition non è la scelta ideale per servire un pubblico esterno di massa.", "agentDetail.configure.build.empty.description": "Descrivi ciò che vuoi e il modulo a sinistra verrà compilato man mano.", "agentDetail.configure.build.empty.title": "Crea il tuo agente con la chat", "agentDetail.configure.build.inputPlaceholder": "Descrivi cosa dovrebbe fare il tuo agente", @@ -72,16 +73,18 @@ "agentDetail.configure.buildDraft.changesToApply_other": "{{count}} modifiche da applicare", "agentDetail.configure.buildDraft.discard": "Scarta", "agentDetail.configure.buildDraft.modeBadge": "Modalità build", - "agentDetail.configure.buildDraft.modeDescription": "Sei in modalità build. Modella questa configurazione tramite la chat a destra, poi applica.", + "agentDetail.configure.buildDraft.modeDescription": "Sei in modalità build. Configure può essere aggiornato solo dall’agente in questa modalità. Modella questa configurazione tramite la chat a destra, poi applica.", "agentDetail.configure.buildDraft.rewritten": "Riscritto", "agentDetail.configure.buildDraft.title": "Bozza di build", "agentDetail.configure.buildDraft.updated": "Aggiornato", "agentDetail.configure.chatFeatures.description": "Definisci l’esperienza di chat per l’utente finale sulla tua webapp e sulle superfici di chat.", "agentDetail.configure.chatFeatures.title": "Funzionalità chat", + "agentDetail.configure.communityEditionIsolationTip": "Community Edition non fornisce un isolamento rigido del file system tra utenti finali o esecuzioni. Non esporre lo stesso agente CE a più utenti finali indipendenti quando sono richiesti isolamento dei dati o conformità rigorosa.", "agentDetail.configure.files.add": "Aggiungi file", "agentDetail.configure.files.buildNote.generated": "Generato", "agentDetail.configure.files.buildNote.richTooltip": "Il registro dell'agente di ciò che ha configurato in modalità Build. Lo legge all'inizio di ogni conversazione, insieme al tuo Prompt. Scopri di più", "agentDetail.configure.files.buildNote.tooltip": "Il registro dell'agente di ciò che ha configurato in modalità Build. Lo legge all'inizio di ogni conversazione, insieme al tuo Prompt. Scopri di più", + "agentDetail.configure.files.download": "Scarica {{name}}", "agentDetail.configure.files.empty.description": "Carica documenti che l’agente possa leggere, come specifiche, modelli o linee guida", "agentDetail.configure.files.empty.title": "Nessun file al momento", "agentDetail.configure.files.label": "File", diff --git a/web/i18n/ja-JP/agent-v-2.json b/web/i18n/ja-JP/agent-v-2.json index a45519caf11..30279b3121c 100644 --- a/web/i18n/ja-JP/agent-v-2.json +++ b/web/i18n/ja-JP/agent-v-2.json @@ -63,6 +63,7 @@ "agentDetail.configure.advancedSettings.envEditor.valuePlaceholder": "Value", "agentDetail.configure.advancedSettings.label": "詳細設定", "agentDetail.configure.advancedSettings.toggle": "詳細設定の表示を切り替え", + "agentDetail.configure.build.empty.communityEditionTip": "Community Edition は抽出された設定のバージョン管理を提供しますが、ファイルシステム自体のバージョン管理には対応していません。Build モードでの操作には注意してください。ファイルシステムへの変更はリアルタイムで発生し、常にきれいに元に戻せるとは限りません。Community Edition は大規模な外部利用者向けサービスには理想的な選択ではありません。", "agentDetail.configure.build.empty.description": "やりたいことを説明すると、左側のフォームが会話に合わせて入力されます。", "agentDetail.configure.build.empty.title": "チャットでエージェントを作成", "agentDetail.configure.build.inputPlaceholder": "エージェントに実行させたいことを説明", @@ -72,16 +73,18 @@ "agentDetail.configure.buildDraft.changesToApply_other": "適用する変更 {{count}} 件", "agentDetail.configure.buildDraft.discard": "破棄", "agentDetail.configure.buildDraft.modeBadge": "ビルドモード", - "agentDetail.configure.buildDraft.modeDescription": "ビルドモードです。右側のチャットでこの設定を調整してから適用してください。", + "agentDetail.configure.buildDraft.modeDescription": "ビルドモードです。このモードでは、Configure はエージェントのみが更新できます。右側のチャットでこの設定を調整してから適用してください。", "agentDetail.configure.buildDraft.rewritten": "書き換え済み", "agentDetail.configure.buildDraft.title": "ビルドドラフト", "agentDetail.configure.buildDraft.updated": "更新済み", "agentDetail.configure.chatFeatures.description": "Web app やチャット画面でのエンドユーザー向けチャット体験を設定します。", "agentDetail.configure.chatFeatures.title": "チャット機能", + "agentDetail.configure.communityEditionIsolationTip": "Community Edition では、エンドユーザー間または実行間で厳密なファイルシステム分離は提供されません。データ分離や厳格なコンプライアンスが必要な場合は、同じ CE エージェントを複数の独立したエンドユーザーに公開しないでください。", "agentDetail.configure.files.add": "ファイルを追加", "agentDetail.configure.files.buildNote.generated": "生成済み", "agentDetail.configure.files.buildNote.richTooltip": "Build モードでエージェントが設定した内容の記録です。各会話の開始時に、Prompt と一緒にこれを読み取ります。詳しく見る", "agentDetail.configure.files.buildNote.tooltip": "Build モードでエージェントが設定した内容の記録です。各会話の開始時に、Prompt と一緒にこれを読み取ります。詳しく見る", + "agentDetail.configure.files.download": "{{name}} をダウンロード", "agentDetail.configure.files.empty.description": "仕様、テンプレート、ガイドラインなど、エージェントが読めるドキュメントをアップロード", "agentDetail.configure.files.empty.title": "ファイルはまだありません", "agentDetail.configure.files.label": "ファイル", diff --git a/web/i18n/ko-KR/agent-v-2.json b/web/i18n/ko-KR/agent-v-2.json index 08cf45d7c70..4f0ba8d8151 100644 --- a/web/i18n/ko-KR/agent-v-2.json +++ b/web/i18n/ko-KR/agent-v-2.json @@ -63,6 +63,7 @@ "agentDetail.configure.advancedSettings.envEditor.valuePlaceholder": "Value", "agentDetail.configure.advancedSettings.label": "고급 설정", "agentDetail.configure.advancedSettings.toggle": "고급 설정 전환", + "agentDetail.configure.build.empty.communityEditionTip": "Community Edition은 추출된 설정에 대한 버전 관리는 제공하지만 파일 시스템 자체의 버전 관리는 지원하지 않습니다. Build 모드에서의 작업에 주의하세요. 파일 시스템 변경은 실시간으로 발생하며 항상 깔끔하게 되돌릴 수 있는 것은 아닙니다. Community Edition은 대규모 외부 사용자에게 서비스를 제공하기에 이상적인 선택이 아닙니다.", "agentDetail.configure.build.empty.description": "원하는 내용을 설명하면 대화에 맞춰 왼쪽 양식이 채워집니다.", "agentDetail.configure.build.empty.title": "채팅으로 에이전트 만들기", "agentDetail.configure.build.inputPlaceholder": "에이전트가 해야 할 일을 설명하세요", @@ -72,16 +73,18 @@ "agentDetail.configure.buildDraft.changesToApply_other": "적용할 변경 사항 {{count}}개", "agentDetail.configure.buildDraft.discard": "폐기", "agentDetail.configure.buildDraft.modeBadge": "빌드 모드", - "agentDetail.configure.buildDraft.modeDescription": "빌드 모드입니다. 오른쪽 채팅으로 이 설정을 다듬은 뒤 적용하세요.", + "agentDetail.configure.buildDraft.modeDescription": "빌드 모드입니다. 이 모드에서는 에이전트만 Configure를 업데이트할 수 있습니다. 오른쪽 채팅으로 이 설정을 다듬은 뒤 적용하세요.", "agentDetail.configure.buildDraft.rewritten": "다시 작성됨", "agentDetail.configure.buildDraft.title": "빌드 초안", "agentDetail.configure.buildDraft.updated": "업데이트됨", "agentDetail.configure.chatFeatures.description": "Web app 및 채팅 화면에서의 최종 사용자 채팅 경험을 구성합니다.", "agentDetail.configure.chatFeatures.title": "채팅 기능", + "agentDetail.configure.communityEditionIsolationTip": "Community Edition은 최종 사용자 간 또는 실행 간에 강력한 파일 시스템 격리를 제공하지 않습니다. 데이터 격리나 엄격한 규정 준수가 필요한 경우 동일한 CE 에이전트를 여러 독립 최종 사용자에게 노출하지 마세요.", "agentDetail.configure.files.add": "파일 추가", "agentDetail.configure.files.buildNote.generated": "생성됨", "agentDetail.configure.files.buildNote.richTooltip": "에이전트가 Build mode에서 설정한 내용의 기록입니다. 모든 대화 시작 시 Prompt와 함께 이 기록을 읽습니다. 자세히 알아보기", "agentDetail.configure.files.buildNote.tooltip": "에이전트가 Build mode에서 설정한 내용의 기록입니다. 모든 대화 시작 시 Prompt와 함께 이 기록을 읽습니다. 자세히 알아보기", + "agentDetail.configure.files.download": "{{name}} 다운로드", "agentDetail.configure.files.empty.description": "사양, 템플릿, 가이드라인 등 에이전트가 읽을 수 있는 문서를 업로드하세요", "agentDetail.configure.files.empty.title": "아직 파일이 없습니다", "agentDetail.configure.files.label": "파일", diff --git a/web/i18n/nl-NL/agent-v-2.json b/web/i18n/nl-NL/agent-v-2.json index d4d48eb77e6..5fe85b681df 100644 --- a/web/i18n/nl-NL/agent-v-2.json +++ b/web/i18n/nl-NL/agent-v-2.json @@ -63,6 +63,7 @@ "agentDetail.configure.advancedSettings.envEditor.valuePlaceholder": "Value", "agentDetail.configure.advancedSettings.label": "Geavanceerde instellingen", "agentDetail.configure.advancedSettings.toggle": "Geavanceerde instellingen in/uit", + "agentDetail.configure.build.empty.communityEditionTip": "Community Edition biedt wel versiebeheer voor geëxtraheerde configuraties, maar ondersteunt geen versiebeheer van het bestandssysteem zelf. Wees voorzichtig met je acties in de Build-modus, omdat wijzigingen aan het bestandssysteem in realtime plaatsvinden en niet altijd netjes kunnen worden teruggedraaid. Community Edition is niet de ideale keuze voor het bedienen van grote externe doelgroepen.", "agentDetail.configure.build.empty.description": "Beschrijf wat je wilt en het formulier links wordt gaandeweg ingevuld.", "agentDetail.configure.build.empty.title": "Bouw je agent via chat", "agentDetail.configure.build.inputPlaceholder": "Beschrijf wat je agent moet doen", @@ -72,16 +73,18 @@ "agentDetail.configure.buildDraft.changesToApply_other": "{{count}} wijzigingen om toe te passen", "agentDetail.configure.buildDraft.discard": "Negeren", "agentDetail.configure.buildDraft.modeBadge": "Buildmodus", - "agentDetail.configure.buildDraft.modeDescription": "Je bent in buildmodus. Werk deze configuratie bij via de chat rechts en pas daarna toe.", + "agentDetail.configure.buildDraft.modeDescription": "Je bent in buildmodus. Configure kan in deze modus alleen door de agent worden bijgewerkt. Werk deze configuratie bij via de chat rechts en pas daarna toe.", "agentDetail.configure.buildDraft.rewritten": "Herschreven", "agentDetail.configure.buildDraft.title": "Buildconcept", "agentDetail.configure.buildDraft.updated": "Bijgewerkt", "agentDetail.configure.chatFeatures.description": "Geef vorm aan de chatervaring voor eindgebruikers in je webapp en chatoppervlakken.", "agentDetail.configure.chatFeatures.title": "Chatfuncties", + "agentDetail.configure.communityEditionIsolationTip": "Community Edition biedt geen harde bestandssysteemisolatie tussen eindgebruikers of runs. Stel dezelfde CE-agent niet beschikbaar aan meerdere onafhankelijke eindgebruikers wanneer gegevensisolatie of strikte compliance vereist is.", "agentDetail.configure.files.add": "Bestand toevoegen", "agentDetail.configure.files.buildNote.generated": "Gegenereerd", "agentDetail.configure.files.buildNote.richTooltip": "Het verslag van de agent van wat hij in de Build-modus heeft ingesteld. Hij leest dit aan het begin van elk gesprek, samen met uw Prompt. Meer informatie", "agentDetail.configure.files.buildNote.tooltip": "Het verslag van de agent van wat hij in de Build-modus heeft ingesteld. Hij leest dit aan het begin van elk gesprek, samen met uw Prompt. Meer informatie", + "agentDetail.configure.files.download": "{{name}} downloaden", "agentDetail.configure.files.empty.description": "Upload documenten die de agent kan lezen, zoals specificaties, sjablonen of richtlijnen", "agentDetail.configure.files.empty.title": "Nog geen bestanden", "agentDetail.configure.files.label": "Bestanden", diff --git a/web/i18n/pl-PL/agent-v-2.json b/web/i18n/pl-PL/agent-v-2.json index fe4c96745d1..5492b29fcf9 100644 --- a/web/i18n/pl-PL/agent-v-2.json +++ b/web/i18n/pl-PL/agent-v-2.json @@ -63,6 +63,7 @@ "agentDetail.configure.advancedSettings.envEditor.valuePlaceholder": "Value", "agentDetail.configure.advancedSettings.label": "Ustawienia zaawansowane", "agentDetail.configure.advancedSettings.toggle": "Przełącz ustawienia zaawansowane", + "agentDetail.configure.build.empty.communityEditionTip": "Community Edition oferuje wersjonowanie wyodrębnionych konfiguracji, ale nie obsługuje wersjonowania samego systemu plików. Zachowaj ostrożność podczas działań w trybie Build, ponieważ zmiany w systemie plików zachodzą w czasie rzeczywistym i nie zawsze można je czysto cofnąć. Community Edition nie jest idealnym wyborem do obsługi masowej publiczności zewnętrznej.", "agentDetail.configure.build.empty.description": "Opisz, czego chcesz, a formularz po lewej będzie wypełniany w trakcie rozmowy.", "agentDetail.configure.build.empty.title": "Buduj agenta przez czat", "agentDetail.configure.build.inputPlaceholder": "Opisz, co agent ma robić", @@ -72,16 +73,18 @@ "agentDetail.configure.buildDraft.changesToApply_other": "{{count}} zmiany do zastosowania", "agentDetail.configure.buildDraft.discard": "Odrzuć", "agentDetail.configure.buildDraft.modeBadge": "Tryb budowania", - "agentDetail.configure.buildDraft.modeDescription": "Jesteś w trybie budowania. Dostosuj tę konfigurację przez czat po prawej, a następnie zastosuj.", + "agentDetail.configure.buildDraft.modeDescription": "Jesteś w trybie budowania. W tym trybie Configure może być aktualizowane tylko przez agenta. Dostosuj tę konfigurację przez czat po prawej, a następnie zastosuj.", "agentDetail.configure.buildDraft.rewritten": "Przepisano", "agentDetail.configure.buildDraft.title": "Szkic budowania", "agentDetail.configure.buildDraft.updated": "Zaktualizowano", "agentDetail.configure.chatFeatures.description": "Ukształtuj doświadczenie czatu użytkownika końcowego w aplikacji webowej i powierzchniach czatu.", "agentDetail.configure.chatFeatures.title": "Funkcje czatu", + "agentDetail.configure.communityEditionIsolationTip": "Community Edition nie zapewnia twardej izolacji systemu plików między użytkownikami końcowymi ani uruchomieniami. Nie udostępniaj tego samego agenta CE wielu niezależnym użytkownikom końcowym, gdy wymagana jest izolacja danych lub ścisła zgodność.", "agentDetail.configure.files.add": "Dodaj plik", "agentDetail.configure.files.buildNote.generated": "Wygenerowano", "agentDetail.configure.files.buildNote.richTooltip": "Zapis agenta tego, co skonfigurował w trybie Build. Odczytuje go na początku każdej rozmowy razem z Twoim Promptem. Dowiedz się więcej", "agentDetail.configure.files.buildNote.tooltip": "Zapis agenta tego, co skonfigurował w trybie Build. Odczytuje go na początku każdej rozmowy razem z Twoim Promptem. Dowiedz się więcej", + "agentDetail.configure.files.download": "Pobierz {{name}}", "agentDetail.configure.files.empty.description": "Prześlij dokumenty, które agent może czytać, np. specyfikacje, szablony lub wytyczne", "agentDetail.configure.files.empty.title": "Brak plików", "agentDetail.configure.files.label": "Pliki", diff --git a/web/i18n/pt-BR/agent-v-2.json b/web/i18n/pt-BR/agent-v-2.json index 39671d80b88..867ef4b8e6b 100644 --- a/web/i18n/pt-BR/agent-v-2.json +++ b/web/i18n/pt-BR/agent-v-2.json @@ -63,6 +63,7 @@ "agentDetail.configure.advancedSettings.envEditor.valuePlaceholder": "Value", "agentDetail.configure.advancedSettings.label": "Configurações avançadas", "agentDetail.configure.advancedSettings.toggle": "Alternar configurações avançadas", + "agentDetail.configure.build.empty.communityEditionTip": "A Community Edition, embora ofereça versionamento para as configurações extraídas, não oferece suporte a versionamento do próprio sistema de arquivos. Tenha cuidado com suas ações no modo Build, pois as alterações feitas no sistema de arquivos acontecem em tempo real e nem sempre podem ser revertidas de forma limpa. A Community Edition não é a escolha ideal para atender grandes públicos externos.", "agentDetail.configure.build.empty.description": "Descreva o que você quer e o formulário à esquerda será preenchido conforme a conversa avança.", "agentDetail.configure.build.empty.title": "Crie seu agente conversando", "agentDetail.configure.build.inputPlaceholder": "Descreva o que seu agente deve fazer", @@ -72,16 +73,18 @@ "agentDetail.configure.buildDraft.changesToApply_other": "{{count}} alterações para aplicar", "agentDetail.configure.buildDraft.discard": "Descartar", "agentDetail.configure.buildDraft.modeBadge": "Modo build", - "agentDetail.configure.buildDraft.modeDescription": "Você está no modo build. Ajuste esta configuração pelo chat à direita e depois aplique.", + "agentDetail.configure.buildDraft.modeDescription": "Você está no modo build. O Configure só pode ser atualizado pelo agente neste modo. Ajuste esta configuração pelo chat à direita e depois aplique.", "agentDetail.configure.buildDraft.rewritten": "Reescrito", "agentDetail.configure.buildDraft.title": "Rascunho de build", "agentDetail.configure.buildDraft.updated": "Atualizado", "agentDetail.configure.chatFeatures.description": "Modele a experiência de chat do usuário final no seu webapp e superfícies de chat.", "agentDetail.configure.chatFeatures.title": "Recursos de chat", + "agentDetail.configure.communityEditionIsolationTip": "A Community Edition não fornece isolamento rígido do sistema de arquivos entre usuários finais ou execuções. Não exponha o mesmo agente CE a vários usuários finais independentes quando isolamento de dados ou conformidade rigorosa forem necessários.", "agentDetail.configure.files.add": "Adicionar arquivo", "agentDetail.configure.files.buildNote.generated": "Gerado", "agentDetail.configure.files.buildNote.richTooltip": "O registro do agente do que ele configurou no modo Build. Ele lê isso no início de cada conversa, junto com seu Prompt. Saiba mais", "agentDetail.configure.files.buildNote.tooltip": "O registro do agente do que ele configurou no modo Build. Ele lê isso no início de cada conversa, junto com seu Prompt. Saiba mais", + "agentDetail.configure.files.download": "Baixar {{name}}", "agentDetail.configure.files.empty.description": "Envie documentos que o agente possa ler, como especificações, modelos ou diretrizes", "agentDetail.configure.files.empty.title": "Ainda não há arquivos", "agentDetail.configure.files.label": "Arquivos", diff --git a/web/i18n/ro-RO/agent-v-2.json b/web/i18n/ro-RO/agent-v-2.json index 7390fe70517..d56d9c17b33 100644 --- a/web/i18n/ro-RO/agent-v-2.json +++ b/web/i18n/ro-RO/agent-v-2.json @@ -63,6 +63,7 @@ "agentDetail.configure.advancedSettings.envEditor.valuePlaceholder": "Value", "agentDetail.configure.advancedSettings.label": "Setări avansate", "agentDetail.configure.advancedSettings.toggle": "Comută setările avansate", + "agentDetail.configure.build.empty.communityEditionTip": "Community Edition, deși oferă versionare pentru configurațiile extrase, nu acceptă versionarea sistemului de fișiere în sine. Fii atent la acțiunile din modul Build, deoarece modificările făcute în sistemul de fișiere au loc în timp real și nu pot fi întotdeauna anulate curat. Community Edition nu este alegerea ideală pentru servirea unor audiențe externe numeroase.", "agentDetail.configure.build.empty.description": "Descrie ce dorești, iar formularul din stânga se completează pe măsură ce avansezi.", "agentDetail.configure.build.empty.title": "Construiește agentul prin chat", "agentDetail.configure.build.inputPlaceholder": "Descrie ce ar trebui să facă agentul tău", @@ -72,16 +73,18 @@ "agentDetail.configure.buildDraft.changesToApply_other": "{{count}} modificări de aplicat", "agentDetail.configure.buildDraft.discard": "Renunță", "agentDetail.configure.buildDraft.modeBadge": "Mod build", - "agentDetail.configure.buildDraft.modeDescription": "Ești în modul build. Ajustează această configurare prin chatul din dreapta, apoi aplică.", + "agentDetail.configure.buildDraft.modeDescription": "Ești în modul build. Configure poate fi actualizat doar de agent în acest mod. Ajustează această configurare prin chatul din dreapta, apoi aplică.", "agentDetail.configure.buildDraft.rewritten": "Rescris", "agentDetail.configure.buildDraft.title": "Schiță de build", "agentDetail.configure.buildDraft.updated": "Actualizat", "agentDetail.configure.chatFeatures.description": "Modelează experiența de chat a utilizatorului final pe webapp-ul tău și pe suprafețele de chat.", "agentDetail.configure.chatFeatures.title": "Funcții de chat", + "agentDetail.configure.communityEditionIsolationTip": "Community Edition nu oferă izolare strictă a sistemului de fișiere între utilizatorii finali sau între rulări. Nu expune același agent CE către mai mulți utilizatori finali independenți atunci când este necesară izolarea datelor sau conformitatea strictă.", "agentDetail.configure.files.add": "Adaugă fișier", "agentDetail.configure.files.buildNote.generated": "Generat", "agentDetail.configure.files.buildNote.richTooltip": "Înregistrarea agentului despre ce a configurat în modul Build. O citește la începutul fiecărei conversații, împreună cu Promptul dvs. Aflați mai multe", "agentDetail.configure.files.buildNote.tooltip": "Înregistrarea agentului despre ce a configurat în modul Build. O citește la începutul fiecărei conversații, împreună cu Promptul dvs. Aflați mai multe", + "agentDetail.configure.files.download": "Descarcă {{name}}", "agentDetail.configure.files.empty.description": "Încarcă documente pe care agentul le poate citi, precum specificații, șabloane sau ghiduri", "agentDetail.configure.files.empty.title": "Niciun fișier încă", "agentDetail.configure.files.label": "Fișiere", diff --git a/web/i18n/ru-RU/agent-v-2.json b/web/i18n/ru-RU/agent-v-2.json index c3378ae51fd..62ff29d2d05 100644 --- a/web/i18n/ru-RU/agent-v-2.json +++ b/web/i18n/ru-RU/agent-v-2.json @@ -63,6 +63,7 @@ "agentDetail.configure.advancedSettings.envEditor.valuePlaceholder": "Value", "agentDetail.configure.advancedSettings.label": "Расширенные настройки", "agentDetail.configure.advancedSettings.toggle": "Переключить расширенные настройки", + "agentDetail.configure.build.empty.communityEditionTip": "Community Edition предоставляет версионирование извлеченных конфигураций, но не поддерживает версионирование самой файловой системы. Будьте осторожны с действиями в режиме Build: изменения файловой системы происходят в реальном времени и не всегда могут быть аккуратно отменены. Community Edition не является идеальным выбором для обслуживания массовой внешней аудитории.", "agentDetail.configure.build.empty.description": "Опишите, что вам нужно, и форма слева будет заполняться по ходу диалога.", "agentDetail.configure.build.empty.title": "Создайте агента в чате", "agentDetail.configure.build.inputPlaceholder": "Опишите, что должен делать агент", @@ -72,16 +73,18 @@ "agentDetail.configure.buildDraft.changesToApply_other": "{{count}} изменений для применения", "agentDetail.configure.buildDraft.discard": "Отменить", "agentDetail.configure.buildDraft.modeBadge": "Режим сборки", - "agentDetail.configure.buildDraft.modeDescription": "Вы в режиме сборки. Настройте эту конфигурацию через чат справа, затем примените изменения.", + "agentDetail.configure.buildDraft.modeDescription": "Вы в режиме сборки. В этом режиме Configure может обновлять только агент. Настройте эту конфигурацию через чат справа, затем примените изменения.", "agentDetail.configure.buildDraft.rewritten": "Переписано", "agentDetail.configure.buildDraft.title": "Черновик сборки", "agentDetail.configure.buildDraft.updated": "Обновлено", "agentDetail.configure.chatFeatures.description": "Настройте чат-опыт конечного пользователя в вашем веб-приложении и чат-поверхностях.", "agentDetail.configure.chatFeatures.title": "Функции чата", + "agentDetail.configure.communityEditionIsolationTip": "Community Edition не обеспечивает жесткую изоляцию файловой системы между конечными пользователями или запусками. Не предоставляйте один и тот же CE-агент нескольким независимым конечным пользователям, если требуется изоляция данных или строгое соответствие требованиям.", "agentDetail.configure.files.add": "Добавить файл", "agentDetail.configure.files.buildNote.generated": "Сгенерировано", "agentDetail.configure.files.buildNote.richTooltip": "Запись агента о том, что он настроил в режиме Build. Он читает ее в начале каждого разговора вместе с вашим Prompt. Подробнее", "agentDetail.configure.files.buildNote.tooltip": "Запись агента о том, что он настроил в режиме Build. Он читает ее в начале каждого разговора вместе с вашим Prompt. Подробнее", + "agentDetail.configure.files.download": "Скачать {{name}}", "agentDetail.configure.files.empty.description": "Загрузите документы, которые может прочитать агент, например спецификации, шаблоны или руководства", "agentDetail.configure.files.empty.title": "Пока нет файлов", "agentDetail.configure.files.label": "Файлы", diff --git a/web/i18n/sl-SI/agent-v-2.json b/web/i18n/sl-SI/agent-v-2.json index 533f2813f30..900a6ed6075 100644 --- a/web/i18n/sl-SI/agent-v-2.json +++ b/web/i18n/sl-SI/agent-v-2.json @@ -63,6 +63,7 @@ "agentDetail.configure.advancedSettings.envEditor.valuePlaceholder": "Value", "agentDetail.configure.advancedSettings.label": "Napredne nastavitve", "agentDetail.configure.advancedSettings.toggle": "Preklopi napredne nastavitve", + "agentDetail.configure.build.empty.communityEditionTip": "Community Edition sicer ponuja različice za izvlečene konfiguracije, vendar ne podpira različic samega datotečnega sistema. Pri dejanjih v načinu Build bodite previdni, saj se spremembe datotečnega sistema zgodijo v realnem času in jih ni vedno mogoče lepo razveljaviti. Community Edition ni idealna izbira za množično zunanjo publiko.", "agentDetail.configure.build.empty.description": "Opišite, kaj želite, in obrazec na levi se bo sproti izpolnjeval.", "agentDetail.configure.build.empty.title": "Izdelajte agenta s klepetom", "agentDetail.configure.build.inputPlaceholder": "Opišite, kaj naj vaš agent počne", @@ -72,16 +73,18 @@ "agentDetail.configure.buildDraft.changesToApply_other": "{{count}} spremembe za uveljavitev", "agentDetail.configure.buildDraft.discard": "Zavrzi", "agentDetail.configure.buildDraft.modeBadge": "Način gradnje", - "agentDetail.configure.buildDraft.modeDescription": "Ste v načinu gradnje. Nastavitev oblikujte s klepetom na desni, nato jo uporabite.", + "agentDetail.configure.buildDraft.modeDescription": "Ste v načinu gradnje. Configure lahko v tem načinu posodobi samo agent. Nastavitev oblikujte s klepetom na desni, nato jo uporabite.", "agentDetail.configure.buildDraft.rewritten": "Prepisano", "agentDetail.configure.buildDraft.title": "Osnutek gradnje", "agentDetail.configure.buildDraft.updated": "Posodobljeno", "agentDetail.configure.chatFeatures.description": "Oblikujte uporabniško izkušnjo klepeta v vaši spletni aplikaciji in klepetalnih površinah.", "agentDetail.configure.chatFeatures.title": "Funkcije klepeta", + "agentDetail.configure.communityEditionIsolationTip": "Community Edition ne zagotavlja stroge izolacije datotečnega sistema med končnimi uporabniki ali zagoni. Istega agenta CE ne izpostavljajte več neodvisnim končnim uporabnikom, kadar sta potrebni izolacija podatkov ali stroga skladnost.", "agentDetail.configure.files.add": "Dodaj datoteko", "agentDetail.configure.files.buildNote.generated": "Ustvarjeno", "agentDetail.configure.files.buildNote.richTooltip": "Agentov zapis tega, kar je nastavil v načinu Build. Prebere ga na začetku vsakega pogovora skupaj z vašim Promptom. Več informacij", "agentDetail.configure.files.buildNote.tooltip": "Agentov zapis tega, kar je nastavil v načinu Build. Prebere ga na začetku vsakega pogovora skupaj z vašim Promptom. Več informacij", + "agentDetail.configure.files.download": "Prenesi {{name}}", "agentDetail.configure.files.empty.description": "Naložite dokumente, ki jih lahko agent bere, npr. specifikacije, predloge ali smernice", "agentDetail.configure.files.empty.title": "Še ni datotek", "agentDetail.configure.files.label": "Datoteke", diff --git a/web/i18n/th-TH/agent-v-2.json b/web/i18n/th-TH/agent-v-2.json index 1683bb55939..1042946c10a 100644 --- a/web/i18n/th-TH/agent-v-2.json +++ b/web/i18n/th-TH/agent-v-2.json @@ -63,6 +63,7 @@ "agentDetail.configure.advancedSettings.envEditor.valuePlaceholder": "Value", "agentDetail.configure.advancedSettings.label": "การตั้งค่าขั้นสูง", "agentDetail.configure.advancedSettings.toggle": "สลับการตั้งค่าขั้นสูง", + "agentDetail.configure.build.empty.communityEditionTip": "Community Edition แม้จะมีการจัดการเวอร์ชันสำหรับการกำหนดค่าที่แยกออกมา แต่ไม่รองรับการจัดการเวอร์ชันของระบบไฟล์เอง โปรดระมัดระวังการดำเนินการในโหมด Build เนื่องจากการเปลี่ยนแปลงระบบไฟล์เกิดขึ้นแบบเรียลไทม์และอาจไม่สามารถย้อนกลับได้อย่างเรียบร้อยเสมอไป Community Edition ไม่ใช่ตัวเลือกที่เหมาะสมที่สุดสำหรับการให้บริการผู้ชมภายนอกจำนวนมาก", "agentDetail.configure.build.empty.description": "อธิบายสิ่งที่คุณต้องการ แล้วแบบฟอร์มด้านซ้ายจะถูกกรอกไปพร้อมกับการสนทนา", "agentDetail.configure.build.empty.title": "สร้างเอเจนต์ของคุณด้วยการแชท", "agentDetail.configure.build.inputPlaceholder": "อธิบายว่าเอเจนต์ของคุณควรทำอะไร", @@ -72,16 +73,18 @@ "agentDetail.configure.buildDraft.changesToApply_other": "{{count}} การเปลี่ยนแปลงที่ต้องนำไปใช้", "agentDetail.configure.buildDraft.discard": "ทิ้ง", "agentDetail.configure.buildDraft.modeBadge": "โหมด Build", - "agentDetail.configure.buildDraft.modeDescription": "คุณอยู่ในโหมด Build ปรับแต่งการตั้งค่านี้ผ่านแชททางขวา แล้วกดนำไปใช้", + "agentDetail.configure.buildDraft.modeDescription": "คุณอยู่ในโหมด Build ในโหมดนี้ Configure จะอัปเดตได้โดยเอเจนต์เท่านั้น ปรับแต่งการตั้งค่านี้ผ่านแชททางขวา แล้วกดนำไปใช้", "agentDetail.configure.buildDraft.rewritten": "เขียนใหม่แล้ว", "agentDetail.configure.buildDraft.title": "ฉบับร่าง Build", "agentDetail.configure.buildDraft.updated": "อัปเดตแล้ว", "agentDetail.configure.chatFeatures.description": "กำหนดประสบการณ์การแชทของผู้ใช้ปลายทางบน Web app และหน้าจอแชท", "agentDetail.configure.chatFeatures.title": "ฟีเจอร์แชท", + "agentDetail.configure.communityEditionIsolationTip": "Community Edition ไม่มีการแยกระบบไฟล์อย่างเข้มงวดระหว่างผู้ใช้ปลายทางหรือระหว่างการรัน อย่าเปิดเผยเอเจนต์ CE ตัวเดียวกันให้กับผู้ใช้ปลายทางอิสระหลายรายเมื่อจำเป็นต้องมีการแยกข้อมูลหรือการปฏิบัติตามข้อกำหนดอย่างเข้มงวด", "agentDetail.configure.files.add": "เพิ่มไฟล์", "agentDetail.configure.files.buildNote.generated": "สร้างแล้ว", "agentDetail.configure.files.buildNote.richTooltip": "บันทึกของ agent เกี่ยวกับสิ่งที่ตั้งค่าไว้ในโหมด Build โดยจะอ่านสิ่งนี้ตอนเริ่มทุกบทสนทนา พร้อมกับ Prompt ของคุณ เรียนรู้เพิ่มเติม", "agentDetail.configure.files.buildNote.tooltip": "บันทึกของ agent เกี่ยวกับสิ่งที่ตั้งค่าไว้ในโหมด Build โดยจะอ่านสิ่งนี้ตอนเริ่มทุกบทสนทนา พร้อมกับ Prompt ของคุณ เรียนรู้เพิ่มเติม", + "agentDetail.configure.files.download": "ดาวน์โหลด {{name}}", "agentDetail.configure.files.empty.description": "อัปโหลดเอกสารที่ตัวแทนสามารถอ่านได้ เช่น ข้อกำหนด เทมเพลต หรือแนวทาง", "agentDetail.configure.files.empty.title": "ยังไม่มีไฟล์", "agentDetail.configure.files.label": "ไฟล์", diff --git a/web/i18n/tr-TR/agent-v-2.json b/web/i18n/tr-TR/agent-v-2.json index d15a40df8fc..ea90af5e5d9 100644 --- a/web/i18n/tr-TR/agent-v-2.json +++ b/web/i18n/tr-TR/agent-v-2.json @@ -63,6 +63,7 @@ "agentDetail.configure.advancedSettings.envEditor.valuePlaceholder": "Value", "agentDetail.configure.advancedSettings.label": "Gelişmiş Ayarlar", "agentDetail.configure.advancedSettings.toggle": "Gelişmiş ayarları değiştir", + "agentDetail.configure.build.empty.communityEditionTip": "Community Edition, çıkarılan yapılandırmalar için sürümleme sunsa da dosya sisteminin kendisi için sürümleme desteklemez. Build modundaki işlemlerinizde dikkatli olun; dosya sisteminde yapılan değişiklikler gerçek zamanlı gerçekleşir ve her zaman temiz bir şekilde geri alınamayabilir. Community Edition, kitlesel dış hedef kitlelere hizmet vermek için ideal seçiminiz değildir.", "agentDetail.configure.build.empty.description": "Ne istediğinizi açıklayın; soldaki form ilerledikçe doldurulur.", "agentDetail.configure.build.empty.title": "Aracınızı sohbet ederek oluşturun", "agentDetail.configure.build.inputPlaceholder": "Aracınızın ne yapması gerektiğini açıklayın", @@ -72,16 +73,18 @@ "agentDetail.configure.buildDraft.changesToApply_other": "Uygulanacak {{count}} değişiklik", "agentDetail.configure.buildDraft.discard": "Vazgeç", "agentDetail.configure.buildDraft.modeBadge": "Build modu", - "agentDetail.configure.buildDraft.modeDescription": "Build modundasınız. Bu kurulumu sağdaki sohbetle şekillendirin, ardından uygulayın.", + "agentDetail.configure.buildDraft.modeDescription": "Build modundasınız. Bu modda Configure yalnızca ajan tarafından güncellenebilir. Bu kurulumu sağdaki sohbetle şekillendirin, ardından uygulayın.", "agentDetail.configure.buildDraft.rewritten": "Yeniden yazıldı", "agentDetail.configure.buildDraft.title": "Build taslağı", "agentDetail.configure.buildDraft.updated": "Güncellendi", "agentDetail.configure.chatFeatures.description": "Web app ve sohbet yüzeylerinizde son kullanıcı sohbet deneyimini şekillendirin.", "agentDetail.configure.chatFeatures.title": "Sohbet Özellikleri", + "agentDetail.configure.communityEditionIsolationTip": "Community Edition, son kullanıcılar veya çalıştırmalar arasında katı dosya sistemi yalıtımı sağlamaz. Veri yalıtımı veya sıkı uyumluluk gerektiğinde aynı CE ajanını birden fazla bağımsız son kullanıcıya açmayın.", "agentDetail.configure.files.add": "Dosya ekle", "agentDetail.configure.files.buildNote.generated": "Oluşturuldu", "agentDetail.configure.files.buildNote.richTooltip": "Agent'ın Build mode'da kurduğu şeylerin kaydı. Her konuşmanın başında bunu Prompt'unuzla birlikte okur. Daha fazla bilgi", "agentDetail.configure.files.buildNote.tooltip": "Agent'ın Build mode'da kurduğu şeylerin kaydı. Her konuşmanın başında bunu Prompt'unuzla birlikte okur. Daha fazla bilgi", + "agentDetail.configure.files.download": "{{name}} indir", "agentDetail.configure.files.empty.description": "Ajanın okuyabileceği belgeleri yükleyin, örneğin spesifikasyonlar, şablonlar veya yönergeler", "agentDetail.configure.files.empty.title": "Henüz dosya yok", "agentDetail.configure.files.label": "Dosyalar", diff --git a/web/i18n/uk-UA/agent-v-2.json b/web/i18n/uk-UA/agent-v-2.json index 717923d30a2..6da2f4e3dde 100644 --- a/web/i18n/uk-UA/agent-v-2.json +++ b/web/i18n/uk-UA/agent-v-2.json @@ -63,6 +63,7 @@ "agentDetail.configure.advancedSettings.envEditor.valuePlaceholder": "Value", "agentDetail.configure.advancedSettings.label": "Розширені налаштування", "agentDetail.configure.advancedSettings.toggle": "Перемкнути розширені налаштування", + "agentDetail.configure.build.empty.communityEditionTip": "Community Edition, хоча й пропонує версіонування витягнутих конфігурацій, не підтримує версіонування самої файлової системи. Будьте обережні з діями в режимі Build, адже зміни файлової системи відбуваються в реальному часі й не завжди можуть бути чисто скасовані. Community Edition не є ідеальним вибором для обслуговування масової зовнішньої аудиторії.", "agentDetail.configure.build.empty.description": "Опишіть, що вам потрібно, і форма ліворуч заповнюватиметься під час розмови.", "agentDetail.configure.build.empty.title": "Створіть агента через чат", "agentDetail.configure.build.inputPlaceholder": "Опишіть, що має робити ваш агент", @@ -72,16 +73,18 @@ "agentDetail.configure.buildDraft.changesToApply_other": "{{count}} змін для застосування", "agentDetail.configure.buildDraft.discard": "Відхилити", "agentDetail.configure.buildDraft.modeBadge": "Режим збірки", - "agentDetail.configure.buildDraft.modeDescription": "Ви в режимі збірки. Налаштуйте цю конфігурацію через чат праворуч, а потім застосуйте.", + "agentDetail.configure.buildDraft.modeDescription": "Ви в режимі збірки. У цьому режимі Configure може оновлювати лише агент. Налаштуйте цю конфігурацію через чат праворуч, а потім застосуйте.", "agentDetail.configure.buildDraft.rewritten": "Переписано", "agentDetail.configure.buildDraft.title": "Чернетка збірки", "agentDetail.configure.buildDraft.updated": "Оновлено", "agentDetail.configure.chatFeatures.description": "Налаштуйте чат-досвід кінцевого користувача у вашому веб-застосунку та чат-поверхнях.", "agentDetail.configure.chatFeatures.title": "Функції чату", + "agentDetail.configure.communityEditionIsolationTip": "Community Edition не забезпечує жорсткої ізоляції файлової системи між кінцевими користувачами або запусками. Не надавайте один і той самий CE-агент кільком незалежним кінцевим користувачам, якщо потрібна ізоляція даних або сувора відповідність вимогам.", "agentDetail.configure.files.add": "Додати файл", "agentDetail.configure.files.buildNote.generated": "Згенеровано", "agentDetail.configure.files.buildNote.richTooltip": "Запис агента про те, що він налаштував у режимі Build. Він читає його на початку кожної розмови разом із вашим Prompt. Докладніше", "agentDetail.configure.files.buildNote.tooltip": "Запис агента про те, що він налаштував у режимі Build. Він читає його на початку кожної розмови разом із вашим Prompt. Докладніше", + "agentDetail.configure.files.download": "Завантажити {{name}}", "agentDetail.configure.files.empty.description": "Завантажте документи, які може читати агент, наприклад специфікації, шаблони чи інструкції", "agentDetail.configure.files.empty.title": "Файлів ще немає", "agentDetail.configure.files.label": "Файли", diff --git a/web/i18n/vi-VN/agent-v-2.json b/web/i18n/vi-VN/agent-v-2.json index 8a15e581f67..f3a16a876ad 100644 --- a/web/i18n/vi-VN/agent-v-2.json +++ b/web/i18n/vi-VN/agent-v-2.json @@ -63,6 +63,7 @@ "agentDetail.configure.advancedSettings.envEditor.valuePlaceholder": "Value", "agentDetail.configure.advancedSettings.label": "Cài đặt nâng cao", "agentDetail.configure.advancedSettings.toggle": "Bật/tắt cài đặt nâng cao", + "agentDetail.configure.build.empty.communityEditionTip": "Community Edition, mặc dù cung cấp phiên bản cho các cấu hình được trích xuất, không hỗ trợ phiên bản cho chính hệ thống tệp. Hãy cẩn thận với các thao tác trong chế độ Build, vì các thay đổi đối với hệ thống tệp diễn ra theo thời gian thực và không phải lúc nào cũng có thể hoàn tác gọn gàng. Community Edition không phải là lựa chọn lý tưởng để phục vụ lượng lớn khán giả bên ngoài.", "agentDetail.configure.build.empty.description": "Mô tả điều bạn muốn và biểu mẫu bên trái sẽ được điền dần khi trò chuyện.", "agentDetail.configure.build.empty.title": "Xây dựng tác nhân bằng trò chuyện", "agentDetail.configure.build.inputPlaceholder": "Mô tả tác nhân của bạn nên làm gì", @@ -72,16 +73,18 @@ "agentDetail.configure.buildDraft.changesToApply_other": "{{count}} thay đổi để áp dụng", "agentDetail.configure.buildDraft.discard": "Hủy bỏ", "agentDetail.configure.buildDraft.modeBadge": "Chế độ build", - "agentDetail.configure.buildDraft.modeDescription": "Bạn đang ở chế độ build. Điều chỉnh thiết lập này qua khung chat bên phải, rồi Áp dụng.", + "agentDetail.configure.buildDraft.modeDescription": "Bạn đang ở chế độ build. Configure chỉ có thể được cập nhật bởi tác nhân trong chế độ này. Điều chỉnh thiết lập này qua khung chat bên phải, rồi Áp dụng.", "agentDetail.configure.buildDraft.rewritten": "Đã viết lại", "agentDetail.configure.buildDraft.title": "Bản nháp build", "agentDetail.configure.buildDraft.updated": "Đã cập nhật", "agentDetail.configure.chatFeatures.description": "Định hình trải nghiệm trò chuyện cho người dùng cuối trên Web app và các bề mặt trò chuyện.", "agentDetail.configure.chatFeatures.title": "Tính năng trò chuyện", + "agentDetail.configure.communityEditionIsolationTip": "Community Edition không cung cấp cách ly hệ thống tệp cứng giữa người dùng cuối hoặc giữa các lần chạy. Không cung cấp cùng một tác nhân CE cho nhiều người dùng cuối độc lập khi cần cách ly dữ liệu hoặc tuân thủ nghiêm ngặt.", "agentDetail.configure.files.add": "Thêm tệp", "agentDetail.configure.files.buildNote.generated": "Đã tạo", "agentDetail.configure.files.buildNote.richTooltip": "Bản ghi của tác nhân về những gì nó đã thiết lập trong chế độ Build. Nó đọc bản ghi này ở đầu mỗi cuộc trò chuyện, cùng với Prompt của bạn. Tìm hiểu thêm", "agentDetail.configure.files.buildNote.tooltip": "Bản ghi của tác nhân về những gì nó đã thiết lập trong chế độ Build. Nó đọc bản ghi này ở đầu mỗi cuộc trò chuyện, cùng với Prompt của bạn. Tìm hiểu thêm", + "agentDetail.configure.files.download": "Tải xuống {{name}}", "agentDetail.configure.files.empty.description": "Tải lên tài liệu mà tác nhân có thể đọc, như đặc tả, mẫu hoặc hướng dẫn", "agentDetail.configure.files.empty.title": "Chưa có tệp nào", "agentDetail.configure.files.label": "Tệp", diff --git a/web/i18n/zh-Hans/agent-v-2.json b/web/i18n/zh-Hans/agent-v-2.json index bff704ebff3..613d125939a 100644 --- a/web/i18n/zh-Hans/agent-v-2.json +++ b/web/i18n/zh-Hans/agent-v-2.json @@ -63,6 +63,7 @@ "agentDetail.configure.advancedSettings.envEditor.valuePlaceholder": "Value", "agentDetail.configure.advancedSettings.label": "高级设置", "agentDetail.configure.advancedSettings.toggle": "展开或收起高级设置", + "agentDetail.configure.build.empty.communityEditionTip": "Community Edition 虽然会对提取出的配置提供版本管理,但不支持文件系统本身的版本管理。请谨慎使用 Build 模式,因为对文件系统的更改会实时发生,并且不一定总能干净地回退。Community Edition 并不是面向大量外部受众提供服务的理想选择。", "agentDetail.configure.build.empty.description": "描述你的需求,它会随着对话填写左侧表单。", "agentDetail.configure.build.empty.title": "通过对话构建 Agent", "agentDetail.configure.build.inputPlaceholder": "描述你的 Agent 应该做什么", @@ -72,16 +73,18 @@ "agentDetail.configure.buildDraft.changesToApply_other": "{{count}} 项变更待应用", "agentDetail.configure.buildDraft.discard": "放弃", "agentDetail.configure.buildDraft.modeBadge": "构建模式", - "agentDetail.configure.buildDraft.modeDescription": "你正在使用构建模式。通过右侧聊天调整此配置,然后应用。", + "agentDetail.configure.buildDraft.modeDescription": "你正在使用构建模式。在此模式下,Configure 只能由 Agent 更新。通过右侧聊天调整此配置,然后应用。", "agentDetail.configure.buildDraft.rewritten": "已重写", "agentDetail.configure.buildDraft.title": "Build 草稿", "agentDetail.configure.buildDraft.updated": "已更新", "agentDetail.configure.chatFeatures.description": "配置 Web app 和聊天界面的终端用户聊天体验。", "agentDetail.configure.chatFeatures.title": "Chat 功能", + "agentDetail.configure.communityEditionIsolationTip": "Community Edition 不在最终用户之间或不同运行之间提供严格的文件系统隔离。如果需要数据隔离或严格合规,请勿将同一个 CE Agent 暴露给多个相互独立的最终用户。", "agentDetail.configure.files.add": "添加文件", "agentDetail.configure.files.buildNote.generated": "已生成", "agentDetail.configure.files.buildNote.richTooltip": "Agent 在构建模式中完成的设置都记录在这里。每次对话开始时,它会连同提示词一起读取这份记录。了解更多", "agentDetail.configure.files.buildNote.tooltip": "Agent 在构建模式中完成的设置都记录在这里。每次对话开始时,它会连同提示词一起读取这份记录。了解更多", + "agentDetail.configure.files.download": "下载 {{name}}", "agentDetail.configure.files.empty.description": "上传 Agent 可读取的文档,例如规格、模板或指南", "agentDetail.configure.files.empty.title": "暂无文件", "agentDetail.configure.files.label": "文件", diff --git a/web/i18n/zh-Hans/common.json b/web/i18n/zh-Hans/common.json index 83e309caa57..3f81738d058 100644 --- a/web/i18n/zh-Hans/common.json +++ b/web/i18n/zh-Hans/common.json @@ -288,7 +288,7 @@ "menus.explore": "探索", "menus.exploreMarketplace": "探索 Marketplace", "menus.plugins": "集成", - "menus.roster": "Agent 名册", + "menus.roster": "名册", "menus.status": "beta", "menus.tools": "工具", "model.capabilities": "多模态能力", diff --git a/web/i18n/zh-Hant/agent-v-2.json b/web/i18n/zh-Hant/agent-v-2.json index 4c0daade372..2e3e2007894 100644 --- a/web/i18n/zh-Hant/agent-v-2.json +++ b/web/i18n/zh-Hant/agent-v-2.json @@ -63,6 +63,7 @@ "agentDetail.configure.advancedSettings.envEditor.valuePlaceholder": "Value", "agentDetail.configure.advancedSettings.label": "進階設定", "agentDetail.configure.advancedSettings.toggle": "展開或收合進階設定", + "agentDetail.configure.build.empty.communityEditionTip": "Community Edition 雖然會對提取出的設定提供版本管理,但不支援檔案系統本身的版本管理。請謹慎使用 Build 模式,因為對檔案系統的變更會即時發生,且不一定總能乾淨地回復。Community Edition 並不是面向大量外部受眾提供服務的理想選擇。", "agentDetail.configure.build.empty.description": "描述你的需求,它會隨著對話填寫左側表單。", "agentDetail.configure.build.empty.title": "透過對話建置 Agent", "agentDetail.configure.build.inputPlaceholder": "描述你的 Agent 應該做什麼", @@ -72,16 +73,18 @@ "agentDetail.configure.buildDraft.changesToApply_other": "{{count}} 項變更待套用", "agentDetail.configure.buildDraft.discard": "放棄", "agentDetail.configure.buildDraft.modeBadge": "建置模式", - "agentDetail.configure.buildDraft.modeDescription": "你正在使用建置模式。透過右側聊天調整此設定,然後套用。", + "agentDetail.configure.buildDraft.modeDescription": "你正在使用建置模式。在此模式下,Configure 只能由 Agent 更新。透過右側聊天調整此設定,然後套用。", "agentDetail.configure.buildDraft.rewritten": "已重寫", "agentDetail.configure.buildDraft.title": "Build 草稿", "agentDetail.configure.buildDraft.updated": "已更新", "agentDetail.configure.chatFeatures.description": "配置 Web app 和聊天介面的終端使用者聊天體驗。", "agentDetail.configure.chatFeatures.title": "Chat 功能", + "agentDetail.configure.communityEditionIsolationTip": "Community Edition 不會在最終使用者之間或不同執行之間提供嚴格的檔案系統隔離。如果需要資料隔離或嚴格合規,請勿將同一個 CE Agent 暴露給多個相互獨立的最終使用者。", "agentDetail.configure.files.add": "新增檔案", "agentDetail.configure.files.buildNote.generated": "已生成", "agentDetail.configure.files.buildNote.richTooltip": "Agent 在建置模式中完成的設定都記錄在這裡。每次對話開始時,它會連同提示詞一起讀取這份記錄。了解更多", "agentDetail.configure.files.buildNote.tooltip": "Agent 在建置模式中完成的設定都記錄在這裡。每次對話開始時,它會連同提示詞一起讀取這份記錄。了解更多", + "agentDetail.configure.files.download": "下載 {{name}}", "agentDetail.configure.files.empty.description": "上傳 Agent 可讀取的文件,例如規格、範本或指南", "agentDetail.configure.files.empty.title": "暫無檔案", "agentDetail.configure.files.label": "檔案", diff --git a/web/i18n/zh-Hant/common.json b/web/i18n/zh-Hant/common.json index f84b7fde1d0..bd5bee143b5 100644 --- a/web/i18n/zh-Hant/common.json +++ b/web/i18n/zh-Hant/common.json @@ -288,7 +288,7 @@ "menus.explore": "探索", "menus.exploreMarketplace": "探索 Marketplace", "menus.plugins": "集成", - "menus.roster": "Agent 名冊", + "menus.roster": "名冊", "menus.status": "beta", "menus.tools": "工具", "model.capabilities": "多模式功能", diff --git a/web/service/client.spec.ts b/web/service/client.spec.ts index d596c7da478..4e94673f6d5 100644 --- a/web/service/client.spec.ts +++ b/web/service/client.spec.ts @@ -59,6 +59,16 @@ type AgentMutationResponse = Parameters['onSuccess']>>[0] type AgentPublishMutationResponse = Parameters['onSuccess']>>[0] type WorkflowAgentComposerMutationResponse = Parameters['onSuccess']>>[0] +type RetryFn = (failureCount: number, error: unknown) => boolean + +const getRetryFn = (queryOptions: object): RetryFn => { + const retry = (queryOptions as { retry?: unknown }).retry + expect(typeof retry).toBe('function') + if (typeof retry !== 'function') + throw new TypeError('Expected query retry to be a function.') + + return retry as RetryFn +} const createAgent = (overrides: Partial = {}): AgentMutationResponse => ({ ...overrides, @@ -335,6 +345,42 @@ describe('normalizeConsoleOpenAPIURL', () => { }) }) +// Scenario: oRPC query defaults own shared Agent detail fetch behavior. +describe('consoleQuery agent query defaults', () => { + afterEach(() => { + vi.restoreAllMocks() + }) + + it('should not retry missing agent detail errors', async () => { + const consoleQuery = await loadConsoleQuery() + const queryOptions = consoleQuery.agent.byAgentId.get.queryOptions({ + input: { + params: { + agent_id: 'agent-1', + }, + }, + }) + const retry = getRetryFn(queryOptions) + + expect(retry(0, new Response(null, { status: 404 }))).toBe(false) + }) + + it('should retry other agent detail errors fewer than three times', async () => { + const consoleQuery = await loadConsoleQuery() + const queryOptions = consoleQuery.agent.byAgentId.get.queryOptions({ + input: { + params: { + agent_id: 'agent-1', + }, + }, + }) + const retry = getRetryFn(queryOptions) + + expect(retry(2, new Error('temporary failure'))).toBe(true) + expect(retry(3, new Error('temporary failure'))).toBe(false) + }) +}) + // Scenario: oRPC mutation defaults own shared Agent roster cache behavior. describe('consoleQuery agent mutation defaults', () => { beforeEach(() => { diff --git a/web/service/client.ts b/web/service/client.ts index e0527885df6..3adfc82b0ea 100644 --- a/web/service/client.ts +++ b/web/service/client.ts @@ -458,6 +458,16 @@ export const consoleQuery: RouterUtils = createTanstackQue }, }, byAgentId: { + get: { + queryOptions: { + retry: (failureCount, error) => { + if (error instanceof Response && error.status === 404) + return false + + return failureCount < 3 + }, + }, + }, copy: { post: { mutationOptions: {