Compare commits
41
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
48e95bc1c2 | ||
|
|
4f777ca6e1 | ||
|
|
d22232d577 | ||
|
|
ac158193e2 | ||
|
|
a7139e90e4 | ||
|
|
1c3f5b10d9 | ||
|
|
4f039c218c | ||
|
|
7acf2a61df | ||
|
|
9e92e804aa | ||
|
|
5eddb77fb2 | ||
|
|
b1c4067e71 | ||
|
|
460e380fee | ||
|
|
0adf95a4df | ||
|
|
6171810ba5 | ||
|
|
c1d98f64bb | ||
|
|
afdefad6bc | ||
|
|
b2160b4f56 | ||
|
|
3f0bb9ecc1 | ||
|
|
4328231bd5 | ||
|
|
a9cd1aaaa2 | ||
|
|
b62a349ab5 | ||
|
|
98b9d8feac | ||
|
|
531095e195 | ||
|
|
c954b27d0a | ||
|
|
4779144dc2 | ||
|
|
5e75ffa03f | ||
|
|
d8bea4f889 | ||
|
|
8e1ea9d336 | ||
|
|
f8b1d9ab95 | ||
|
|
3f314aeeaa | ||
|
|
0313c55638 | ||
|
|
5cf2bf7044 | ||
|
|
e3457e013d | ||
|
|
59bc1a7fb2 | ||
|
|
e96eb2e2d5 | ||
|
|
708c962724 | ||
|
|
0ed3a1f866 | ||
|
|
e0e33feabb | ||
|
|
bf6eecfd75 | ||
|
|
855e653486 | ||
|
|
01d39e0ed4 |
@@ -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)
|
||||
@@ -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:
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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."""
|
||||
@@ -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,
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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."
|
||||
|
||||
|
||||
@@ -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", {})
|
||||
|
||||
@@ -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")
|
||||
|
||||
+52
-2
@@ -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")]),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
+22
-3
@@ -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()
|
||||
|
||||
+44
-10
@@ -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()
|
||||
|
||||
+27
-1
@@ -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,
|
||||
|
||||
+111
-3
@@ -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()
|
||||
})
|
||||
|
||||
+93
-35
@@ -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}
|
||||
|
||||
+57
@@ -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§]')
|
||||
})
|
||||
})
|
||||
})
|
||||
+327
-28
@@ -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>
|
||||
)
|
||||
|
||||
+29
-1
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
+7
-5
@@ -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 })
|
||||
|
||||
+164
@@ -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 })
|
||||
|
||||
|
||||
+52
-34
@@ -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"
|
||||
|
||||
+50
-1
@@ -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"
|
||||
|
||||
+55
-2
@@ -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: [],
|
||||
|
||||
+5
-5
@@ -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 => ({
|
||||
|
||||
+159
@@ -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')}
|
||||
|
||||
+37
-2
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
+19
-1
@@ -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">
|
||||
|
||||
@@ -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": "الملفات",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -63,6 +63,7 @@
|
||||
"agentDetail.configure.advancedSettings.envEditor.valuePlaceholder": "Value",
|
||||
"agentDetail.configure.advancedSettings.label": "Advanced Settings",
|
||||
"agentDetail.configure.advancedSettings.toggle": "Toggle advanced settings",
|
||||
"agentDetail.configure.build.empty.communityEditionTip": "Community Edition, while offering versioning to the extracted configs, does not support versioning on the file system itself. Be careful with your actions in Build mode, as changes made to the file system happen in real time and cannot always be neatly reverted. Community Edition is not your ideal choice for serving mass external audiences.",
|
||||
"agentDetail.configure.build.empty.description": "Describe what you want and it fills in the form on the left as you go.",
|
||||
"agentDetail.configure.build.empty.title": "Build your agent by chatting",
|
||||
"agentDetail.configure.build.inputPlaceholder": "Describe what your agent should do",
|
||||
@@ -72,16 +73,18 @@
|
||||
"agentDetail.configure.buildDraft.changesToApply_other": "{{count}} changes to apply",
|
||||
"agentDetail.configure.buildDraft.discard": "Discard",
|
||||
"agentDetail.configure.buildDraft.modeBadge": "Build mode",
|
||||
"agentDetail.configure.buildDraft.modeDescription": "You're in build mode. Shape this setup through the chat on the right, then Apply.",
|
||||
"agentDetail.configure.buildDraft.modeDescription": "You’re in build mode. Configure can only be updated by the agent in this mode. Shape this setup through the chat on the right, then Apply.",
|
||||
"agentDetail.configure.buildDraft.rewritten": "Rewritten",
|
||||
"agentDetail.configure.buildDraft.title": "Build draft",
|
||||
"agentDetail.configure.buildDraft.updated": "Updated",
|
||||
"agentDetail.configure.chatFeatures.description": "Shape the end-user chat experience on your web app and chat surfaces.",
|
||||
"agentDetail.configure.chatFeatures.title": "Chat Features",
|
||||
"agentDetail.configure.communityEditionIsolationTip": "Community Edition does not provide hard file system isolation between end users or runs. Do not expose the same CE agent to multiple independent end users where data isolation or strict compliance is required.",
|
||||
"agentDetail.configure.files.add": "Add file",
|
||||
"agentDetail.configure.files.buildNote.generated": "Generated",
|
||||
"agentDetail.configure.files.buildNote.richTooltip": "The agent's record of what it set up in Build mode. It reads this at the start of every conversation, alongside your Prompt. <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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "فایلها",
|
||||
|
||||
@@ -63,6 +63,7 @@
|
||||
"agentDetail.configure.advancedSettings.envEditor.valuePlaceholder": "Value",
|
||||
"agentDetail.configure.advancedSettings.label": "Paramètres avancés",
|
||||
"agentDetail.configure.advancedSettings.toggle": "Basculer les paramètres avancés",
|
||||
"agentDetail.configure.build.empty.communityEditionTip": "Community Edition, bien qu’elle offre la gestion des versions pour les configurations extraites, ne prend pas en charge la gestion des versions du système de fichiers lui-même. Soyez prudent avec vos actions en mode Build, car les modifications apportées au système de fichiers se produisent en temps réel et ne peuvent pas toujours être annulées proprement. Community Edition n’est pas le choix idéal pour servir un public externe massif.",
|
||||
"agentDetail.configure.build.empty.description": "Décrivez ce que vous voulez et le formulaire de gauche se remplit au fil de la conversation.",
|
||||
"agentDetail.configure.build.empty.title": "Créez votre agent par chat",
|
||||
"agentDetail.configure.build.inputPlaceholder": "Décrivez ce que votre agent doit faire",
|
||||
@@ -72,16 +73,18 @@
|
||||
"agentDetail.configure.buildDraft.changesToApply_other": "{{count}} modifications à appliquer",
|
||||
"agentDetail.configure.buildDraft.discard": "Ignorer",
|
||||
"agentDetail.configure.buildDraft.modeBadge": "Mode build",
|
||||
"agentDetail.configure.buildDraft.modeDescription": "Vous êtes en mode build. Ajustez cette configuration avec le chat à droite, puis appliquez.",
|
||||
"agentDetail.configure.buildDraft.modeDescription": "Vous êtes en mode build. Configure ne peut être mis à jour que par l’agent dans ce mode. Ajustez cette configuration avec le chat à droite, puis appliquez.",
|
||||
"agentDetail.configure.buildDraft.rewritten": "Réécrit",
|
||||
"agentDetail.configure.buildDraft.title": "Brouillon de build",
|
||||
"agentDetail.configure.buildDraft.updated": "Mis à jour",
|
||||
"agentDetail.configure.chatFeatures.description": "Façonnez l’expérience de chat de l’utilisateur final sur votre webapp et vos surfaces de chat.",
|
||||
"agentDetail.configure.chatFeatures.title": "Fonctionnalités de chat",
|
||||
"agentDetail.configure.communityEditionIsolationTip": "Community Edition ne fournit pas d’isolation stricte du système de fichiers entre les utilisateurs finaux ni entre les exécutions. N’exposez pas le même agent CE à plusieurs utilisateurs finaux indépendants lorsque l’isolation des données ou une conformité stricte est requise.",
|
||||
"agentDetail.configure.files.add": "Ajouter un fichier",
|
||||
"agentDetail.configure.files.buildNote.generated": "Généré",
|
||||
"agentDetail.configure.files.buildNote.richTooltip": "Le registre de l'agent sur ce qu'il a configuré en mode Build. Il le lit au début de chaque conversation, avec votre Prompt. <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 l’agent peut lire, comme des spécifications, des modèles ou des directives",
|
||||
"agentDetail.configure.files.empty.title": "Pas encore de fichiers",
|
||||
"agentDetail.configure.files.label": "Fichiers",
|
||||
|
||||
@@ -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": "फ़ाइलें",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -63,6 +63,7 @@
|
||||
"agentDetail.configure.advancedSettings.envEditor.valuePlaceholder": "Value",
|
||||
"agentDetail.configure.advancedSettings.label": "Impostazioni avanzate",
|
||||
"agentDetail.configure.advancedSettings.toggle": "Attiva/disattiva impostazioni avanzate",
|
||||
"agentDetail.configure.build.empty.communityEditionTip": "Community Edition, pur offrendo il versionamento delle configurazioni estratte, non supporta il versionamento del file system stesso. Fai attenzione alle azioni in modalità Build, perché le modifiche al file system avvengono in tempo reale e non sempre possono essere annullate in modo pulito. Community Edition non è la scelta ideale per servire un pubblico esterno di massa.",
|
||||
"agentDetail.configure.build.empty.description": "Descrivi ciò che vuoi e il modulo a sinistra verrà compilato man mano.",
|
||||
"agentDetail.configure.build.empty.title": "Crea il tuo agente con la chat",
|
||||
"agentDetail.configure.build.inputPlaceholder": "Descrivi cosa dovrebbe fare il tuo agente",
|
||||
@@ -72,16 +73,18 @@
|
||||
"agentDetail.configure.buildDraft.changesToApply_other": "{{count}} modifiche da applicare",
|
||||
"agentDetail.configure.buildDraft.discard": "Scarta",
|
||||
"agentDetail.configure.buildDraft.modeBadge": "Modalità build",
|
||||
"agentDetail.configure.buildDraft.modeDescription": "Sei in modalità build. Modella questa configurazione tramite la chat a destra, poi applica.",
|
||||
"agentDetail.configure.buildDraft.modeDescription": "Sei in modalità build. Configure può essere aggiornato solo dall’agente in questa modalità. Modella questa configurazione tramite la chat a destra, poi applica.",
|
||||
"agentDetail.configure.buildDraft.rewritten": "Riscritto",
|
||||
"agentDetail.configure.buildDraft.title": "Bozza di build",
|
||||
"agentDetail.configure.buildDraft.updated": "Aggiornato",
|
||||
"agentDetail.configure.chatFeatures.description": "Definisci l’esperienza di chat per l’utente finale sulla tua webapp e sulle superfici di chat.",
|
||||
"agentDetail.configure.chatFeatures.title": "Funzionalità chat",
|
||||
"agentDetail.configure.communityEditionIsolationTip": "Community Edition non fornisce un isolamento rigido del file system tra utenti finali o esecuzioni. Non esporre lo stesso agente CE a più utenti finali indipendenti quando sono richiesti isolamento dei dati o conformità rigorosa.",
|
||||
"agentDetail.configure.files.add": "Aggiungi file",
|
||||
"agentDetail.configure.files.buildNote.generated": "Generato",
|
||||
"agentDetail.configure.files.buildNote.richTooltip": "Il registro dell'agente di ciò che ha configurato in modalità Build. Lo legge all'inizio di ogni conversazione, insieme al tuo Prompt. <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 l’agente possa leggere, come specifiche, modelli o linee guida",
|
||||
"agentDetail.configure.files.empty.title": "Nessun file al momento",
|
||||
"agentDetail.configure.files.label": "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": "ファイル",
|
||||
|
||||
@@ -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": "파일",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user