Compare commits

...
Author SHA1 Message Date
Yansong Zhang 48e95bc1c2 fix: block workflow DSL export with agent nodes 2026-07-06 17:48:26 +08:00
autofix-ci[bot]andGitHub 4f777ca6e1 [autofix.ci] apply automated fixes 2026-07-06 09:33:21 +00:00
Joel d22232d577 chore: fs download show 2026-07-06 17:27:38 +08:00
Joel ac158193e2 chore: download and preview space 2026-07-06 16:42:59 +08:00
yyh a7139e90e4 fix(agent-v2): allow file tree scroll chaining 2026-07-06 16:37:26 +08:00
Joel 1c3f5b10d9 fix: / portal place 2026-07-06 16:35:57 +08:00
zyssyz123andGitHub 4f039c218c fix(agent): avoid null agent thought stream fields (#38469) 2026-07-06 16:32:20 +08:00
yyh 7acf2a61df fix(agent-v2): open tool picker directly from add action 2026-07-06 16:28:16 +08:00
Joel 9e92e804aa chore: remove agent translate in roster 2026-07-06 16:13:21 +08:00
Joel 5eddb77fb2 chore: fix generated tooptip not show when hover 2026-07-06 16:03:35 +08:00
Joel b1c4067e71 chore: files change 2026-07-06 15:52:47 +08:00
Joel 460e380fee Improve Agent V2 configure CE notices 2026-07-06 15:52:24 +08:00
Joel 0adf95a4df feat(agent-v2): support file and skill downloads 2026-07-06 15:51:50 +08:00
Joel 6171810ba5 fix(agent-v2): keep prompt focus after slash insert 2026-07-06 15:50:58 +08:00
林玮 (Jade Lin)andGitHub c1d98f64bb fix(agent-v2): agent text history serialization (#38464) 2026-07-06 15:26:42 +08:00
yyh afdefad6bc Merge remote-tracking branch 'origin/main' into feat/agent-v2 2026-07-06 14:57:00 +08:00
autofix-ci[bot]andGitHub b2160b4f56 [autofix.ci] apply automated fixes 2026-07-06 06:42:44 +00:00
Joel 3f0bb9ecc1 fix: not found agent to agent list and fix ts problem 2026-07-06 14:36:17 +08:00
Joel 4328231bd5 fix(agent-v2): align build note badge 2026-07-06 14:36:17 +08:00
zyssyz123andGitHub a9cd1aaaa2 fix: return friendly error for unpublished agent app (#38455) 2026-07-06 14:33:51 +08:00
Joel b62a349ab5 chore: enchance agent 404 flow 2026-07-06 14:24:43 +08:00
Joel 98b9d8feac fix(agent-v2): insert prompt references at slash cursor 2026-07-06 14:24:43 +08:00
Joel 531095e195 chroe: get agent id 404 retry 2026-07-06 14:24:43 +08:00
Joel c954b27d0a feat(agent-v2): add file row downloads 2026-07-06 14:24:42 +08:00
yyhandGitHub 4779144dc2 Merge branch 'main' into feat/agent-v2 2026-07-06 13:45:49 +08:00
yyh 5e75ffa03f test: cover agent selector option refresh 2026-07-06 13:45:13 +08:00
yyh d8bea4f889 fix: refresh agent selector options on mount 2026-07-06 13:31:29 +08:00
yyh 8e1ea9d336 test(e2e): align agent v2 publish fixtures with model guard 2026-07-06 13:16:43 +08:00
f8b1d9ab95 fix(agent): pass workflow files to agent node runtime (#38453)
Co-authored-by: yyh <yuanyouhuilyz@gmail.com>
2026-07-06 13:06:45 +08:00
yyh 3f314aeeaa fix(workflow): add agent v2 help link
Refs DIFY-2705
2026-07-06 13:02:26 +08:00
zyssyz123andGitHub 0313c55638 fix(agent): require model before publishing roster agent (#38451) 2026-07-06 12:02:50 +08:00
autofix-ci[bot]andGitHub 5cf2bf7044 [autofix.ci] apply automated fixes 2026-07-06 03:43:15 +00:00
zyssyz123andGitHub e3457e013d fix(agent): allow incomplete composer draft knowledge (#38446) 2026-07-06 11:37:51 +08:00
autofix-ci[bot]andGitHub 59bc1a7fb2 [autofix.ci] apply automated fixes 2026-07-06 03:24:09 +00:00
zyssyz123andGitHub e96eb2e2d5 fix(agent): enable default file upload feature (#38441) 2026-07-06 11:17:46 +08:00
yyhandGitHub 708c962724 Merge branch 'main' into feat/agent-v2 2026-07-06 11:05:37 +08:00
yyh 0ed3a1f866 test(e2e): tighten agent v2 model guard assertion 2026-07-06 11:01:47 +08:00
yyh e0e33feabb fix(agent-v2): block missing model configure actions 2026-07-06 11:01:33 +08:00
yyh bf6eecfd75 test(e2e): cover agent v2 missing model guards 2026-07-06 10:43:58 +08:00
zyssyz123andGitHub 855e653486 fix(agent): normalize empty streaming response fields (#38439) 2026-07-06 10:29:59 +08:00
Joel 01d39e0ed4 chore: content in prompt not breakline 2026-07-06 09:32:48 +08:00
114 changed files with 2848 additions and 579 deletions
@@ -0,0 +1,51 @@
from typing import Any, cast
from sqlalchemy import select
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
features_dict = cast(dict[str, Any], app_model_config.to_dict()) if app_model_config is not None else {}
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)
return features_dict, agent_app_variables_to_user_input_form(agent_soul.app_variables)
+8 -36
View File
@@ -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):
@@ -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:
+6
View File
@@ -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."
+14 -3
View File
@@ -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
+6
View File
@@ -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:
+6
View File
@@ -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."
@@ -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
@@ -79,12 +79,14 @@ 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 {}
soul_features = agent_soul.app_features.model_dump(mode="json", exclude_none=True)
for key, value in soul_features.items():
base.setdefault(key, value)
model = agent_soul.model
if model is not None:
+4 -5
View File
@@ -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"]
+5 -5
View File
@@ -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"),
+6
View File
@@ -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."""
+2 -2
View File
@@ -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):
@@ -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,
)
@@ -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,
)
@@ -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,
+27 -27
View File
@@ -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):
+26 -4
View File
@@ -13747,6 +13747,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, <br>**Default:** true | | No |
| image | [AgentFileUploadImageFeatureConfig](#agentfileuploadimagefeatureconfig) | | No |
| number_limits | integer, <br>**Default:** 3 | | No |
#### AgentFileUploadImageFeatureConfig
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| enabled | boolean, <br>**Default:** true | | No |
#### AgentHumanContactConfig
| Name | Type | Description | Required |
@@ -13890,6 +13907,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 |
| ---- | ---- | ----------- | -------- |
@@ -13912,8 +13931,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 |
| ---- | ---- | ----------- | -------- |
@@ -13938,8 +13958,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 |
| ---- | ---- | ----------- | -------- |
@@ -14338,6 +14359,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 |
+5 -4
View File
@@ -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,
+32
View File
@@ -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")
+8
View File
@@ -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."
+7 -1
View File
@@ -24,6 +24,7 @@ from core.trigger.constants import (
TRIGGER_SCHEDULE_NODE_TYPE,
TRIGGER_WEBHOOK_NODE_TYPE,
)
from core.workflow.nodes.agent_v2.validators import WorkflowAgentNodeValidator
from core.workflow.nodes.knowledge_retrieval.entities import KnowledgeRetrievalNodeData
from core.workflow.nodes.trigger_schedule.trigger_schedule_node import TriggerScheduleNode
from events.app_event import app_model_config_was_updated, app_was_created
@@ -41,7 +42,7 @@ from models.model import AppModelConfig, AppModelConfigDict, IconType
from models.workflow import Workflow
from services.dsl_version import check_version_compatibility
from services.entities.dsl_entities import CheckDependenciesResult, ImportMode, ImportStatus
from services.errors.app import WorkflowNotFoundError
from services.errors.app import WorkflowAgentNodeDslExportUnsupportedError, WorkflowNotFoundError
from services.plugin.dependencies_analysis import DependenciesAnalysisService
from services.workflow_draft_variable_service import WorkflowDraftVariableService
from services.workflow_service import WorkflowService
@@ -563,6 +564,11 @@ class AppDslService:
raise WorkflowNotFoundError("Missing draft workflow configuration, please check.")
workflow_dict = workflow.to_dict(include_secret=include_secret)
if any(WorkflowAgentNodeValidator.iter_agent_v2_nodes(workflow_dict.get("graph", {}))):
raise WorkflowAgentNodeDslExportUnsupportedError(
"Workflow DSL export does not support Agent nodes yet. Remove Agent nodes before exporting."
)
# TODO: refactor: we need a better way to filter workspace related data from nodes
for node in workflow_dict.get("graph", {}).get("nodes", []):
node_data = node.get("data", {})
+9
View File
@@ -14,6 +14,15 @@ class WorkflowNotFoundError(Exception):
pass
class WorkflowAgentNodeDslExportUnsupportedError(ValueError):
"""Raised when a workflow DSL export contains Agent v2 nodes.
Agent v2 node configuration is not fully portable until Agent DSL
export/import is supported, because its runtime config lives outside the
workflow graph in agent bindings and config snapshots.
"""
class WorkflowIdFormatError(Exception):
pass
@@ -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"),
@@ -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")
@@ -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:
@@ -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
@@ -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
@@ -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),
@@ -65,6 +65,32 @@ 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_legacy_app_model_config_file_upload_takes_precedence():
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"] == {
"enabled": False,
"image": {"enabled": False},
}
def test_prompt_type_defaults_to_simple():
@@ -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")
@@ -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)
@@ -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."""
@@ -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"
@@ -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():
@@ -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):
@@ -0,0 +1,42 @@
from types import SimpleNamespace
from typing import cast
from unittest.mock import Mock
import pytest
from graphon.enums import BuiltinNodeTypes
from models import App
from services import app_dsl_service
from services.app_dsl_service import AppDslService
from services.errors.app import WorkflowAgentNodeDslExportUnsupportedError
def test_append_workflow_export_data_rejects_agent_v2_nodes(monkeypatch: pytest.MonkeyPatch) -> None:
workflow = SimpleNamespace(
to_dict=lambda *, include_secret: {
"graph": {
"nodes": [
{
"id": "agent-node",
"data": {
"type": BuiltinNodeTypes.AGENT,
"version": "2",
},
}
]
}
}
)
workflow_service = Mock()
workflow_service.get_draft_workflow.return_value = workflow
monkeypatch.setattr(app_dsl_service, "WorkflowService", lambda: workflow_service)
with pytest.raises(
WorkflowAgentNodeDslExportUnsupportedError,
match="Workflow DSL export does not support Agent nodes yet.",
):
AppDslService._append_workflow_export_data(
export_data={},
app_model=cast(App, SimpleNamespace(tenant_id="tenant-1")),
include_secret=False,
)
@@ -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
@@ -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"], "<think>\nplan\n</think>answer")
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")]),
+10
View File
@@ -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
+1 -1
View File
@@ -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
+10
View File
@@ -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
@@ -39,6 +39,21 @@ export async function saveAgentBuildDraft(
}
}
export async function agentBuildDraftExists(agentId: string): Promise<boolean> {
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<void> {
const ctx = await createApiContext()
try {
@@ -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,
+15 -1
View File
@@ -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<AgentAppCo
}
}
export async function ensureAgentComposerDraftIsPublishable(agentId: string): Promise<void> {
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<void> {
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<void> {
await ensureAgentComposerDraftIsPublishable(agentId)
await publishAgent(agentId, versionNote)
}
@@ -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(
@@ -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' })
@@ -138,6 +138,10 @@ export const expectAgentConfigFileSaved = async (
})
}
export const expectAgentModelRequiredFeedback = async (page: ReturnType<DifyWorld['getPage']>) => {
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({
@@ -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({
@@ -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())
})
@@ -1116,6 +1116,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 +1429,16 @@ export type AgentConfigRevisionOperation
| 'save_new_version'
| 'save_to_roster'
export type AgentFileUploadFeatureConfig = {
allowed_file_extensions?: Array<string>
allowed_file_types?: Array<FileType>
allowed_file_upload_methods?: Array<FileTransferMethod>
enabled?: boolean
image?: AgentFileUploadImageFeatureConfig
number_limits?: number
[key: string]: unknown
}
export type AgentSecretRefConfig = {
credential_id?: string | null
env_name?: string | null
@@ -1679,6 +1690,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 +1821,6 @@ export type StringListSource = {
value?: Array<string>
}
export type FileType = 'audio' | 'custom' | 'document' | 'image' | 'video'
export type FileTransferMethod = 'datasource_file' | 'local_file' | 'remote_url' | 'tool_file'
export type AgentKnowledgeMetadataCondition = {
comparison_operator:
| '<'
@@ -1926,19 +1926,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 +1940,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 +2217,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 +2262,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 +2294,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 +2309,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 +2353,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(),
@@ -2368,6 +2368,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 +2628,16 @@ export type WorkflowFileUploadPreviewConfigPayload = {
mode?: string | null
}
export type AgentFileUploadFeatureConfig = {
allowed_file_extensions?: Array<string>
allowed_file_types?: Array<FileType>
allowed_file_upload_methods?: Array<FileTransferMethod>
enabled?: boolean
image?: AgentFileUploadImageFeatureConfig
number_limits?: number
[key: string]: unknown
}
export type AgentFeatureToggleConfig = {
enabled?: boolean
[key: string]: unknown
@@ -2873,6 +2884,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 +2962,6 @@ export type StringListSource = {
value?: Array<string>
}
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
@@ -3466,6 +3466,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 +3719,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 +3750,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 +3782,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 +3814,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 +3994,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(),
@@ -211,16 +211,18 @@ const ChatInputArea = ({ readonly, botName, customPlaceholder, showFeatureBar, s
{shouldShowFooterNotice && (
<div className="m-1 mt-0 -translate-y-2 rounded-b-[10px] border-r border-b border-l border-components-panel-border-subtle bg-util-colors-indigo-indigo-50 px-2.5 py-2 pt-4">
<div className="flex items-center gap-1">
<span aria-hidden className="i-ri-information-line size-3.5 shrink-0 text-text-accent" />
<div className="body-xs-medium text-text-accent">{footerNotice}</div>
{shouldShowFooterNoticeTooltip && (
<Tooltip>
<TooltipTrigger
render={(
<button
type="button"
className="flex size-5 items-center justify-center rounded-md text-text-accent hover:bg-state-base-hover focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-components-button-primary-bg"
className="ml-auto flex size-5 items-center justify-center rounded-md system-xs-medium text-text-accent hover:bg-state-base-hover focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-components-button-primary-bg"
aria-label={typeof footerNoticeTooltip === 'string' ? footerNoticeTooltip : undefined}
>
<span aria-hidden className="i-ri-information-line size-3.5 shrink-0" />
<span aria-hidden className="i-ri-question-line size-3.5 shrink-0" />
</button>
)}
/>
@@ -229,7 +231,6 @@ const ChatInputArea = ({ readonly, botName, customPlaceholder, showFeatureBar, s
</TooltipContent>
</Tooltip>
)}
<div className="body-xs-medium text-text-accent">{footerNotice}</div>
</div>
</div>
)}
@@ -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<AgentInviteOptionResponse> & Pick<AgentInviteOptionResponse, 'id' | 'name'>,
): 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(
<QueryClientProvider client={queryClient}>
<HooksStoreContext value={hooksStore}>
<Blocks
searchText=""
onSelect={vi.fn()}
availableBlocksTypes={[BlockEnum.AgentV2]}
blocks={[createBlock(BlockEnum.AgentV2, 'Agent', BlockClassificationEnum.Default, 3)]}
/>
</HooksStoreContext>
</QueryClientProvider>,
)
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: {
@@ -66,6 +66,7 @@ export function AgentSelectorContent({
},
},
}),
staleTime: 0,
})
const agents = agentsQuery.data?.data ?? []
const actionOptions: AgentSelectorActionOption[] = onStartFromScratch
@@ -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({
@@ -7,6 +7,7 @@ import { hasValidAgentBinding } from './types'
const metaData = genNodeMetaData({
sort: 3,
type: BlockEnum.AgentV2,
helpLinkUri: 'agent',
})
const nodeDefault: NodeDefault<AgentV2NodeType> = {
@@ -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<typeof import('@tanstack/react-query')>()
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(
<AgentDetailLayout agentId="missing-agent">
<div>Agent detail content</div>
</AgentDetailLayout>,
)
await waitFor(() => {
expect(mockReplace).toHaveBeenCalledWith('/roster')
})
expect(screen.queryByText('Agent detail content')).not.toBeInTheDocument()
})
})
@@ -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(
<QueryClientProvider client={queryClient}>
<WorkflowReferencesTable agentId="agent-1" />
<WorkflowReferencesTable agentId="agent-1" enabled={enabled} />
</QueryClientProvider>,
)
@@ -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', () => {
@@ -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({
</tr>
</thead>
<tbody className="system-sm-regular text-text-secondary">
{workflowReferencesQuery.isPending && (
{enabled && workflowReferencesQuery.isPending && (
<WorkflowAccessStateRow>
{t('agentDetail.access.workflow.loading')}
</WorkflowAccessStateRow>
)}
{workflowReferencesQuery.isError && (
{enabled && workflowReferencesQuery.isError && (
<WorkflowAccessStateRow>
<div className="flex items-center justify-center gap-2">
<span>{t('agentDetail.access.workflow.loadFailed')}</span>
@@ -83,12 +86,12 @@ export function WorkflowReferencesTable({
</div>
</WorkflowAccessStateRow>
)}
{workflowReferencesQuery.isSuccess && workflowReferences.length === 0 && (
{enabled && workflowReferencesQuery.isSuccess && workflowReferences.length === 0 && (
<WorkflowAccessStateRow>
{t('agentDetail.access.workflow.empty')}
</WorkflowAccessStateRow>
)}
{workflowReferencesQuery.isSuccess && workflowReferences.map(reference => (
{enabled && workflowReferencesQuery.isSuccess && workflowReferences.map(reference => (
<WorkflowAccessRow
key={`${reference.app_id}:${reference.workflow_id}`}
reference={reference}
@@ -71,7 +71,7 @@ export function AgentAccessPage({
</p>
</div>
<WorkflowReferencesTable agentId={agentId} />
<WorkflowReferencesTable agentId={agentId} enabled={agentQuery.isSuccess} />
</section>
</div>
</ScrollArea>
@@ -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<T>() {
let resolve!: (value: T) => void
const promise = new Promise<T>((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(
<QueryClientProvider client={queryClient}>
<AgentConfigurePage agentId="agent-1" />
</QueryClientProvider>,
)
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()
@@ -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<PublishAgentResponse>()
publishAgentMutationFn.mockReturnValueOnce(publishDeferred.promise)
const { result } = renderUseAgentConfigureSync()
const { result } = renderUseAgentConfigureSync({
currentModel: configuredModel,
})
let publishPromise!: Promise<void>
act(() => {
publishPromise = result.current.publishDraft()
@@ -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<string | null>(null)
@@ -333,6 +335,7 @@ function AgentConfigurePageComposerContent({
isBuildDraftActive={buildDraft.isActive}
buildDraftChangedKeys={buildDraft.changedKeys}
showPublishBar={!buildDraft.isActive}
workflowReferencesEnabled={agentQuery.isSuccess}
bottomAction={showBuildDraftBar
? (
<AgentBuildDraftBar
@@ -407,6 +410,11 @@ function AgentConfigurePageComposerContent({
}}
onSaveDraftBeforeRun={rightPanelChatMode === 'build'
? async () => {
if (!currentModel?.provider || !currentModel.model) {
toast.error(tCommon('modelProvider.selectModel'))
throw new Error('Agent model is required.')
}
setBuildDraftActionsDisabled(true)
try {
return await buildDraftActions.prepareBuildDraftBeforeRun()
@@ -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<typeof createStore>) => 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,
@@ -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()
})
@@ -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,7 +14,7 @@ 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'
@@ -24,6 +23,7 @@ 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 { 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'
@@ -93,6 +93,7 @@ function AgentFileItem({
}) {
const { t } = useTranslation('agentV2')
const readOnly = useAgentOrchestrateReadOnly()
const queryClient = useQueryClient()
const [isPreviewOpen, setIsPreviewOpen] = useState(false)
const [selectedFileId, setSelectedFileId] = useState<string>()
const selectedFile = selectedFileId ? findAgentFileNode(files, selectedFileId) : undefined
@@ -169,25 +170,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<HTMLButtonElement>) => {
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 (
<li className="group/file-row relative min-w-0">
<li
data-selected={selected || undefined}
className="group/file-row relative flex h-6 min-w-0 items-center rounded-md focus-within:bg-state-base-hover hover:bg-state-base-hover data-[selected]:bg-state-base-active"
>
<Dialog open={isPreviewOpen} onOpenChange={handlePreviewOpenChange}>
<DialogTrigger
render={(
<button
type="button"
data-selected={selected || undefined}
aria-current={selected ? 'true' : undefined}
className={cn(
'group/file-tree-row relative flex h-6 w-full min-w-0 cursor-pointer items-center rounded-md pl-2 text-left outline-hidden select-none group-hover/file-row:bg-state-base-hover hover:bg-state-base-hover focus-visible:bg-state-base-hover focus-visible:inset-ring-2 focus-visible:inset-ring-state-accent-solid data-[selected]:bg-state-base-active',
'pr-7',
)}
className="group/file-tree-row relative flex h-full min-w-0 flex-1 cursor-pointer items-center rounded-md pl-2 text-left outline-hidden select-none focus-visible:inset-ring-2 focus-visible:inset-ring-state-accent-solid"
/>
)}
>
@@ -214,62 +261,73 @@ function AgentFileItem({
isImage: isImagePreviewFile,
isLoading: !isVirtualPreviewFile && previewQuery.isPending,
},
onDownloadFile: () => downloadFile(selectedPreviewFile),
onSelectFile: selectedFile => setSelectedFileId(selectedFile.id),
selectedFileId: selectedFileId ?? file.id,
sections: [],
}}
/>
</Dialog>
{isBuildNoteFile && (
<AgentBuildNoteInfotip
className={cn(!readOnly && 'group-focus-within/file-row:opacity-0 group-hover/file-row:opacity-0')}
/>
)}
{!readOnly && (!file.virtualContent || isBuildNoteFile) && (
<div className="pointer-events-none absolute top-1/2 right-1 z-10 flex -translate-y-1/2 items-center justify-end gap-1 opacity-0 group-focus-within/file-row:pointer-events-auto group-focus-within/file-row:opacity-100 group-hover/file-row:pointer-events-auto group-hover/file-row:opacity-100">
<button
type="button"
data-agent-file-remove-button
aria-label={t('agentDetail.configure.files.remove', { name: file.name })}
onClick={handleRemove}
className="pointer-events-none absolute top-1/2 right-1 z-10 flex size-5 -translate-y-1/2 items-center justify-center rounded-md text-text-tertiary opacity-0 group-focus-within/file-row:pointer-events-auto group-focus-within/file-row:opacity-100 group-hover/file-row:pointer-events-auto group-hover/file-row:opacity-100 hover:bg-state-destructive-hover hover:text-text-destructive focus-visible:bg-state-destructive-hover focus-visible:text-text-destructive focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden"
aria-label={t('agentDetail.configure.files.download', { name: file.name })}
onClick={handleDownload}
className="flex size-5 items-center justify-center rounded-md text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary focus-visible:bg-state-base-hover focus-visible:text-text-secondary focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden"
>
<span aria-hidden className="i-ri-delete-bin-line size-4" />
<span aria-hidden className="i-ri-download-line size-4" />
</button>
)}
{canRemoveFile && (
<button
type="button"
data-agent-file-remove-button
aria-label={t('agentDetail.configure.files.remove', { name: file.name })}
onClick={handleRemove}
className="flex size-5 items-center justify-center rounded-md text-text-tertiary hover:bg-state-destructive-hover hover:text-text-destructive focus-visible:bg-state-destructive-hover focus-visible:text-text-destructive focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden"
>
<span aria-hidden className="i-ri-delete-bin-line size-4" />
</button>
)}
</div>
</li>
)
}
function AgentBuildNoteFileRow() {
const { t } = useTranslation('agentV2')
return (
<>
<FileTreeIcon type="markdown" />
<FileTreeLabel title={BUILD_NOTE_FILE_NAME}>
<FileTreeLabel className="w-auto flex-none" title={BUILD_NOTE_FILE_NAME}>
{BUILD_NOTE_FILE_NAME}
</FileTreeLabel>
<FileTreeBadge className="ml-0.5 gap-0.5 px-1 py-0.5">
<span aria-hidden className="i-ri-sparkling-line size-3 shrink-0" />
<span>{t('agentDetail.configure.files.buildNote.generated')}</span>
</FileTreeBadge>
<div className="ml-1 flex shrink-0 items-center gap-0.5">
<AgentBuildNoteBadge />
<AgentBuildNoteInfotip />
</div>
</>
)
}
function AgentBuildNoteInfotip({
className,
}: {
className?: string
}) {
function AgentBuildNoteBadge() {
const { t } = useTranslation('agentV2')
return (
<FileTreeBadge className="ms-0 gap-0.5 px-1 py-0.5">
<span aria-hidden className="i-ri-sparkling-line size-3 shrink-0" />
<span>{t('agentDetail.configure.files.buildNote.generated')}</span>
</FileTreeBadge>
)
}
function AgentBuildNoteInfotip() {
const { t } = useTranslation('agentV2')
const docLink = useDocLink()
return (
<Infotip
aria-label={t('agentDetail.configure.files.buildNote.tooltip')}
className={cn('absolute top-1/2 right-1 z-10 size-5 -translate-y-1/2', className)}
iconClassName="size-4"
className="size-5"
iconClassName="size-4 text-text-quaternary hover:text-text-quaternary"
popupClassName="w-[230px] rounded-xl bg-components-tooltip-bg px-4 py-3.5 text-text-secondary shadow-lg backdrop-blur-[5px]"
>
<p className="body-xs-regular text-text-secondary">
@@ -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',
}}
@@ -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 (
<div className="shrink-0 px-4 py-3">
@@ -23,6 +25,28 @@ export function AgentOrchestrateHeader({
<h2 id={headingId} className="truncate title-xl-semi-bold text-text-primary">
{t('agentDetail.configure.title')}
</h2>
<Popover>
<PopoverTrigger
openOnHover
delay={300}
closeDelay={200}
aria-label={communityEditionIsolationTip}
render={(
<button
type="button"
className="inline-flex size-4 shrink-0 items-center justify-center rounded-sm outline-hidden focus-visible:ring-2 focus-visible:ring-state-accent-solid"
>
<span aria-hidden className="i-custom-vender-line-alertsAndFeedback-alert-triangle size-4 text-text-warning-secondary" />
</button>
)}
/>
<PopoverContent
placement="bottom"
popupClassName="max-w-[320px] px-3 py-2 system-xs-regular text-text-tertiary"
>
{communityEditionIsolationTip}
</PopoverContent>
</Popover>
{isBuildDraftActive && (
<span className="flex min-w-[18px] shrink-0 items-center justify-center rounded-[5px] border border-text-accent-secondary bg-components-badge-bg-dimm px-1.25 py-0.75 system-2xs-medium-uppercase text-text-accent-secondary">
{t('agentDetail.configure.buildDraft.modeBadge')}
@@ -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}
@@ -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§]')
})
})
})
@@ -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 {
$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'
@@ -25,7 +39,7 @@ 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,6 +166,222 @@ const isSelectionAfterSlash = (rootElement: HTMLElement | null, fallbackValue: s
return previousChild ? getLastTextContent(previousChild).endsWith('/') : false
}
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),
)
}
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()
@@ -178,8 +408,12 @@ export function AgentPromptEditor() {
})
const [slashMenuView, setSlashMenuView] = useState<SlashMenuView>('main')
const [isSlashMenuOpen, setIsSlashMenuOpen] = useState(false)
const [slashMenuPosition, setSlashMenuPosition] = useState<SlashMenuPosition | null>(null)
const [selectionRestoreRequest, setSelectionRestoreRequest] = useState<SelectionRestoreRequest | null>(null)
const rootRef = useRef<HTMLDivElement>(null)
const editorRef = useRef<HTMLDivElement>(null)
const slashInsertRangeRef = useRef<TextRange | null>(null)
const selectionRestoreRequestIdRef = useRef(0)
const configuredReferenceIds = useMemo(() => {
const skillIds = new Set<string>()
skills.forEach((skill) => {
@@ -215,23 +449,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<HTMLDivElement>) => {
if (!isHydrated || readOnly)
@@ -287,7 +544,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 +618,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 +657,37 @@ export function AgentPromptEditor() {
icon: 'i-ri-book-open-line',
},
]
const slashMenuWidth = slashMenuView === 'main' ? slashMenuMainWidth : slashMenuSubmenuWidth
const slashMenu = isHydrated && !readOnly && isSlashMenuOpen
? createPortal(
<div
data-agent-prompt-slash-menu
className="fixed z-60"
style={{
left: slashMenuPosition ? `${getSlashMenuLeft(slashMenuPosition, slashMenuWidth)}px` : '12px',
top: slashMenuPosition ? `${slashMenuPosition.top}px` : '36px',
}}
>
<AgentPromptSlashMenu
view={slashMenuView}
categories={slashMenuCategories}
skills={skills}
files={files}
tools={tools}
onToolsChange={setTools}
onAddCliTool={ENABLE_AGENT_CLI_TOOLS ? addActions.cli : undefined}
onAddFile={addActions.files}
onAddKnowledge={addActions.knowledge}
onAddSkill={addActions.skills}
retrievals={retrievals}
onBack={() => setSlashMenuView('main')}
onOpenCategory={setSlashMenuView}
onSelect={handleSlashSelect}
/>
</div>,
document.body,
)
: null
return (
<section className="flex flex-col gap-1 px-0 py-0" aria-labelledby="agent-configure-prompt-label">
@@ -442,7 +755,12 @@ export function AgentPromptEditor() {
}}
disableSlashPicker
disableBracePicker
/>
>
<AgentPromptSelectionBridge
restoreRequest={selectionRestoreRequest}
onSlashRangeChange={handleSlashRangeChange}
/>
</PromptEditor>
</div>
{!readOnly && (
<div
@@ -466,26 +784,7 @@ export function AgentPromptEditor() {
)}
</div>
{isHydrated && !readOnly && isSlashMenuOpen && (
<div data-agent-prompt-slash-menu className="absolute top-9 left-3 z-50">
<AgentPromptSlashMenu
view={slashMenuView}
categories={slashMenuCategories}
skills={skills}
files={files}
tools={tools}
onToolsChange={setTools}
onAddCliTool={ENABLE_AGENT_CLI_TOOLS ? addActions.cli : undefined}
onAddFile={addActions.files}
onAddKnowledge={addActions.knowledge}
onAddSkill={addActions.skills}
retrievals={retrievals}
onBack={() => setSlashMenuView('main')}
onOpenCategory={setSlashMenuView}
onSelect={handleSlashSelect}
/>
</div>
)}
{slashMenu}
</div>
</section>
)
@@ -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,
}
}
@@ -33,6 +33,7 @@ type AgentConfigurePublishBarProps = {
draftSavedAt?: number
isPublishing?: boolean
selectedVersionSnapshot?: AgentConfigSnapshotSummaryResponse | null
workflowReferencesEnabled?: boolean
onPublish?: () => void | Promise<void>
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<AgentReferencingWorkflowsResponse>(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 })
@@ -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 })
@@ -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 (
<div className="flex min-w-0 flex-wrap items-center gap-2 px-4">
<span className="system-sm-regular text-text-tertiary">
{t('agentDetail.configure.files.preview.unsupported')}
</span>
<a
href={downloadUrl}
target="_blank"
rel="noreferrer"
className="inline-flex min-w-0 items-center gap-1 rounded-md px-2 py-1 system-sm-medium text-text-accent outline-hidden hover:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid"
>
<span aria-hidden className="i-ri-download-2-line size-4 shrink-0" />
<span className="shrink-0">{tCommon('operation.download')}</span>
</a>
</div>
)
}
return (
<p className="px-4 system-sm-regular text-text-tertiary">
{t('agentDetail.configure.files.preview.empty')}
</p>
<div className="flex min-w-0 flex-wrap items-center gap-2 px-4">
<span className="system-sm-regular text-text-tertiary">
{t('agentDetail.configure.files.preview.unsupported')}
</span>
<a
href={downloadUrl || '#'}
onClick={(event) => {
if (!downloadUrl)
event.preventDefault()
}}
target="_blank"
rel="noreferrer"
className="inline-flex min-w-0 items-center gap-1 rounded-md px-2 py-1 system-sm-medium text-text-accent outline-hidden hover:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid"
>
<span aria-hidden className="i-ri-download-2-line size-4 shrink-0" />
<span className="shrink-0">{tCommon('operation.download')}</span>
</a>
</div>
)
}
@@ -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 (
<div className="flex min-h-0 flex-1 overflow-auto px-2 pb-4">
<pre
aria-hidden="true"
className="m-0 w-7 shrink-0 pr-2 text-right font-mono text-[13px] leading-[22px] text-text-quaternary select-none"
>
{lines.map((_, index) => String(index + 1).padStart(2, '0')).join('\n')}
</pre>
<pre className="m-0 min-w-max flex-1 font-mono text-[13px] leading-[22px] whitespace-pre text-text-primary">
{content}
</pre>
<div className="min-h-0 flex-1 overflow-auto px-2 pb-4 font-mono text-[13px] leading-[22px]">
{lines.map(line => (
<div key={line.key} className="flex min-w-0 items-start">
<span
aria-hidden="true"
className="w-7 shrink-0 pr-2 text-right text-text-quaternary select-none"
>
{line.lineNumber}
</span>
<code className="block min-w-0 flex-1 [overflow-wrap:anywhere] break-words whitespace-pre-wrap text-text-primary">
{line.content}
</code>
</div>
))}
</div>
)
}
@@ -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({
</h2>
)}
</div>
<DialogCloseButton className="static size-7 shrink-0 rounded-md" />
<div className="flex shrink-0 items-center gap-1">
{detail.onDownloadFile && previewTitle && (
<button
type="button"
aria-label={`${tCommon('operation.download')} ${previewTitle}`}
onClick={detail.onDownloadFile}
className="flex size-7 shrink-0 items-center justify-center rounded-md text-text-tertiary outline-hidden hover:bg-state-base-hover hover:text-text-secondary focus-visible:bg-state-base-hover focus-visible:text-text-secondary focus-visible:ring-2 focus-visible:ring-state-accent-solid"
>
<span aria-hidden className="i-ri-download-line size-4" />
</button>
)}
<DialogCloseButton className="static size-7 shrink-0 rounded-md" />
</div>
</div>
<ScrollArea
className="relative min-h-0 flex-1 overflow-hidden has-[>_:first-child:focus-visible]:outline-2 has-[>_:first-child:focus-visible]:outline-offset-0 has-[>_:first-child:focus-visible]:outline-state-accent-solid"
@@ -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({
<span
className={cn(
'shrink-0 system-xs-regular text-text-tertiary',
!readOnly && 'group-focus-within:opacity-0 group-hover:opacity-0',
'group-focus-within:opacity-0 group-hover:opacity-0',
)}
>
{t('agentDetail.configure.skills.itemType')}
</span>
</button>
<button
type="button"
aria-label={`${tCommon('operation.download')} ${skill.name}`}
onClick={handleDownload}
className={cn(
'pointer-events-none absolute top-1/2 flex size-5 -translate-y-1/2 items-center justify-center rounded-md text-text-tertiary opacity-0 group-focus-within:pointer-events-auto group-focus-within:opacity-100 group-hover:pointer-events-auto group-hover:opacity-100 hover:bg-state-base-hover hover:text-text-secondary focus-visible:bg-state-base-hover focus-visible:text-text-secondary focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden',
readOnly ? 'right-1' : 'right-7',
)}
>
<span aria-hidden className="i-ri-download-line size-4" />
</button>
{!readOnly && (
<button
type="button"
@@ -4,9 +4,10 @@ import type { AgentConfigSkillFileResponse } from '@dify/contracts/api/console/a
import type { AgentConfigApiContext } from '../config-context'
import type { AgentSkillDetail } from './detail-dialog'
import type { AgentFileNode, AgentSkill } from '@/features/agent-v2/agent-composer/form-state'
import { useQuery } from '@tanstack/react-query'
import { useMemo, useState } from 'react'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { useCallback, useMemo, useState } from 'react'
import { consoleQuery } from '@/service/client'
import { downloadBlob, downloadUrl } from '@/utils/download'
import { getDriveFileIconType } from '../files/file-icon'
const isSkillFolder = (file: AgentConfigSkillFileResponse) =>
@@ -150,6 +151,7 @@ export function useAgentSkillDetail({
isOpen: boolean
skill: AgentSkill
}): AgentSkillDetail {
const queryClient = useQueryClient()
const [selectedFileId, setSelectedFileId] = useState<string>()
const agentSkillInspectQuery = useQuery({
...consoleQuery.agent.byAgentId.config.skills.byName.inspect.get.queryOptions({
@@ -263,6 +265,56 @@ export function useAgentSkillDetail({
enabled: shouldDownloadPreviewFile && !!apiContext.workflow,
})
const downloadQuery = apiContext.workflow ? workflowDownloadQuery : agentDownloadQuery
const handleDownloadFile = useCallback(async () => {
if (!selectedFile)
return
const file = selectedFile
const path = file.configName ?? file.id
const isSkillMdFile = path === inspectQuery.data?.skill_md.path || file.name === 'SKILL.md'
if (isSkillMdFile && inspectQuery.data?.skill_md.text !== undefined) {
downloadBlob({
data: new Blob([inspectQuery.data.skill_md.text], { type: 'text/markdown;charset=utf-8' }),
fileName: file.name,
})
return
}
if (apiContext.workflow) {
const result = await queryClient.fetchQuery(consoleQuery.apps.byAppId.agent.config.skills.byName.files.download.get.queryOptions({
input: {
params: {
app_id: apiContext.workflow.appId,
name: skill.name,
},
query: {
node_id: apiContext.workflow.nodeId,
path,
draft_type: apiContext.draftType,
version_id: apiContext.versionId,
},
},
}))
downloadUrl({ url: result.url, fileName: file.name })
return
}
const result = await queryClient.fetchQuery(consoleQuery.agent.byAgentId.config.skills.byName.files.download.get.queryOptions({
input: {
params: {
agent_id: apiContext.agentId,
name: skill.name,
},
query: {
path,
draft_type: apiContext.draftType,
version_id: apiContext.versionId,
},
},
}))
downloadUrl({ url: result.url, fileName: file.name })
}, [apiContext, inspectQuery.data?.skill_md.path, inspectQuery.data?.skill_md.text, queryClient, selectedFile, skill.name])
return {
description,
@@ -279,6 +331,7 @@ export function useAgentSkillDetail({
isImage: isImagePreviewFile,
isLoading: isSkillMdSelected ? inspectQuery.isPending : !!selectedPreviewPath && previewQuery.isPending,
},
onDownloadFile: handleDownloadFile,
onSelectFile: file => setSelectedFileId(file.id),
selectedFileId: previewFileId,
sections: [],
@@ -369,21 +369,21 @@ describe('AgentTools', () => {
expect(screen.queryByText('Lark CLI')).not.toBeInTheDocument()
})
it('should keep the add trigger mounted while the tool picker is open', async () => {
it('should open the tool picker directly from the add trigger', async () => {
const user = userEvent.setup()
renderAgentTools()
await user.click(screen.getByRole('button', {
name: 'agentV2.agentDetail.configure.tools.add',
}))
expect(screen.getByText('Mock tool picker')).toBeInTheDocument()
expect(screen.queryByRole('button', {
name: /agentV2\.agentDetail\.configure\.tools\.addMenu\.cliTool\.label/,
})).not.toBeInTheDocument()
await user.click(screen.getByRole('button', {
expect(screen.queryByRole('button', {
name: /agentV2\.agentDetail\.configure\.tools\.addMenu\.tool\.label/,
}))
expect(screen.getByText('Mock tool picker')).toBeInTheDocument()
})).not.toBeInTheDocument()
expect(screen.getByRole('button', {
name: 'agentV2.agentDetail.configure.tools.add',
})).toBeInTheDocument()
@@ -264,6 +264,12 @@ function AddToolMenuItem({
)
}
type AddToolMenuView = 'menu' | 'tool-picker'
// CLI tools are not available yet, so open the tool picker directly for now.
// Switch this back to 'menu' when the CLI tool entry returns.
const addToolDefaultView = 'tool-picker' satisfies AddToolMenuView
function AddToolMenu({
onAddCliTool,
onAddTools,
@@ -275,7 +281,7 @@ function AddToolMenu({
}) {
const { t } = useTranslation('agentV2')
const [open, setOpen] = useState(false)
const [view, setView] = useState<'menu' | 'tool-picker'>('menu')
const [view, setView] = useState<AddToolMenuView>(addToolDefaultView)
const providerById = useAgentToolProviderMap()
const openToolPicker = useCallback(() => {
@@ -291,7 +297,7 @@ function AddToolMenu({
setOpen(nextOpen)
if (nextOpen)
setView('menu')
setView(addToolDefaultView)
}, [])
const toAgentToolDefaultValue = useCallback((tool: ToolDefaultValue): AgentProviderToolDefaultValue => ({
@@ -0,0 +1,159 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { AgentWorkingDirectoryPanel } from '../working-directory-panel'
type QueryOptionsInput = {
input: {
query?: {
path?: string
}
}
}
const mocks = vi.hoisted(() => ({
sandboxInfoQueryOptions: vi.fn(),
sandboxFilesQueryOptions: vi.fn(),
sandboxFileReadQueryOptions: vi.fn(),
workflowSandboxFilesQueryOptions: vi.fn(),
workflowSandboxFileReadQueryOptions: vi.fn(),
downloadBlob: vi.fn(),
}))
vi.mock('@/utils/download', () => ({
downloadBlob: mocks.downloadBlob,
}))
vi.mock('@/service/client', () => ({
consoleQuery: {
agent: {
byAgentId: {
sandbox: {
get: {
queryOptions: mocks.sandboxInfoQueryOptions,
},
files: {
get: {
queryOptions: mocks.sandboxFilesQueryOptions,
},
read: {
get: {
queryOptions: mocks.sandboxFileReadQueryOptions,
},
},
},
},
},
},
apps: {
byAppId: {
workflowRuns: {
byWorkflowRunId: {
agentNodes: {
byNodeId: {
sandbox: {
files: {
get: {
queryOptions: mocks.workflowSandboxFilesQueryOptions,
},
read: {
get: {
queryOptions: mocks.workflowSandboxFileReadQueryOptions,
},
},
},
},
},
},
},
},
},
},
},
}))
function renderWorkingDirectoryPanel() {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
},
})
return render(
<QueryClientProvider client={queryClient}>
<AgentWorkingDirectoryPanel
open
onOpenChange={vi.fn()}
source={{
type: 'agent',
agentId: 'agent-1',
conversationId: 'conversation-1',
}}
/>
</QueryClientProvider>,
)
}
describe('AgentWorkingDirectoryPanel', () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.sandboxInfoQueryOptions.mockImplementation(() => ({
queryKey: ['sandbox-info'],
queryFn: async () => ({
workspace_cwd: 'workspace',
}),
}))
mocks.sandboxFilesQueryOptions.mockImplementation(({ input }: QueryOptionsInput) => ({
queryKey: ['sandbox-files', input],
queryFn: async () => ({
path: input.query?.path ?? '~/workspace',
entries: [
{ name: 'workspace/report.md', type: 'file' },
{ name: 'workspace/notes.md', type: 'file' },
{ name: 'workspace/chart.png', type: 'file' },
],
}),
}))
mocks.sandboxFileReadQueryOptions.mockImplementation(({ input }: QueryOptionsInput) => ({
queryKey: ['sandbox-file-read', input],
queryFn: async () => ({
binary: input.query?.path?.endsWith('chart.png') ?? false,
path: input.query?.path ?? '',
text: input.query?.path?.endsWith('chart.png') ? null : `Content for ${input.query?.path}`,
truncated: false,
}),
}))
})
it('should download the selected working directory file from the preview header action', async () => {
const user = userEvent.setup()
renderWorkingDirectoryPanel()
await user.click(await screen.findByText('notes.md'))
await user.click(await screen.findByRole('button', {
name: /common\.operation\.download.*notes\.md/i,
}))
await waitFor(() => {
expect(mocks.downloadBlob).toHaveBeenCalledWith({
data: expect.any(Blob),
fileName: 'notes.md',
})
})
const blob = mocks.downloadBlob.mock.calls[0]?.[0].data as Blob
await expect(blob.text()).resolves.toBe('Content for ~/workspace/notes.md')
})
it('should show an unsupported preview download placeholder for binary working directory files', async () => {
const user = userEvent.setup()
renderWorkingDirectoryPanel()
await user.click(await screen.findByText('chart.png'))
expect(await screen.findByText('agentV2.agentDetail.configure.files.preview.unsupported')).toBeInTheDocument()
expect(screen.getByRole('link', { name: /common\.operation\.download/i })).toHaveAttribute('href', '#')
expect(screen.queryByRole('button', { name: /common\.operation\.download.*chart\.png/i })).not.toBeInTheDocument()
expect(mocks.downloadBlob).not.toHaveBeenCalled()
})
})
@@ -1,6 +1,7 @@
'use client'
import type { AgentChatRuntimeEmptyStateProps, AgentChatRuntimeProps } from './chat-runtime'
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
import { useTranslation } from 'react-i18next'
import { AgentChatRuntime } from './chat-runtime'
@@ -26,6 +27,7 @@ function AgentBuildChatEmptyState({
inputNode,
}: AgentChatRuntimeEmptyStateProps) {
const { t } = useTranslation('agentV2')
const communityEditionBuildModeTip = t('agentDetail.configure.build.empty.communityEditionTip')
return (
<div className="flex h-full items-center justify-center">
@@ -42,8 +44,32 @@ function AgentBuildChatEmptyState({
</div>
<span aria-hidden className="absolute i-ri-hammer-line size-5 text-saas-dify-blue-inverted" />
</div>
<div className="mt-3 max-w-full truncate system-md-medium text-text-secondary">
{t('agentDetail.configure.build.empty.title')}
<div className="mt-3 flex max-w-full items-center gap-1.5">
<div className="min-w-0 truncate system-md-medium text-text-secondary">
{t('agentDetail.configure.build.empty.title')}
</div>
<Popover>
<PopoverTrigger
openOnHover
delay={300}
closeDelay={200}
aria-label={communityEditionBuildModeTip}
render={(
<button
type="button"
className="inline-flex size-4 shrink-0 items-center justify-center rounded-sm outline-hidden focus-visible:ring-2 focus-visible:ring-state-accent-solid"
>
<span aria-hidden className="i-custom-vender-line-alertsAndFeedback-alert-triangle size-4 text-text-warning-secondary" />
</button>
)}
/>
<PopoverContent
placement="top"
popupClassName="max-w-[340px] px-3 py-2 system-xs-regular text-text-tertiary"
>
{communityEditionBuildModeTip}
</PopoverContent>
</Popover>
</div>
<p className="mt-1 max-w-full body-md-regular text-text-tertiary">
{t('agentDetail.configure.build.empty.description')}
@@ -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<string>(['audio', 'custom', 'document', 'image', 'video'])
const agentFileTransferMethods = new Set<string>(['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<string, unknown> = { ...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,
}
}
@@ -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 (
<Dialog open={open} onOpenChange={onOpenChange}>
@@ -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
@@ -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,
@@ -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 (
<div className="relative flex h-full min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
<div className="min-h-0 min-w-0 flex-1 overflow-auto">
+4 -1
View File
@@ -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 الخاص بك. <docLink>معرفة المزيد</docLink>",
"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": "الملفات",
+4 -1
View File
@@ -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. <docLink>Mehr erfahren</docLink>",
"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",
+4 -1
View File
@@ -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": "Youre 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. <docLink>Learn more</docLink>",
"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",
+4 -1
View File
@@ -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. <docLink>Más información</docLink>",
"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",
+4 -1
View File
@@ -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 شما می‌خواند. <docLink>بیشتر بدانید</docLink>",
"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": "فایل‌ها",
+4 -1
View File
@@ -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 quelle 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 nest 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 lagent 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 lexpérience de chat de lutilisateur 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 disolation stricte du système de fichiers entre les utilisateurs finaux ni entre les exécutions. Nexposez pas le même agent CE à plusieurs utilisateurs finaux indépendants lorsque lisolation 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. <docLink>En savoir plus</docLink>",
"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 lagent 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",
+4 -1
View File
@@ -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 के साथ पढ़ता है। <docLink>और जानें</docLink>",
"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": "फ़ाइलें",
+4 -1
View File
@@ -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. <docLink>Pelajari selengkapnya</docLink>",
"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",
+4 -1
View File
@@ -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 dallagente 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 lesperienza di chat per lutente 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. <docLink>Scopri di più</docLink>",
"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 lagente possa leggere, come specifiche, modelli o linee guida",
"agentDetail.configure.files.empty.title": "Nessun file al momento",
"agentDetail.configure.files.label": "File",
+4 -1
View File
@@ -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 と一緒にこれを読み取ります。<docLink>詳しく見る</docLink>",
"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": "ファイル",
+4 -1
View File
@@ -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와 함께 이 기록을 읽습니다. <docLink>자세히 알아보기</docLink>",
"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": "파일",
+4 -1
View File
@@ -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. <docLink>Meer informatie</docLink>",
"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",
+4 -1
View File
@@ -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. <docLink>Dowiedz się więcej</docLink>",
"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",

Some files were not shown because too many files have changed in this diff Show More