mirror of
https://github.com/langgenius/dify.git
synced 2026-06-11 10:57:13 +08:00
Compare commits
3 Commits
feat/opena
...
codex/fix-
| Author | SHA1 | Date | |
|---|---|---|---|
| 5f51107260 | |||
| 44eda16261 | |||
| fe8b87d460 |
@ -90,12 +90,10 @@ class WorkflowAgentComposerValidateApi(Resource):
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@get_app_model(mode=[AppMode.WORKFLOW, AppMode.ADVANCED_CHAT])
|
||||
@with_current_tenant_id
|
||||
def post(self, tenant_id: str, app_model: App, node_id: str):
|
||||
def post(self, app_model: App, node_id: str):
|
||||
payload = ComposerSavePayload.model_validate(console_ns.payload or {})
|
||||
ComposerConfigValidator.validate_save_payload(payload)
|
||||
findings = AgentComposerService.collect_validation_findings(tenant_id=tenant_id, payload=payload)
|
||||
return dump_response(AgentComposerValidateResponse, {"result": "success", "errors": [], **findings})
|
||||
return dump_response(AgentComposerValidateResponse, {"result": "success", "errors": []})
|
||||
|
||||
|
||||
@console_ns.route("/apps/<uuid:app_id>/workflows/draft/nodes/<string:node_id>/agent-composer/candidates")
|
||||
@ -107,17 +105,10 @@ class WorkflowAgentComposerCandidatesApi(Resource):
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@get_app_model(mode=[AppMode.WORKFLOW, AppMode.ADVANCED_CHAT])
|
||||
@with_current_user_id
|
||||
@with_current_tenant_id
|
||||
def get(self, tenant_id: str, current_user_id: str, app_model: App, node_id: str):
|
||||
def get(self, app_model: App, node_id: str):
|
||||
return dump_response(
|
||||
AgentComposerCandidatesResponse,
|
||||
AgentComposerService.get_workflow_candidates(
|
||||
tenant_id=tenant_id,
|
||||
app_id=app_model.id,
|
||||
node_id=node_id,
|
||||
user_id=current_user_id,
|
||||
),
|
||||
AgentComposerService.get_workflow_candidates(app_id=app_model.id),
|
||||
)
|
||||
|
||||
|
||||
@ -176,7 +167,7 @@ class AgentAppComposerApi(Resource):
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@get_app_model(mode=[AppMode.AGENT])
|
||||
@get_app_model()
|
||||
@with_current_tenant_id
|
||||
def get(self, tenant_id: str, app_model: App):
|
||||
return dump_response(
|
||||
@ -190,7 +181,7 @@ class AgentAppComposerApi(Resource):
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@edit_permission_required
|
||||
@get_app_model(mode=[AppMode.AGENT])
|
||||
@get_app_model()
|
||||
@with_current_user_id
|
||||
@with_current_tenant_id
|
||||
def put(self, tenant_id: str, account_id: str, app_model: App):
|
||||
@ -215,13 +206,11 @@ class AgentAppComposerValidateApi(Resource):
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@get_app_model(mode=[AppMode.AGENT])
|
||||
@with_current_tenant_id
|
||||
def post(self, tenant_id: str, app_model: App):
|
||||
@get_app_model()
|
||||
def post(self, app_model: App):
|
||||
payload = ComposerSavePayload.model_validate(console_ns.payload or {})
|
||||
ComposerConfigValidator.validate_save_payload(payload)
|
||||
findings = AgentComposerService.collect_validation_findings(tenant_id=tenant_id, payload=payload)
|
||||
return dump_response(AgentComposerValidateResponse, {"result": "success", "errors": [], **findings})
|
||||
return dump_response(AgentComposerValidateResponse, {"result": "success", "errors": []})
|
||||
|
||||
|
||||
@console_ns.route("/apps/<uuid:app_id>/agent-composer/candidates")
|
||||
@ -232,15 +221,9 @@ class AgentAppComposerCandidatesApi(Resource):
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@get_app_model(mode=[AppMode.AGENT])
|
||||
@with_current_user_id
|
||||
@with_current_tenant_id
|
||||
def get(self, tenant_id: str, current_user_id: str, app_model: App):
|
||||
@get_app_model()
|
||||
def get(self, app_model: App):
|
||||
return dump_response(
|
||||
AgentComposerCandidatesResponse,
|
||||
AgentComposerService.get_agent_app_candidates(
|
||||
tenant_id=tenant_id,
|
||||
app_id=app_model.id,
|
||||
user_id=current_user_id,
|
||||
),
|
||||
AgentComposerService.get_agent_app_candidates(app_id=app_model.id),
|
||||
)
|
||||
|
||||
@ -2,17 +2,15 @@ from flask import request
|
||||
from flask_restx import Resource
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
from configs import dify_config
|
||||
from constants.languages import supported_language
|
||||
from controllers.common.schema import register_schema_models
|
||||
from controllers.console import console_ns
|
||||
from controllers.console.error import AccountInFreezeError, AlreadyActivateError
|
||||
from controllers.console.error import AlreadyActivateError
|
||||
from extensions.ext_database import db
|
||||
from libs.datetime_utils import naive_utc_now
|
||||
from libs.helper import EmailStr, timezone
|
||||
from models import AccountStatus
|
||||
from services.account_service import RegisterService
|
||||
from services.billing_service import BillingService
|
||||
|
||||
|
||||
class ActivateCheckQuery(BaseModel):
|
||||
@ -122,12 +120,9 @@ class ActivateApi(Resource):
|
||||
if invitation is None:
|
||||
raise AlreadyActivateError()
|
||||
|
||||
account = invitation["account"]
|
||||
if dify_config.BILLING_ENABLED and BillingService.is_email_in_freeze(account.email):
|
||||
raise AccountInFreezeError()
|
||||
|
||||
RegisterService.revoke_token(args.workspace_id, normalized_request_email, args.token)
|
||||
|
||||
account = invitation["account"]
|
||||
account.name = args.name
|
||||
|
||||
account.interface_language = args.interface_language
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
from flask import Blueprint
|
||||
from flask_restx import Namespace
|
||||
|
||||
from controllers.openapi._errors import ErrorBody, OpenApiErrorFormatter
|
||||
from libs.device_flow_security import attach_anti_framing
|
||||
from libs.external_api import ExternalApi
|
||||
|
||||
@ -13,7 +12,6 @@ api = ExternalApi(
|
||||
version="1.0",
|
||||
title="OpenAPI",
|
||||
description="User-scoped programmatic API (bearer auth)",
|
||||
error_body_formatter=OpenApiErrorFormatter(),
|
||||
)
|
||||
|
||||
openapi_ns = Namespace("openapi", description="User-scoped operations", path="/")
|
||||
@ -91,7 +89,6 @@ register_schema_models(
|
||||
)
|
||||
register_response_schema_models(
|
||||
openapi_ns,
|
||||
ErrorBody,
|
||||
TagItem,
|
||||
UsageInfo,
|
||||
MessageMetadata,
|
||||
|
||||
@ -21,7 +21,6 @@ from pydantic import BaseModel, ValidationError
|
||||
|
||||
from controllers.common.schema import query_params_from_model, query_params_from_request
|
||||
from controllers.openapi import openapi_ns
|
||||
from controllers.openapi._errors import ErrorBody
|
||||
|
||||
|
||||
def accepts(*, query: type[BaseModel] | None = None, body: type[BaseModel] | None = None) -> Callable:
|
||||
@ -52,8 +51,6 @@ def accepts(*, query: type[BaseModel] | None = None, body: type[BaseModel] | Non
|
||||
openapi_ns.doc(params=query_params_from_model(query))(wrapper)
|
||||
if body is not None:
|
||||
openapi_ns.expect(openapi_ns.models[body.__name__])(wrapper)
|
||||
if query is not None or body is not None:
|
||||
openapi_ns.response(422, "Validation error", openapi_ns.models[ErrorBody.__name__])(wrapper)
|
||||
return wrapper
|
||||
|
||||
return decorator
|
||||
@ -79,7 +76,6 @@ def returns(code: int, model: type[BaseModel], description: str | None = None) -
|
||||
return result
|
||||
|
||||
openapi_ns.response(code, description or model.__name__, openapi_ns.models[model.__name__])(wrapper)
|
||||
openapi_ns.response("default", "Error", openapi_ns.models[ErrorBody.__name__])(wrapper)
|
||||
return wrapper
|
||||
|
||||
return decorator
|
||||
|
||||
@ -1,241 +0,0 @@
|
||||
"""Canonical error contract for the /openapi/v1 surface.
|
||||
|
||||
``ErrorBody`` is the only wire shape an /openapi/v1 endpoint may emit for a
|
||||
non-2xx response (RFC 8628 device-flow responses excepted — that shape is
|
||||
mandated by the OAuth spec)::
|
||||
|
||||
code str semantic error code (OpenApiErrorCode member)
|
||||
message str human-readable summary
|
||||
status int HTTP status, duplicated in the body
|
||||
hint str | None actionable next step for the caller
|
||||
details list[ErrorDetail] per-field validation breakdown {type, loc, msg}
|
||||
|
||||
``OpenApiErrorFormatter`` is injected into ``ExternalApi`` so every
|
||||
error-handler path funnels through one builder, and it also rewrites
|
||||
``e.data`` because flask-restx ``Api.handle_error`` lets a pre-existing
|
||||
``e.data`` override the registered handler's return value.
|
||||
|
||||
The transport-generic enum members, ``_CODE_BY_STATUS`` and the
|
||||
``OpenApiError``/``OpenApiErrorFormatter`` bases are openapi-only today;
|
||||
promote them to ``libs`` if a second surface adopts ``ErrorBody``.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from enum import StrEnum
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel
|
||||
from werkzeug.exceptions import HTTPException
|
||||
|
||||
from libs.external_api import http_status_message
|
||||
|
||||
|
||||
class OpenApiErrorCode(StrEnum):
|
||||
# transport-generic (resolved from HTTP status for plain werkzeug raises)
|
||||
BAD_REQUEST = "bad_request"
|
||||
UNAUTHORIZED = "unauthorized"
|
||||
FORBIDDEN = "forbidden"
|
||||
NOT_FOUND = "not_found"
|
||||
METHOD_NOT_ALLOWED = "method_not_allowed"
|
||||
NOT_ACCEPTABLE = "not_acceptable"
|
||||
CONFLICT = "conflict"
|
||||
REQUEST_TOO_LARGE = "request_entity_too_large"
|
||||
UNSUPPORTED_MEDIA_TYPE = "unsupported_media_type"
|
||||
INVALID_PARAM = "invalid_param"
|
||||
TOO_MANY_REQUESTS = "too_many_requests"
|
||||
INTERNAL_ERROR = "internal_server_error"
|
||||
BAD_GATEWAY = "bad_gateway"
|
||||
UNKNOWN = "unknown"
|
||||
# domain codes (must match the error_code attribute of the exception
|
||||
# classes raised on the openapi surface)
|
||||
APP_UNAVAILABLE = "app_unavailable"
|
||||
CONVERSATION_COMPLETED = "conversation_completed"
|
||||
PROVIDER_NOT_INITIALIZE = "provider_not_initialize"
|
||||
PROVIDER_QUOTA_EXCEEDED = "provider_quota_exceeded"
|
||||
MODEL_NOT_SUPPORTED = "model_currently_not_support"
|
||||
COMPLETION_REQUEST_ERROR = "completion_request_error"
|
||||
RATE_LIMIT_ERROR = "rate_limit_error"
|
||||
FILE_TOO_LARGE = "file_too_large"
|
||||
UNSUPPORTED_FILE_TYPE = "unsupported_file_type"
|
||||
NO_FILE_UPLOADED = "no_file_uploaded"
|
||||
TOO_MANY_FILES = "too_many_files"
|
||||
FILENAME_NOT_EXISTS = "filename_not_exists"
|
||||
FILE_EXTENSION_BLOCKED = "file_extension_blocked"
|
||||
MEMBER_LIMIT_EXCEEDED = "member_limit_exceeded"
|
||||
MEMBER_LICENSE_EXCEEDED = "member_license_exceeded"
|
||||
|
||||
|
||||
class ErrorDetail(BaseModel):
|
||||
type: str
|
||||
loc: list[str | int] = []
|
||||
msg: str
|
||||
|
||||
|
||||
class ErrorBody(BaseModel):
|
||||
"""Canonical non-2xx body. ``code`` is typed ``str`` (not the enum) so the
|
||||
generated client schema stays an open enum — old CLIs keep parsing when a
|
||||
future server adds a code. Formatter tests pin emitted values to the enum."""
|
||||
|
||||
code: str
|
||||
message: str
|
||||
status: int
|
||||
hint: str | None = None
|
||||
details: list[ErrorDetail] | None = None
|
||||
|
||||
|
||||
_CODE_BY_STATUS: dict[int, OpenApiErrorCode] = {
|
||||
400: OpenApiErrorCode.BAD_REQUEST,
|
||||
401: OpenApiErrorCode.UNAUTHORIZED,
|
||||
403: OpenApiErrorCode.FORBIDDEN,
|
||||
404: OpenApiErrorCode.NOT_FOUND,
|
||||
405: OpenApiErrorCode.METHOD_NOT_ALLOWED,
|
||||
406: OpenApiErrorCode.NOT_ACCEPTABLE,
|
||||
409: OpenApiErrorCode.CONFLICT,
|
||||
413: OpenApiErrorCode.REQUEST_TOO_LARGE,
|
||||
415: OpenApiErrorCode.UNSUPPORTED_MEDIA_TYPE,
|
||||
422: OpenApiErrorCode.INVALID_PARAM,
|
||||
429: OpenApiErrorCode.TOO_MANY_REQUESTS,
|
||||
500: OpenApiErrorCode.INTERNAL_ERROR,
|
||||
502: OpenApiErrorCode.BAD_GATEWAY,
|
||||
}
|
||||
|
||||
_GENERIC_500_MESSAGE = "Internal Server Error"
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class OpenApiError(HTTPException):
|
||||
"""Dedicated throwable for the /openapi/v1 surface.
|
||||
|
||||
A subclass declares ``code`` (HTTP status), ``error_code`` and
|
||||
``description`` exactly once; call sites just ``raise SomeError()`` —
|
||||
no per-site dict building, no duplicated message constants. The
|
||||
formatter emits all three (plus optional ``hint``/``details``) verbatim.
|
||||
"""
|
||||
|
||||
code = 400
|
||||
error_code: OpenApiErrorCode = OpenApiErrorCode.UNKNOWN
|
||||
hint: str | None = None
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str | None = None,
|
||||
*,
|
||||
hint: str | None = None,
|
||||
details: list[ErrorDetail] | None = None,
|
||||
) -> None:
|
||||
super().__init__(description=message)
|
||||
if hint is not None:
|
||||
self.hint = hint
|
||||
self.details = details
|
||||
|
||||
|
||||
class OpenApiErrorFormatter:
|
||||
"""Builds the canonical ErrorBody from whatever the shared handlers computed.
|
||||
|
||||
Resolution order for ``code``: explicit ``error_code`` class attribute
|
||||
(BaseHTTPException subclasses and OpenApiError subclasses) → HTTP status
|
||||
map → ``unknown``. Class-name-derived codes from the shared handler are
|
||||
deliberately ignored — they are not a stable contract.
|
||||
"""
|
||||
|
||||
def finalize(self, e: Exception, data: dict[str, Any], status_code: int) -> dict[str, Any]:
|
||||
exc_data = getattr(e, "data", None)
|
||||
merged: dict[str, Any] = {**data, **exc_data} if isinstance(exc_data, dict) else dict(data)
|
||||
|
||||
# finalize runs inside the framework error handler: raising here would
|
||||
# replace the response with an unformatted 500, so fall back instead
|
||||
try:
|
||||
body = ErrorBody(
|
||||
code=self._resolve_code(e, status_code),
|
||||
message=self._resolve_message(merged, status_code),
|
||||
status=status_code,
|
||||
hint=self._resolve_hint(e),
|
||||
details=self._extract_details(e, merged),
|
||||
)
|
||||
wire = body.model_dump(mode="json", exclude_none=True)
|
||||
except Exception:
|
||||
logger.exception("error-body build failed; emitting fallback body")
|
||||
wire = {
|
||||
"code": str(_CODE_BY_STATUS.get(status_code, OpenApiErrorCode.UNKNOWN)),
|
||||
"message": http_status_message(status_code) or "request failed",
|
||||
"status": status_code,
|
||||
}
|
||||
|
||||
# flask-restx Api.handle_error does `data = getattr(e, "data", default_data)`
|
||||
# AFTER our handler returns, so a pre-existing e.data (flask_restx.abort,
|
||||
# BaseHTTPException) would override the canonical body. Rewrite it.
|
||||
try:
|
||||
e.data = wire # type: ignore[attr-defined]
|
||||
except AttributeError:
|
||||
pass
|
||||
return wire
|
||||
|
||||
def _resolve_code(self, e: Exception, status_code: int) -> str:
|
||||
explicit = getattr(type(e), "error_code", None)
|
||||
if isinstance(explicit, (OpenApiErrorCode, str)) and str(explicit) != "unknown":
|
||||
return str(explicit)
|
||||
return str(_CODE_BY_STATUS.get(status_code, OpenApiErrorCode.UNKNOWN))
|
||||
|
||||
def _resolve_message(self, merged: dict[str, Any], status_code: int) -> str:
|
||||
if status_code >= 500:
|
||||
return _GENERIC_500_MESSAGE
|
||||
message = merged.get("message")
|
||||
if isinstance(message, str) and message:
|
||||
return message
|
||||
return http_status_message(status_code) or "request failed"
|
||||
|
||||
def _resolve_hint(self, e: Exception) -> str | None:
|
||||
hint = getattr(e, "hint", None)
|
||||
return hint if isinstance(hint, str) and hint else None
|
||||
|
||||
def _extract_details(self, e: Exception, merged: dict[str, Any]) -> list[ErrorDetail] | None:
|
||||
explicit = getattr(e, "details", None)
|
||||
if isinstance(explicit, list) and explicit and all(isinstance(d, ErrorDetail) for d in explicit):
|
||||
return explicit
|
||||
# an already-canonical body (e.g. e.data rewritten by a prior finalize)
|
||||
# carries "details"; re-validate so finalize stays idempotent
|
||||
canonical = merged.get("details")
|
||||
if isinstance(canonical, list) and canonical and all(isinstance(d, dict) for d in canonical):
|
||||
return [ErrorDetail.model_validate(d) for d in canonical]
|
||||
errors = merged.get("errors")
|
||||
if isinstance(errors, list) and errors:
|
||||
details = [
|
||||
ErrorDetail(
|
||||
type=str(item.get("type", "invalid")),
|
||||
loc=[part for part in item.get("loc", []) if self._is_loc_part(part)],
|
||||
msg=str(item.get("msg", "")),
|
||||
)
|
||||
for item in errors
|
||||
if isinstance(item, dict)
|
||||
]
|
||||
return details or None
|
||||
params = merged.get("params")
|
||||
if isinstance(params, str) and params:
|
||||
return [ErrorDetail(type="invalid", loc=[params], msg=str(merged.get("message", "")))]
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _is_loc_part(part: Any) -> bool:
|
||||
# bool is an int subclass but is not a valid path segment
|
||||
return isinstance(part, (str, int)) and not isinstance(part, bool)
|
||||
|
||||
|
||||
class FilenameNotExists(OpenApiError): # noqa: N818
|
||||
code = 400
|
||||
error_code = OpenApiErrorCode.FILENAME_NOT_EXISTS
|
||||
description = "The specified filename does not exist."
|
||||
|
||||
|
||||
class MemberLimitExceeded(OpenApiError): # noqa: N818
|
||||
code = 403
|
||||
error_code = OpenApiErrorCode.MEMBER_LIMIT_EXCEEDED
|
||||
description = "Subscription member limit reached."
|
||||
hint = "Upgrade your plan to invite more members or remove an existing member first."
|
||||
|
||||
|
||||
class MemberLicenseExceeded(OpenApiError): # noqa: N818
|
||||
code = 403
|
||||
error_code = OpenApiErrorCode.MEMBER_LICENSE_EXCEEDED
|
||||
description = "Workspace member license capacity reached."
|
||||
hint = "Contact your workspace administrator to expand the license seat count."
|
||||
@ -10,6 +10,7 @@ from werkzeug.exceptions import BadRequest
|
||||
import services
|
||||
from controllers.common.errors import (
|
||||
BlockedFileExtensionError,
|
||||
FilenameNotExistsError,
|
||||
FileTooLargeError,
|
||||
NoFileUploadedError,
|
||||
TooManyFilesError,
|
||||
@ -17,7 +18,6 @@ from controllers.common.errors import (
|
||||
)
|
||||
from controllers.openapi import openapi_ns
|
||||
from controllers.openapi._contract import returns
|
||||
from controllers.openapi._errors import FilenameNotExists
|
||||
from controllers.openapi.auth.composition import auth_router
|
||||
from controllers.openapi.auth.data import AuthData
|
||||
from extensions.ext_database import db
|
||||
@ -52,7 +52,7 @@ class AppFileUploadApi(Resource):
|
||||
if not file.mimetype:
|
||||
raise UnsupportedFileTypeError()
|
||||
if not file.filename:
|
||||
raise FilenameNotExists()
|
||||
raise FilenameNotExistsError()
|
||||
|
||||
try:
|
||||
upload_file = FileService(db.engine).upload_file(
|
||||
|
||||
@ -14,13 +14,13 @@ from __future__ import annotations
|
||||
from itertools import starmap
|
||||
from urllib import parse
|
||||
|
||||
from flask import jsonify, make_response
|
||||
from flask_restx import Resource
|
||||
from werkzeug.exceptions import BadRequest, NotFound
|
||||
from werkzeug.exceptions import BadRequest, Forbidden, NotFound
|
||||
|
||||
from configs import dify_config
|
||||
from controllers.openapi import openapi_ns
|
||||
from controllers.openapi._contract import accepts, returns
|
||||
from controllers.openapi._errors import MemberLicenseExceeded, MemberLimitExceeded
|
||||
from controllers.openapi._models import (
|
||||
MemberActionResponse,
|
||||
MemberInvitePayload,
|
||||
@ -77,16 +77,34 @@ def _load_account(account_id: object) -> Account:
|
||||
return account
|
||||
|
||||
|
||||
def _quota_error(*, code: str, message: str, hint: str) -> Forbidden:
|
||||
err = Forbidden(message)
|
||||
err.response = make_response(
|
||||
jsonify({"code": code, "message": message, "hint": hint}),
|
||||
403,
|
||||
)
|
||||
return err
|
||||
|
||||
|
||||
def _check_member_invite_quota(tenant_id: str) -> None:
|
||||
features = FeatureService.get_features(tenant_id)
|
||||
|
||||
if features.billing.enabled:
|
||||
members = features.members
|
||||
if 0 < members.limit <= members.size:
|
||||
raise MemberLimitExceeded()
|
||||
raise _quota_error(
|
||||
code="members.limit_exceeded",
|
||||
message="Subscription member limit reached.",
|
||||
hint="Upgrade your plan to invite more members or remove an existing member first.",
|
||||
)
|
||||
|
||||
if features.workspace_members.enabled and not features.workspace_members.is_available(1):
|
||||
raise MemberLicenseExceeded()
|
||||
if features.workspace_members.enabled:
|
||||
if not features.workspace_members.is_available(1):
|
||||
raise _quota_error(
|
||||
code="workspace_members.license_exceeded",
|
||||
message="Workspace member license capacity reached.",
|
||||
hint="Contact your workspace administrator to expand the license seat count.",
|
||||
)
|
||||
|
||||
|
||||
@openapi_ns.route("/workspaces")
|
||||
|
||||
@ -35,7 +35,6 @@ from core.workflow.nodes.agent_v2.plugin_tools_builder import (
|
||||
from core.workflow.nodes.agent_v2.runtime_request_builder import build_shell_layer_config
|
||||
from models.agent_config_entities import AgentSoulConfig
|
||||
from models.provider_ids import ModelProviderID
|
||||
from services.agent.prompt_mentions import build_soul_mention_resolver, expand_prompt_mentions
|
||||
|
||||
|
||||
class AgentAppRuntimeRequestBuildError(ValueError):
|
||||
@ -136,12 +135,7 @@ class AgentAppRuntimeRequestBuilder:
|
||||
invoke_from=cast(DifyExecutionContextInvokeFrom, context.dify_context.invoke_from.value),
|
||||
agent_mode="agent_app",
|
||||
),
|
||||
# ENG-616: expand slash-menu mention tokens to canonical names so
|
||||
# no frontend-internal {{#…#}} marker ever reaches the model.
|
||||
agent_soul_prompt=expand_prompt_mentions(
|
||||
agent_soul.prompt.system_prompt, build_soul_mention_resolver(agent_soul)
|
||||
).strip()
|
||||
or None,
|
||||
agent_soul_prompt=agent_soul.prompt.system_prompt or None,
|
||||
user_prompt=context.user_query,
|
||||
tools=tools_layer,
|
||||
include_shell=dify_config.AGENT_SHELL_ENABLED,
|
||||
|
||||
@ -1,86 +0,0 @@
|
||||
"""Draft-workflow graph topology helper, shared by Agent v2 publish validation
|
||||
and the agent-composer candidates endpoint (ENG-615).
|
||||
|
||||
Extracted from ``core/workflow/nodes/agent_v2/validators.py`` so both call sites
|
||||
parse the same ``Workflow.graph`` JSON shape (``nodes`` with string ids,
|
||||
``edges`` with ``source``/``target``).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import defaultdict, deque
|
||||
from collections.abc import Mapping, Sequence
|
||||
from typing import Any
|
||||
|
||||
|
||||
class WorkflowGraphTopology:
|
||||
def __init__(self, *, node_ids: set[str], incoming: Mapping[str, Sequence[str]]) -> None:
|
||||
self._node_ids = node_ids
|
||||
self._incoming = incoming
|
||||
|
||||
@classmethod
|
||||
def from_graph(cls, graph: Mapping[str, Any]) -> WorkflowGraphTopology:
|
||||
node_ids = cls._node_ids_from_graph(graph)
|
||||
incoming: dict[str, list[str]] = defaultdict(list)
|
||||
edges = graph.get("edges")
|
||||
if isinstance(edges, list):
|
||||
for edge in edges:
|
||||
if not isinstance(edge, Mapping):
|
||||
continue
|
||||
source = edge.get("source")
|
||||
target = edge.get("target")
|
||||
if isinstance(source, str) and isinstance(target, str):
|
||||
incoming[target].append(source)
|
||||
return cls(node_ids=node_ids, incoming=incoming)
|
||||
|
||||
def has_node(self, node_id: str) -> bool:
|
||||
return node_id in self._node_ids
|
||||
|
||||
def is_upstream(self, *, source_node_id: str, target_node_id: str) -> bool:
|
||||
if source_node_id == target_node_id:
|
||||
return False
|
||||
visited: set[str] = set()
|
||||
queue: deque[str] = deque(self._incoming.get(target_node_id, ()))
|
||||
while queue:
|
||||
candidate = queue.popleft()
|
||||
if candidate == source_node_id:
|
||||
return True
|
||||
if candidate in visited:
|
||||
continue
|
||||
visited.add(candidate)
|
||||
queue.extend(self._incoming.get(candidate, ()))
|
||||
return False
|
||||
|
||||
def upstream_node_ids(self, target_node_id: str) -> set[str]:
|
||||
"""All graph nodes reachable upstream of ``target_node_id`` (excluding it).
|
||||
|
||||
Edges may reference ids missing from ``nodes`` (half-deleted graphs);
|
||||
only real nodes are returned.
|
||||
"""
|
||||
visited: set[str] = set()
|
||||
queue: deque[str] = deque(self._incoming.get(target_node_id, ()))
|
||||
while queue:
|
||||
candidate = queue.popleft()
|
||||
if candidate in visited:
|
||||
continue
|
||||
visited.add(candidate)
|
||||
queue.extend(self._incoming.get(candidate, ()))
|
||||
visited.discard(target_node_id)
|
||||
return visited & self._node_ids
|
||||
|
||||
@staticmethod
|
||||
def _node_ids_from_graph(graph: Mapping[str, Any]) -> set[str]:
|
||||
node_ids: set[str] = set()
|
||||
nodes = graph.get("nodes")
|
||||
if not isinstance(nodes, list):
|
||||
return node_ids
|
||||
for node in nodes:
|
||||
if not isinstance(node, Mapping):
|
||||
continue
|
||||
node_id = node.get("id")
|
||||
if isinstance(node_id, str):
|
||||
node_ids.add(node_id)
|
||||
return node_ids
|
||||
|
||||
|
||||
__all__ = ["WorkflowGraphTopology"]
|
||||
@ -45,11 +45,6 @@ from models.agent_config_entities import (
|
||||
effective_declared_outputs as _effective_declared_outputs,
|
||||
)
|
||||
from models.provider_ids import ModelProviderID
|
||||
from services.agent.prompt_mentions import (
|
||||
build_node_job_mention_resolver,
|
||||
build_soul_mention_resolver,
|
||||
expand_prompt_mentions,
|
||||
)
|
||||
|
||||
from .output_failure_orchestrator import retry_idempotency_key
|
||||
from .plugin_tools_builder import WorkflowAgentPluginToolsBuilder, WorkflowAgentPluginToolsBuildError
|
||||
@ -134,16 +129,7 @@ class WorkflowAgentRuntimeRequestBuilder:
|
||||
|
||||
metadata = self._build_metadata(context, agent_soul, node_job)
|
||||
workflow_context_prompt = self._build_workflow_context_prompt(context, node_job)
|
||||
# ENG-616: expand slash-menu mention tokens into model-readable names.
|
||||
# node_output mentions expand to their reference name only — the value
|
||||
# stays in the Workflow context block (user_prompt) below.
|
||||
workflow_job_prompt = (
|
||||
expand_prompt_mentions(node_job.workflow_prompt, build_node_job_mention_resolver(node_job)).strip()
|
||||
or "Run this workflow Agent Node for the current run."
|
||||
)
|
||||
soul_prompt = expand_prompt_mentions(
|
||||
agent_soul.prompt.system_prompt, build_soul_mention_resolver(agent_soul)
|
||||
).strip()
|
||||
workflow_job_prompt = node_job.workflow_prompt.strip() or "Run this workflow Agent Node for the current run."
|
||||
user_prompt = workflow_context_prompt.strip() or "Use the current workflow context."
|
||||
credentials = self._credentials_provider.fetch(agent_soul.model.model_provider, agent_soul.model.model)
|
||||
try:
|
||||
@ -201,7 +187,7 @@ class WorkflowAgentRuntimeRequestBuilder:
|
||||
agent_mode=self._agent_backend_agent_mode(context.dify_context.invoke_from),
|
||||
invoke_from=cast(DifyExecutionContextInvokeFrom, context.dify_context.invoke_from.value),
|
||||
),
|
||||
agent_soul_prompt=soul_prompt or None,
|
||||
agent_soul_prompt=agent_soul.prompt.system_prompt or None,
|
||||
workflow_node_job_prompt=workflow_job_prompt,
|
||||
user_prompt=user_prompt,
|
||||
output=self._build_output_config(node_job.declared_outputs),
|
||||
|
||||
@ -1,12 +1,12 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterator, Mapping
|
||||
from collections import defaultdict, deque
|
||||
from collections.abc import Iterator, Mapping, Sequence
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from core.workflow.graph_topology import WorkflowGraphTopology
|
||||
from graphon.enums import BuiltinNodeTypes
|
||||
from models.agent import Agent, AgentConfigSnapshot, AgentStatus, WorkflowAgentNodeBinding
|
||||
from models.agent_config_entities import (
|
||||
@ -523,6 +523,54 @@ class WorkflowAgentNodeValidator:
|
||||
)
|
||||
|
||||
|
||||
# Extracted to core/workflow/graph_topology.py (shared with the agent-composer
|
||||
# candidates endpoint, ENG-615); kept as a private alias for existing call sites.
|
||||
_WorkflowGraphTopology = WorkflowGraphTopology
|
||||
class _WorkflowGraphTopology:
|
||||
def __init__(self, *, node_ids: set[str], incoming: Mapping[str, Sequence[str]]) -> None:
|
||||
self._node_ids = node_ids
|
||||
self._incoming = incoming
|
||||
|
||||
@classmethod
|
||||
def from_graph(cls, graph: Mapping[str, Any]) -> _WorkflowGraphTopology:
|
||||
node_ids = cls._node_ids_from_graph(graph)
|
||||
incoming: dict[str, list[str]] = defaultdict(list)
|
||||
edges = graph.get("edges")
|
||||
if isinstance(edges, list):
|
||||
for edge in edges:
|
||||
if not isinstance(edge, Mapping):
|
||||
continue
|
||||
source = edge.get("source")
|
||||
target = edge.get("target")
|
||||
if isinstance(source, str) and isinstance(target, str):
|
||||
incoming[target].append(source)
|
||||
return cls(node_ids=node_ids, incoming=incoming)
|
||||
|
||||
def has_node(self, node_id: str) -> bool:
|
||||
return node_id in self._node_ids
|
||||
|
||||
def is_upstream(self, *, source_node_id: str, target_node_id: str) -> bool:
|
||||
if source_node_id == target_node_id:
|
||||
return False
|
||||
visited: set[str] = set()
|
||||
queue: deque[str] = deque(self._incoming.get(target_node_id, ()))
|
||||
while queue:
|
||||
candidate = queue.popleft()
|
||||
if candidate == source_node_id:
|
||||
return True
|
||||
if candidate in visited:
|
||||
continue
|
||||
visited.add(candidate)
|
||||
queue.extend(self._incoming.get(candidate, ()))
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _node_ids_from_graph(graph: Mapping[str, Any]) -> set[str]:
|
||||
node_ids: set[str] = set()
|
||||
nodes = graph.get("nodes")
|
||||
if not isinstance(nodes, list):
|
||||
return node_ids
|
||||
for node in nodes:
|
||||
if not isinstance(node, Mapping):
|
||||
continue
|
||||
node_id = node.get("id")
|
||||
if isinstance(node_id, str):
|
||||
node_ids.add(node_id)
|
||||
return node_ids
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
from typing import Annotated, Literal
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import Field
|
||||
|
||||
@ -14,7 +14,6 @@ from models.agent import (
|
||||
)
|
||||
from models.agent_config_entities import (
|
||||
AgentCliToolConfig,
|
||||
AgentFileRefConfig,
|
||||
AgentHumanContactConfig,
|
||||
AgentKnowledgeDatasetConfig,
|
||||
AgentSkillRefConfig,
|
||||
@ -155,7 +154,6 @@ class WorkflowAgentComposerResponse(ResponseModel):
|
||||
effective_declared_outputs: list[DeclaredOutputConfig] = Field(default_factory=list)
|
||||
save_options: list[ComposerSaveStrategy]
|
||||
impact_summary: AgentComposerImpactResponse | None = None
|
||||
validation: "ComposerValidationFindingsResponse | None" = None
|
||||
app_id: str | None = None
|
||||
workflow_id: str | None = None
|
||||
node_id: str | None = None
|
||||
@ -167,32 +165,11 @@ class AgentAppComposerResponse(ResponseModel):
|
||||
active_config_snapshot: AgentConfigSnapshotSummaryResponse
|
||||
agent_soul: AgentSoulConfig
|
||||
save_options: list[ComposerSaveStrategy]
|
||||
validation: "ComposerValidationFindingsResponse | None" = None
|
||||
|
||||
|
||||
class ComposerValidationWarningResponse(ResponseModel):
|
||||
code: str
|
||||
surface: str | None = None
|
||||
kind: str | None = None
|
||||
id: str | None = None
|
||||
message: str | None = None
|
||||
|
||||
|
||||
class ComposerKnowledgePlaceholderResponse(ResponseModel):
|
||||
id: str
|
||||
placeholder_name: str
|
||||
|
||||
|
||||
class ComposerValidationFindingsResponse(ResponseModel):
|
||||
warnings: list[ComposerValidationWarningResponse] = Field(default_factory=list)
|
||||
knowledge_retrieval_placeholder: list[ComposerKnowledgePlaceholderResponse] = Field(default_factory=list)
|
||||
|
||||
|
||||
class AgentComposerValidateResponse(ResponseModel):
|
||||
result: Literal["success"]
|
||||
errors: list[str] = Field(default_factory=list)
|
||||
warnings: list[ComposerValidationWarningResponse] = Field(default_factory=list)
|
||||
knowledge_retrieval_placeholder: list[ComposerKnowledgePlaceholderResponse] = Field(default_factory=list)
|
||||
|
||||
|
||||
class AgentComposerDifyToolCandidateResponse(ResponseModel):
|
||||
@ -204,20 +181,6 @@ class AgentComposerDifyToolCandidateResponse(ResponseModel):
|
||||
plugin_id: str | None = None
|
||||
|
||||
|
||||
class AgentComposerSkillCandidateResponse(AgentSkillRefConfig):
|
||||
kind: Literal["skill"] = "skill"
|
||||
|
||||
|
||||
class AgentComposerFileCandidateResponse(AgentFileRefConfig):
|
||||
kind: Literal["file"] = "file"
|
||||
|
||||
|
||||
AgentComposerSkillFileCandidateResponse = Annotated[
|
||||
AgentComposerSkillCandidateResponse | AgentComposerFileCandidateResponse,
|
||||
Field(discriminator="kind"),
|
||||
]
|
||||
|
||||
|
||||
class AgentComposerNodeJobCandidatesResponse(ResponseModel):
|
||||
previous_node_outputs: list[WorkflowPreviousNodeOutputRef] = Field(default_factory=list)
|
||||
declare_output_types: list[DeclaredOutputType] = Field(default_factory=list)
|
||||
@ -225,7 +188,7 @@ class AgentComposerNodeJobCandidatesResponse(ResponseModel):
|
||||
|
||||
|
||||
class AgentComposerSoulCandidatesResponse(ResponseModel):
|
||||
skills_files: list[AgentComposerSkillFileCandidateResponse] = Field(default_factory=list)
|
||||
skills_files: list[AgentSkillRefConfig] = Field(default_factory=list)
|
||||
dify_tools: list[AgentComposerDifyToolCandidateResponse] = Field(default_factory=list)
|
||||
cli_tools: list[AgentCliToolConfig] = Field(default_factory=list)
|
||||
knowledge_datasets: list[AgentKnowledgeDatasetConfig] = Field(default_factory=list)
|
||||
@ -241,4 +204,3 @@ class AgentComposerCandidatesResponse(ResponseModel):
|
||||
default_factory=AgentComposerSoulCandidatesResponse
|
||||
)
|
||||
capabilities: ComposerCandidateCapabilities = Field(default_factory=ComposerCandidateCapabilities)
|
||||
truncated: bool = False
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
import re
|
||||
from collections.abc import Mapping
|
||||
from typing import Any, Protocol, override
|
||||
from typing import Any
|
||||
|
||||
from flask import Blueprint, Flask, current_app, got_request_exception, request
|
||||
from flask import Blueprint, Flask, current_app, got_request_exception
|
||||
from flask_restx import Api
|
||||
from werkzeug.exceptions import HTTPException
|
||||
from werkzeug.http import HTTP_STATUS_CODES
|
||||
@ -17,24 +17,11 @@ def http_status_message(code):
|
||||
return HTTP_STATUS_CODES.get(code, "")
|
||||
|
||||
|
||||
class ErrorBodyFormatter(Protocol):
|
||||
"""Last-touch hook over an error body before it goes on the wire."""
|
||||
|
||||
def finalize(self, e: Exception, data: dict[str, Any], status_code: int) -> dict[str, Any]: ...
|
||||
|
||||
|
||||
def register_external_error_handlers(api: Api, body_formatter: ErrorBodyFormatter | None = None):
|
||||
def _finalize(e: Exception, data: dict[str, Any], status_code: int) -> dict[str, Any]:
|
||||
if body_formatter is None:
|
||||
return data
|
||||
return body_formatter.finalize(e, data, status_code)
|
||||
|
||||
def register_external_error_handlers(api: Api):
|
||||
def handle_http_exception(e: HTTPException):
|
||||
got_request_exception.send(current_app, exception=e)
|
||||
|
||||
# If Werkzeug already prepared a Response, just use it. This bypasses
|
||||
# body_formatter entirely — surfaces with a formatter must not raise
|
||||
# exceptions carrying a pre-built response.
|
||||
# If Werkzeug already prepared a Response, just use it.
|
||||
if e.response is not None:
|
||||
return e.response
|
||||
|
||||
@ -58,7 +45,7 @@ def register_external_error_handlers(api: Api, body_formatter: ErrorBodyFormatte
|
||||
# Payload per status
|
||||
if status_code == 406 and api.default_mediatype is None:
|
||||
data = {"code": "not_acceptable", "message": default_data["message"], "status": status_code}
|
||||
return _finalize(e, data, status_code), status_code, headers
|
||||
return data, status_code, headers
|
||||
elif status_code == 400:
|
||||
msg = default_data["message"]
|
||||
if isinstance(msg, Mapping) and msg:
|
||||
@ -73,7 +60,7 @@ def register_external_error_handlers(api: Api, body_formatter: ErrorBodyFormatte
|
||||
else:
|
||||
data = {**default_data}
|
||||
data.setdefault("code", "unknown")
|
||||
return _finalize(e, data, status_code), status_code, headers
|
||||
return data, status_code, headers
|
||||
else:
|
||||
data = {**default_data}
|
||||
data.setdefault("code", "unknown")
|
||||
@ -85,20 +72,20 @@ def register_external_error_handlers(api: Api, body_formatter: ErrorBodyFormatte
|
||||
if error_code == "unauthorized_and_force_logout":
|
||||
# Add Set-Cookie headers to clear auth cookies
|
||||
headers["Set-Cookie"] = build_force_logout_cookie_headers()
|
||||
return _finalize(e, data, status_code), status_code, headers
|
||||
return data, status_code, headers
|
||||
|
||||
def handle_value_error(e: ValueError):
|
||||
got_request_exception.send(current_app, exception=e)
|
||||
current_app.logger.exception("value_error in request handler")
|
||||
status_code = 400
|
||||
data = {"code": "invalid_param", "message": str(e), "status": status_code}
|
||||
return _finalize(e, data, status_code), status_code
|
||||
return data, status_code
|
||||
|
||||
def handle_quota_exceeded(e: AppInvokeQuotaExceededError):
|
||||
got_request_exception.send(current_app, exception=e)
|
||||
status_code = 429
|
||||
data = {"code": "too_many_requests", "message": str(e), "status": status_code}
|
||||
return _finalize(e, data, status_code), status_code
|
||||
return data, status_code
|
||||
|
||||
def handle_general_exception(e: Exception):
|
||||
got_request_exception.send(current_app, exception=e)
|
||||
@ -116,7 +103,7 @@ def register_external_error_handlers(api: Api, body_formatter: ErrorBodyFormatte
|
||||
# Note: Exception logging is handled by Flask/Flask-RESTX framework automatically
|
||||
# Explicit log_exception call removed to avoid duplicate log entries
|
||||
|
||||
return _finalize(e, data, status_code), status_code
|
||||
return data, status_code
|
||||
|
||||
api.errorhandler(HTTPException)(handle_http_exception)
|
||||
api.errorhandler(ValueError)(handle_value_error)
|
||||
@ -134,46 +121,14 @@ class ExternalApi(Api):
|
||||
}
|
||||
}
|
||||
|
||||
def __init__(self, app: Blueprint | Flask, *args, error_body_formatter: ErrorBodyFormatter | None = None, **kwargs):
|
||||
self._error_body_formatter = error_body_formatter
|
||||
def __init__(self, app: Blueprint | Flask, *args, **kwargs):
|
||||
patch_swagger_for_inline_nested_dicts()
|
||||
kwargs.setdefault("authorizations", self._authorizations)
|
||||
kwargs.setdefault("security", "Bearer")
|
||||
kwargs["add_specs"] = dify_config.SWAGGER_UI_ENABLED
|
||||
kwargs["doc"] = dify_config.SWAGGER_UI_PATH if dify_config.SWAGGER_UI_ENABLED else False
|
||||
if error_body_formatter is not None:
|
||||
kwargs.setdefault("catch_all_404s", True)
|
||||
# the overrides below patch private flask-restx methods; fail at
|
||||
# startup (not at the first 404) if an upgrade removes them
|
||||
for private_hook in ("_should_use_fr_error_handler", "_help_on_404"):
|
||||
if not callable(getattr(Api, private_hook, None)):
|
||||
raise RuntimeError(f"flask-restx no longer exposes {private_hook}; update ExternalApi overrides")
|
||||
|
||||
# manual separate call on construction and init_app to ensure configs in kwargs effective
|
||||
super().__init__(app=None, *args, **kwargs)
|
||||
self.init_app(app, **kwargs)
|
||||
register_external_error_handlers(self, body_formatter=error_body_formatter)
|
||||
|
||||
@override
|
||||
def _should_use_fr_error_handler(self):
|
||||
# catch_all_404s makes flask-restx claim NotFound for ANY app path
|
||||
# (it wraps the app-level handle_exception), so scope the claim to
|
||||
# this blueprint's url prefix; other surfaces keep their own 404s.
|
||||
if self._error_body_formatter is not None and not self._request_under_own_prefix():
|
||||
return False
|
||||
return super()._should_use_fr_error_handler()
|
||||
|
||||
def _request_under_own_prefix(self) -> bool:
|
||||
prefix = self.blueprint.url_prefix if self.blueprint is not None else None
|
||||
if not prefix:
|
||||
return True
|
||||
return request.path == prefix or request.path.startswith(prefix.rstrip("/") + "/")
|
||||
|
||||
@override
|
||||
def _help_on_404(self, message: str | None = None) -> str | None:
|
||||
# flask-restx appends route suggestions post-handler; with a canonical
|
||||
# formatter installed, that would corrupt the contract and enumerate
|
||||
# routes to unauthenticated callers.
|
||||
if self._error_body_formatter is not None:
|
||||
return message
|
||||
return super()._help_on_404(message)
|
||||
register_external_error_handlers(self)
|
||||
|
||||
@ -11808,7 +11808,6 @@ Get banner list
|
||||
| agent | [AgentComposerAgentResponse](#agentcomposeragentresponse) | | Yes |
|
||||
| agent_soul | [AgentSoulConfig](#agentsoulconfig) | | Yes |
|
||||
| save_options | [ [ComposerSaveStrategy](#composersavestrategy) ] | | Yes |
|
||||
| validation | [ComposerValidationFindingsResponse](#composervalidationfindingsresponse) | | No |
|
||||
| variant | string | | Yes |
|
||||
|
||||
#### AgentAppFeaturesPayload
|
||||
@ -11904,7 +11903,6 @@ Risk marker for CLI tool bootstrap commands.
|
||||
| allowed_node_job_candidates | [AgentComposerNodeJobCandidatesResponse](#agentcomposernodejobcandidatesresponse) | | No |
|
||||
| allowed_soul_candidates | [AgentComposerSoulCandidatesResponse](#agentcomposersoulcandidatesresponse) | | No |
|
||||
| capabilities | [ComposerCandidateCapabilities](#composercandidatecapabilities) | | No |
|
||||
| truncated | boolean | | No |
|
||||
| variant | [ComposerVariant](#composervariant) | | Yes |
|
||||
|
||||
#### AgentComposerDifyToolCandidateResponse
|
||||
@ -11918,22 +11916,6 @@ Risk marker for CLI tool bootstrap commands.
|
||||
| provider | string | | No |
|
||||
| provider_id | string | | No |
|
||||
|
||||
#### AgentComposerFileCandidateResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| file_id | string | | No |
|
||||
| id | string | | No |
|
||||
| kind | string | | No |
|
||||
| name | string | | No |
|
||||
| reference | string | | No |
|
||||
| remote_url | string | | No |
|
||||
| tenant_id | string | | No |
|
||||
| transfer_method | string | | No |
|
||||
| type | string | | No |
|
||||
| upload_file_id | string | | No |
|
||||
| url | string | | No |
|
||||
|
||||
#### AgentComposerImpactBindingResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
@ -11958,17 +11940,6 @@ Risk marker for CLI tool bootstrap commands.
|
||||
| human_contacts | [ [AgentHumanContactConfig](#agenthumancontactconfig) ] | | No |
|
||||
| previous_node_outputs | [ [WorkflowPreviousNodeOutputRef](#workflowpreviousnodeoutputref) ] | | No |
|
||||
|
||||
#### AgentComposerSkillCandidateResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| description | string | | No |
|
||||
| file_id | string | | No |
|
||||
| id | string | | No |
|
||||
| kind | string | | No |
|
||||
| name | string | | No |
|
||||
| path | string | | No |
|
||||
|
||||
#### AgentComposerSoulCandidatesResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
@ -11977,7 +11948,7 @@ Risk marker for CLI tool bootstrap commands.
|
||||
| dify_tools | [ [AgentComposerDifyToolCandidateResponse](#agentcomposerdifytoolcandidateresponse) ] | | No |
|
||||
| human_contacts | [ [AgentHumanContactConfig](#agenthumancontactconfig) ] | | No |
|
||||
| knowledge_datasets | [ [AgentKnowledgeDatasetConfig](#agentknowledgedatasetconfig) ] | | No |
|
||||
| skills_files | [ ] | | No |
|
||||
| skills_files | [ [AgentSkillRefConfig](#agentskillrefconfig) ] | | No |
|
||||
|
||||
#### AgentComposerSoulLockResponse
|
||||
|
||||
@ -11992,9 +11963,7 @@ Risk marker for CLI tool bootstrap commands.
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| errors | [ string ] | | No |
|
||||
| knowledge_retrieval_placeholder | [ [ComposerKnowledgePlaceholderResponse](#composerknowledgeplaceholderresponse) ] | | No |
|
||||
| result | string | | Yes |
|
||||
| warnings | [ [ComposerValidationWarningResponse](#composervalidationwarningresponse) ] | | No |
|
||||
|
||||
#### AgentConfigRevisionOperation
|
||||
|
||||
@ -13317,13 +13286,6 @@ Button styles for user actions.
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| human_roster_available | boolean | | No |
|
||||
|
||||
#### ComposerKnowledgePlaceholderResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| id | string | | Yes |
|
||||
| placeholder_name | string | | Yes |
|
||||
|
||||
#### ComposerSavePayload
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
@ -13352,23 +13314,6 @@ Button styles for user actions.
|
||||
| locked | boolean | | No |
|
||||
| unlocked_from_version_id | string | | No |
|
||||
|
||||
#### ComposerValidationFindingsResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| knowledge_retrieval_placeholder | [ [ComposerKnowledgePlaceholderResponse](#composerknowledgeplaceholderresponse) ] | | No |
|
||||
| warnings | [ [ComposerValidationWarningResponse](#composervalidationwarningresponse) ] | | No |
|
||||
|
||||
#### ComposerValidationWarningResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| code | string | | Yes |
|
||||
| id | string | | No |
|
||||
| kind | string | | No |
|
||||
| message | string | | No |
|
||||
| surface | string | | No |
|
||||
|
||||
#### ComposerVariant
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
@ -17626,7 +17571,6 @@ How a workflow node is bound to an Agent.
|
||||
| node_job | [WorkflowNodeJobConfig](#workflownodejobconfig) | | Yes |
|
||||
| save_options | [ [ComposerSaveStrategy](#composersavestrategy) ] | | Yes |
|
||||
| soul_lock | [AgentComposerSoulLockResponse](#agentcomposersoullockresponse) | | Yes |
|
||||
| validation | [ComposerValidationFindingsResponse](#composervalidationfindingsresponse) | | No |
|
||||
| variant | string | | Yes |
|
||||
| workflow_id | string | | No |
|
||||
|
||||
|
||||
@ -24,7 +24,6 @@ User-scoped operations
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Health check | [HealthResponse](#healthresponse) |
|
||||
| default | Error | [ErrorBody](#errorbody) |
|
||||
|
||||
### /_version
|
||||
|
||||
@ -34,7 +33,6 @@ User-scoped operations
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Server version | [ServerVersionResponse](#serverversionresponse) |
|
||||
| default | Error | [ErrorBody](#errorbody) |
|
||||
|
||||
### /account
|
||||
|
||||
@ -44,7 +42,6 @@ User-scoped operations
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Account info | [AccountResponse](#accountresponse) |
|
||||
| default | Error | [ErrorBody](#errorbody) |
|
||||
|
||||
### /account/sessions
|
||||
|
||||
@ -61,8 +58,6 @@ User-scoped operations
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Session list | [SessionListResponse](#sessionlistresponse) |
|
||||
| 422 | Validation error | [ErrorBody](#errorbody) |
|
||||
| default | Error | [ErrorBody](#errorbody) |
|
||||
|
||||
### /account/sessions/self
|
||||
|
||||
@ -72,7 +67,6 @@ User-scoped operations
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Session revoked | [RevokeResponse](#revokeresponse) |
|
||||
| default | Error | [ErrorBody](#errorbody) |
|
||||
|
||||
### /account/sessions/{session_id}
|
||||
|
||||
@ -88,7 +82,6 @@ User-scoped operations
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Session revoked | [RevokeResponse](#revokeresponse) |
|
||||
| default | Error | [ErrorBody](#errorbody) |
|
||||
|
||||
### /apps
|
||||
|
||||
@ -109,8 +102,6 @@ User-scoped operations
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | App list | [AppListResponse](#applistresponse) |
|
||||
| 422 | Validation error | [ErrorBody](#errorbody) |
|
||||
| default | Error | [ErrorBody](#errorbody) |
|
||||
|
||||
### /apps/{app_id}/check-dependencies
|
||||
|
||||
@ -126,7 +117,6 @@ User-scoped operations
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Dependencies checked | [CheckDependenciesResult](#checkdependenciesresult) |
|
||||
| default | Error | [ErrorBody](#errorbody) |
|
||||
|
||||
### /apps/{app_id}/describe
|
||||
|
||||
@ -143,8 +133,6 @@ User-scoped operations
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | App description | [AppDescribeResponse](#appdescriberesponse) |
|
||||
| 422 | Validation error | [ErrorBody](#errorbody) |
|
||||
| default | Error | [ErrorBody](#errorbody) |
|
||||
|
||||
### /apps/{app_id}/export
|
||||
|
||||
@ -162,8 +150,6 @@ User-scoped operations
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Export successful | [AppDslExportResponse](#appdslexportresponse) |
|
||||
| 422 | Validation error | [ErrorBody](#errorbody) |
|
||||
| default | Error | [ErrorBody](#errorbody) |
|
||||
|
||||
### /apps/{app_id}/files/upload
|
||||
|
||||
@ -187,7 +173,6 @@ Upload a file to use as an input variable when running the app
|
||||
| 401 | Unauthorized — invalid or expired bearer token | |
|
||||
| 413 | File too large | |
|
||||
| 415 | Unsupported file type or blocked extension | |
|
||||
| default | Error | [ErrorBody](#errorbody) |
|
||||
|
||||
### /apps/{app_id}/form/human_input/{form_token}
|
||||
|
||||
@ -219,8 +204,6 @@ Upload a file to use as an input variable when running the app
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Form submitted | [FormSubmitResponse](#formsubmitresponse) |
|
||||
| 422 | Validation error | [ErrorBody](#errorbody) |
|
||||
| default | Error | [ErrorBody](#errorbody) |
|
||||
|
||||
### /apps/{app_id}/run
|
||||
|
||||
@ -234,10 +217,9 @@ Upload a file to use as an input variable when running the app
|
||||
|
||||
##### Responses
|
||||
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Run result (SSE stream) | |
|
||||
| 422 | Validation error | [ErrorBody](#errorbody) |
|
||||
| Code | Description |
|
||||
| ---- | ----------- |
|
||||
| 200 | Run result (SSE stream) |
|
||||
|
||||
### /apps/{app_id}/tasks/{task_id}/events
|
||||
|
||||
@ -270,7 +252,6 @@ Upload a file to use as an input variable when running the app
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Task stopped | [TaskStopResponse](#taskstopresponse) |
|
||||
| default | Error | [ErrorBody](#errorbody) |
|
||||
|
||||
### /oauth/device/approve
|
||||
|
||||
@ -364,8 +345,6 @@ Upload a file to use as an input variable when running the app
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Permitted external apps list | [PermittedExternalAppsListResponse](#permittedexternalappslistresponse) |
|
||||
| 422 | Validation error | [ErrorBody](#errorbody) |
|
||||
| default | Error | [ErrorBody](#errorbody) |
|
||||
|
||||
### /workspaces
|
||||
|
||||
@ -375,7 +354,6 @@ Upload a file to use as an input variable when running the app
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Workspace list | [WorkspaceListResponse](#workspacelistresponse) |
|
||||
| default | Error | [ErrorBody](#errorbody) |
|
||||
|
||||
### /workspaces/{workspace_id}
|
||||
|
||||
@ -391,7 +369,6 @@ Upload a file to use as an input variable when running the app
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Workspace detail | [WorkspaceDetailResponse](#workspacedetailresponse) |
|
||||
| default | Error | [ErrorBody](#errorbody) |
|
||||
|
||||
### /workspaces/{workspace_id}/apps/imports
|
||||
|
||||
@ -410,8 +387,6 @@ Upload a file to use as an input variable when running the app
|
||||
| 200 | Import completed | [Import](#import) |
|
||||
| 202 | Import pending confirmation | [Import](#import) |
|
||||
| 400 | Import failed | [Import](#import) |
|
||||
| 422 | Validation error | [ErrorBody](#errorbody) |
|
||||
| default | Error | [ErrorBody](#errorbody) |
|
||||
|
||||
### /workspaces/{workspace_id}/apps/imports/{import_id}/confirm
|
||||
|
||||
@ -429,7 +404,6 @@ Upload a file to use as an input variable when running the app
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Import confirmed | [Import](#import) |
|
||||
| 400 | Import failed | [Import](#import) |
|
||||
| default | Error | [ErrorBody](#errorbody) |
|
||||
|
||||
### /workspaces/{workspace_id}/members
|
||||
|
||||
@ -447,8 +421,6 @@ Upload a file to use as an input variable when running the app
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Member list | [MemberListResponse](#memberlistresponse) |
|
||||
| 422 | Validation error | [ErrorBody](#errorbody) |
|
||||
| default | Error | [ErrorBody](#errorbody) |
|
||||
|
||||
#### POST
|
||||
##### Parameters
|
||||
@ -463,8 +435,6 @@ Upload a file to use as an input variable when running the app
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 201 | Member invited | [MemberInviteResponse](#memberinviteresponse) |
|
||||
| 422 | Validation error | [ErrorBody](#errorbody) |
|
||||
| default | Error | [ErrorBody](#errorbody) |
|
||||
|
||||
### /workspaces/{workspace_id}/members/{member_id}
|
||||
|
||||
@ -481,7 +451,6 @@ Upload a file to use as an input variable when running the app
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Member removed | [MemberActionResponse](#memberactionresponse) |
|
||||
| default | Error | [ErrorBody](#errorbody) |
|
||||
|
||||
### /workspaces/{workspace_id}/members/{member_id}/role
|
||||
|
||||
@ -499,8 +468,6 @@ Upload a file to use as an input variable when running the app
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Role updated | [MemberActionResponse](#memberactionresponse) |
|
||||
| 422 | Validation error | [ErrorBody](#errorbody) |
|
||||
| default | Error | [ErrorBody](#errorbody) |
|
||||
|
||||
### /workspaces/{workspace_id}/switch
|
||||
|
||||
@ -516,7 +483,6 @@ Upload a file to use as an input variable when running the app
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Workspace detail | [WorkspaceDetailResponse](#workspacedetailresponse) |
|
||||
| default | Error | [ErrorBody](#errorbody) |
|
||||
|
||||
---
|
||||
### Models
|
||||
@ -727,28 +693,6 @@ mode is a closed enum.
|
||||
| client_id | string | | Yes |
|
||||
| device_code | string | | Yes |
|
||||
|
||||
#### ErrorBody
|
||||
|
||||
Canonical non-2xx body. ``code`` is typed ``str`` (not the enum) so the
|
||||
generated client schema stays an open enum — old CLIs keep parsing when a
|
||||
future server adds a code. Formatter tests pin emitted values to the enum.
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| code | string | | Yes |
|
||||
| details | [ [ErrorDetail](#errordetail) ] | | No |
|
||||
| hint | string | | No |
|
||||
| message | string | | Yes |
|
||||
| status | integer | | Yes |
|
||||
|
||||
#### ErrorDetail
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| loc | [ ] | | No |
|
||||
| msg | string | | Yes |
|
||||
| type | string | | Yes |
|
||||
|
||||
#### FileResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
|
||||
@ -1,210 +0,0 @@
|
||||
"""Slash-menu candidates assembly (ENG-615).
|
||||
|
||||
Pure assembly over injected loaders so the upstream-graph computation and the
|
||||
per-source mapping are unit-testable without a database. IO wiring (draft
|
||||
workflow / bindings / draft variables / datasets / workspace tools) lives in
|
||||
``AgentComposerService.get_*_candidates``.
|
||||
|
||||
``previous_node_outputs`` entries are emitted in the stored
|
||||
``WorkflowPreviousNodeOutputRef`` shape (``selector``/``node_id``/``output``/
|
||||
``name``) so the frontend can write a selected candidate back into
|
||||
``node_job.previous_node_output_refs`` verbatim; display extras
|
||||
(``node_title``/``node_kind``/``value_type``/``inferred``) ride along via the
|
||||
flexible config schema. Output enumeration follows the Node Output Inspector:
|
||||
start variables + recorded ``sys.*`` variables are static, Agent v2 nodes use
|
||||
their binding's declared outputs, and every other node kind is inferred from
|
||||
the latest draft-run variables (``inferred: true``).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable, Mapping
|
||||
from typing import Any
|
||||
|
||||
from models.agent_config_entities import (
|
||||
AgentSoulConfig,
|
||||
DeclaredOutputConfig,
|
||||
)
|
||||
|
||||
MAX_CANDIDATES_PER_LIST = 200
|
||||
|
||||
_SYSTEM_NODE_ID = "sys"
|
||||
|
||||
# loader signatures injected by the service layer
|
||||
DeclaredOutputsLoader = Callable[[str], list[DeclaredOutputConfig] | None]
|
||||
DraftVariablesLoader = Callable[[str], list[tuple[str, str | None]]]
|
||||
SystemVariablesLoader = Callable[[], list[tuple[str, str | None]]]
|
||||
DatasetLookup = Callable[[list[str]], Mapping[str, Any]]
|
||||
WorkspaceToolsLoader = Callable[[], list[dict[str, Any]]]
|
||||
|
||||
|
||||
def previous_node_output_candidates(
|
||||
*,
|
||||
graph: Mapping[str, Any],
|
||||
node_id: str,
|
||||
declared_outputs_loader: DeclaredOutputsLoader,
|
||||
draft_variables_loader: DraftVariablesLoader,
|
||||
system_variables_loader: SystemVariablesLoader,
|
||||
) -> tuple[list[dict[str, Any]], bool]:
|
||||
"""Enumerate upstream node outputs for ``node_id`` as writable ref candidates."""
|
||||
from core.workflow.graph_topology import WorkflowGraphTopology
|
||||
|
||||
topology = WorkflowGraphTopology.from_graph(graph)
|
||||
upstream = topology.upstream_node_ids(node_id)
|
||||
|
||||
entries: list[dict[str, Any]] = []
|
||||
for name, value_type in system_variables_loader():
|
||||
entries.append(
|
||||
_ref_entry(
|
||||
node_id=_SYSTEM_NODE_ID,
|
||||
output=name,
|
||||
node_title="System",
|
||||
node_kind="system",
|
||||
value_type=value_type,
|
||||
inferred=True,
|
||||
)
|
||||
)
|
||||
|
||||
nodes = graph.get("nodes")
|
||||
for node in nodes if isinstance(nodes, list) else []:
|
||||
if not isinstance(node, Mapping):
|
||||
continue
|
||||
nid = node.get("id")
|
||||
if not isinstance(nid, str) or nid not in upstream:
|
||||
continue
|
||||
raw_data = node.get("data")
|
||||
data: Mapping[str, Any] = raw_data if isinstance(raw_data, Mapping) else {}
|
||||
kind = str(data.get("type") or "unknown")
|
||||
title = str(data.get("title") or nid)
|
||||
|
||||
if kind == "start":
|
||||
for variable in data.get("variables") or []:
|
||||
if not isinstance(variable, Mapping):
|
||||
continue
|
||||
var_name = variable.get("variable")
|
||||
if isinstance(var_name, str) and var_name:
|
||||
entries.append(
|
||||
_ref_entry(
|
||||
node_id=nid,
|
||||
output=var_name,
|
||||
node_title=title,
|
||||
node_kind=kind,
|
||||
value_type=variable.get("type") if isinstance(variable.get("type"), str) else None,
|
||||
inferred=False,
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
declared: list[DeclaredOutputConfig] | None = None
|
||||
if kind == "agent" and str(data.get("version", "")) == "2":
|
||||
declared = declared_outputs_loader(nid)
|
||||
if declared is not None:
|
||||
for output in declared:
|
||||
entries.append(
|
||||
_ref_entry(
|
||||
node_id=nid,
|
||||
output=output.name,
|
||||
node_title=title,
|
||||
node_kind=kind,
|
||||
value_type=output.type.value,
|
||||
inferred=False,
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
for var_name, value_type in draft_variables_loader(nid):
|
||||
entries.append(
|
||||
_ref_entry(
|
||||
node_id=nid,
|
||||
output=var_name,
|
||||
node_title=title,
|
||||
node_kind=kind,
|
||||
value_type=value_type,
|
||||
inferred=True,
|
||||
)
|
||||
)
|
||||
|
||||
return _capped(entries)
|
||||
|
||||
|
||||
def soul_candidates(
|
||||
*,
|
||||
agent_soul: AgentSoulConfig | None,
|
||||
dataset_lookup: DatasetLookup,
|
||||
workspace_tools_loader: WorkspaceToolsLoader,
|
||||
) -> tuple[dict[str, list[dict[str, Any]]], bool]:
|
||||
"""Assemble the soul-surface candidate lists (design §3.2)."""
|
||||
soul = agent_soul or AgentSoulConfig()
|
||||
truncated = False
|
||||
|
||||
skills_files = [{"kind": "skill", **skill.model_dump(exclude_none=True)} for skill in soul.skills_files.skills]
|
||||
skills_files += [{"kind": "file", **file.model_dump(exclude_none=True)} for file in soul.skills_files.files]
|
||||
|
||||
cli_tools = [tool.model_dump(exclude_none=True) for tool in soul.tools.cli_tools if tool.enabled]
|
||||
|
||||
dataset_ids = [dataset.id for dataset in soul.knowledge.datasets if dataset.id]
|
||||
dataset_rows = dataset_lookup(dataset_ids) if dataset_ids else {}
|
||||
knowledge_datasets: list[dict[str, Any]] = []
|
||||
for dataset in soul.knowledge.datasets:
|
||||
if not dataset.id:
|
||||
continue
|
||||
row = dataset_rows.get(dataset.id)
|
||||
knowledge_datasets.append(
|
||||
{
|
||||
"id": dataset.id,
|
||||
"name": (getattr(row, "name", None) or dataset.name or dataset.id),
|
||||
"description": getattr(row, "description", None) or dataset.description,
|
||||
"missing": row is None,
|
||||
}
|
||||
)
|
||||
|
||||
human_contacts = [contact.model_dump(exclude_none=True) for contact in soul.human.contacts]
|
||||
dify_tools = workspace_tools_loader()
|
||||
|
||||
lists = {
|
||||
"skills_files": skills_files,
|
||||
"dify_tools": dify_tools,
|
||||
"cli_tools": cli_tools,
|
||||
"knowledge_datasets": knowledge_datasets,
|
||||
"human_contacts": human_contacts,
|
||||
}
|
||||
capped: dict[str, list[dict[str, Any]]] = {}
|
||||
for key, values in lists.items():
|
||||
clipped, was_clipped = _capped(values)
|
||||
truncated = truncated or was_clipped
|
||||
capped[key] = clipped
|
||||
return capped, truncated
|
||||
|
||||
|
||||
def _ref_entry(
|
||||
*,
|
||||
node_id: str,
|
||||
output: str,
|
||||
node_title: str,
|
||||
node_kind: str,
|
||||
value_type: str | None,
|
||||
inferred: bool,
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"selector": [node_id, output],
|
||||
"node_id": node_id,
|
||||
"output": output,
|
||||
"name": f"{node_title}/{output}",
|
||||
"node_title": node_title,
|
||||
"node_kind": node_kind,
|
||||
"value_type": value_type,
|
||||
"inferred": inferred,
|
||||
}
|
||||
|
||||
|
||||
def _capped(values: list[dict[str, Any]]) -> tuple[list[dict[str, Any]], bool]:
|
||||
if len(values) > MAX_CANDIDATES_PER_LIST:
|
||||
return values[:MAX_CANDIDATES_PER_LIST], True
|
||||
return values, False
|
||||
|
||||
|
||||
__all__ = [
|
||||
"MAX_CANDIDATES_PER_LIST",
|
||||
"previous_node_output_candidates",
|
||||
"soul_candidates",
|
||||
]
|
||||
@ -1,4 +1,3 @@
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import func, select
|
||||
@ -40,8 +39,6 @@ from services.entities.agent_entities import (
|
||||
# Mirrors Workflow.version when it is "draft" (see models/workflow.py).
|
||||
_DRAFT_WORKFLOW_VERSION = "draft"
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AgentComposerService:
|
||||
@classmethod
|
||||
@ -111,9 +108,7 @@ class AgentComposerService:
|
||||
agent_id=agent.id if agent else None,
|
||||
version_id=binding.current_snapshot_id,
|
||||
)
|
||||
state = cls._serialize_workflow_state(binding=binding, agent=agent, version=version)
|
||||
state["validation"] = cls.collect_validation_findings(tenant_id=tenant_id, payload=payload)
|
||||
return state
|
||||
return cls._serialize_workflow_state(binding=binding, agent=agent, version=version)
|
||||
|
||||
@classmethod
|
||||
def load_agent_app_composer(cls, *, tenant_id: str, app_id: str) -> dict[str, Any]:
|
||||
@ -210,241 +205,42 @@ class AgentComposerService:
|
||||
agent.updated_by = account_id
|
||||
|
||||
db.session.commit()
|
||||
state = cls.load_agent_app_composer(tenant_id=tenant_id, app_id=app_id)
|
||||
state["validation"] = cls.collect_validation_findings(tenant_id=tenant_id, payload=payload)
|
||||
return state
|
||||
return cls.load_agent_app_composer(tenant_id=tenant_id, app_id=app_id)
|
||||
|
||||
@classmethod
|
||||
def collect_validation_findings(cls, *, tenant_id: str, payload: ComposerSavePayload) -> dict[str, Any]:
|
||||
"""ENG-617 soft findings, with DB-backed dataset existence for placeholders."""
|
||||
from services.agent.prompt_mentions import MentionKind, parse_prompt_mentions
|
||||
|
||||
mentioned_ids: set[str] = set()
|
||||
if payload.agent_soul is not None:
|
||||
mentioned_ids |= {
|
||||
mention.ref_id
|
||||
for mention in parse_prompt_mentions(payload.agent_soul.prompt.system_prompt)
|
||||
if mention.kind == MentionKind.KNOWLEDGE
|
||||
}
|
||||
existing_dataset_ids: set[str] | None = None
|
||||
if mentioned_ids:
|
||||
existing_dataset_ids = set(cls._dataset_rows(tenant_id=tenant_id, dataset_ids=sorted(mentioned_ids)))
|
||||
return ComposerConfigValidator.collect_soft_findings(payload, existing_dataset_ids=existing_dataset_ids)
|
||||
|
||||
@classmethod
|
||||
def get_workflow_candidates(cls, *, tenant_id: str, app_id: str, node_id: str, user_id: str) -> dict[str, Any]:
|
||||
"""Slash-menu data source for the workflow Agent node composer (ENG-615)."""
|
||||
from services.agent.composer_candidates import previous_node_output_candidates, soul_candidates
|
||||
|
||||
try:
|
||||
workflow = cls._get_draft_workflow(tenant_id=tenant_id, app_id=app_id)
|
||||
except ValueError:
|
||||
workflow = None
|
||||
|
||||
node_job: WorkflowNodeJobConfig | None = None
|
||||
agent_soul: AgentSoulConfig | None = None
|
||||
if workflow is not None:
|
||||
binding = cls._get_workflow_binding(tenant_id=tenant_id, workflow_id=workflow.id, node_id=node_id)
|
||||
if binding is not None:
|
||||
node_job = cls._parse_node_job(binding)
|
||||
agent_soul = cls._load_binding_soul(tenant_id=tenant_id, binding=binding)
|
||||
|
||||
truncated = False
|
||||
previous_outputs: list[dict[str, Any]] = []
|
||||
if workflow is not None:
|
||||
draft_variable_session = cls._draft_variable_session()
|
||||
try:
|
||||
previous_outputs, outputs_truncated = previous_node_output_candidates(
|
||||
graph=workflow.graph_dict,
|
||||
node_id=node_id,
|
||||
declared_outputs_loader=lambda nid: cls._binding_declared_outputs(
|
||||
tenant_id=tenant_id, workflow_id=workflow.id, node_id=nid
|
||||
),
|
||||
draft_variables_loader=lambda nid: cls._draft_node_variables(
|
||||
session=draft_variable_session, app_id=app_id, node_id=nid, user_id=user_id
|
||||
),
|
||||
system_variables_loader=lambda: cls._draft_system_variables(
|
||||
session=draft_variable_session, app_id=app_id, user_id=user_id
|
||||
),
|
||||
)
|
||||
finally:
|
||||
draft_variable_session.close()
|
||||
truncated = truncated or outputs_truncated
|
||||
|
||||
soul_lists, soul_truncated = soul_candidates(
|
||||
agent_soul=agent_soul,
|
||||
dataset_lookup=lambda ids: cls._dataset_rows(tenant_id=tenant_id, dataset_ids=ids),
|
||||
workspace_tools_loader=lambda: cls._workspace_dify_tools(tenant_id=tenant_id, user_id=user_id),
|
||||
)
|
||||
truncated = truncated or soul_truncated
|
||||
|
||||
def get_workflow_candidates(cls, *, app_id: str) -> dict[str, Any]:
|
||||
response = ComposerCandidatesResponse(
|
||||
variant=ComposerVariant.WORKFLOW,
|
||||
allowed_node_job_candidates={
|
||||
"previous_node_outputs": previous_outputs,
|
||||
"previous_node_outputs": [],
|
||||
"declare_output_types": ["string", "number", "object", "array", "boolean", "file"],
|
||||
"human_contacts": [
|
||||
contact.model_dump(exclude_none=True) for contact in (node_job.human_contacts if node_job else [])
|
||||
],
|
||||
"human_contacts": [],
|
||||
},
|
||||
allowed_soul_candidates={
|
||||
"skills_files": [],
|
||||
"dify_tools": [],
|
||||
"cli_tools": [],
|
||||
"knowledge_datasets": [],
|
||||
"human_contacts": [],
|
||||
},
|
||||
allowed_soul_candidates=soul_lists,
|
||||
truncated=truncated,
|
||||
)
|
||||
return response.model_dump(mode="json")
|
||||
|
||||
@classmethod
|
||||
def get_agent_app_candidates(cls, *, tenant_id: str, app_id: str, user_id: str) -> dict[str, Any]:
|
||||
"""Slash-menu data source for the Agent App (Console) composer (ENG-615)."""
|
||||
from services.agent.composer_candidates import soul_candidates
|
||||
|
||||
agent_soul = cls._load_agent_app_soul(tenant_id=tenant_id, app_id=app_id)
|
||||
soul_lists, truncated = soul_candidates(
|
||||
agent_soul=agent_soul,
|
||||
dataset_lookup=lambda ids: cls._dataset_rows(tenant_id=tenant_id, dataset_ids=ids),
|
||||
workspace_tools_loader=lambda: cls._workspace_dify_tools(tenant_id=tenant_id, user_id=user_id),
|
||||
)
|
||||
def get_agent_app_candidates(cls, *, app_id: str) -> dict[str, Any]:
|
||||
response = ComposerCandidatesResponse(
|
||||
variant=ComposerVariant.AGENT_APP,
|
||||
allowed_node_job_candidates={},
|
||||
allowed_soul_candidates=soul_lists,
|
||||
truncated=truncated,
|
||||
allowed_soul_candidates={
|
||||
"skills_files": [],
|
||||
"dify_tools": [],
|
||||
"cli_tools": [],
|
||||
"knowledge_datasets": [],
|
||||
"human_contacts": [],
|
||||
},
|
||||
)
|
||||
return response.model_dump(mode="json")
|
||||
|
||||
# ── candidates IO helpers (ENG-615) ──────────────────────────────────────
|
||||
|
||||
@staticmethod
|
||||
def _parse_node_job(binding: WorkflowAgentNodeBinding) -> WorkflowNodeJobConfig | None:
|
||||
try:
|
||||
return WorkflowNodeJobConfig.model_validate(binding.node_job_config_dict)
|
||||
except Exception:
|
||||
logger.warning("candidates: malformed node_job_config for binding %s", binding.id, exc_info=True)
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def _load_binding_soul(cls, *, tenant_id: str, binding: WorkflowAgentNodeBinding) -> AgentSoulConfig | None:
|
||||
agent = cls._get_agent_if_present(tenant_id=tenant_id, agent_id=binding.agent_id)
|
||||
version = cls._get_version_if_present(
|
||||
tenant_id=tenant_id,
|
||||
agent_id=agent.id if agent else None,
|
||||
version_id=binding.current_snapshot_id,
|
||||
)
|
||||
return cls._parse_soul_snapshot(version)
|
||||
|
||||
@classmethod
|
||||
def _load_agent_app_soul(cls, *, tenant_id: str, app_id: str) -> AgentSoulConfig | None:
|
||||
agent = db.session.scalar(
|
||||
select(Agent)
|
||||
.where(
|
||||
Agent.tenant_id == tenant_id,
|
||||
Agent.app_id == app_id,
|
||||
Agent.scope == AgentScope.ROSTER,
|
||||
Agent.status == AgentStatus.ACTIVE,
|
||||
)
|
||||
.order_by(Agent.created_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
if agent is None:
|
||||
return None
|
||||
version = cls._get_version_if_present(
|
||||
tenant_id=tenant_id, agent_id=agent.id, version_id=agent.active_config_snapshot_id
|
||||
)
|
||||
return cls._parse_soul_snapshot(version)
|
||||
|
||||
@staticmethod
|
||||
def _parse_soul_snapshot(version: AgentConfigSnapshot | None) -> AgentSoulConfig | None:
|
||||
if version is None:
|
||||
return None
|
||||
try:
|
||||
return AgentSoulConfig.model_validate(version.config_snapshot_dict)
|
||||
except Exception:
|
||||
logger.warning("candidates: malformed soul snapshot %s", version.id, exc_info=True)
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def _binding_declared_outputs(
|
||||
cls, *, tenant_id: str, workflow_id: str, node_id: str
|
||||
) -> list[DeclaredOutputConfig] | None:
|
||||
binding = cls._get_workflow_binding(tenant_id=tenant_id, workflow_id=workflow_id, node_id=node_id)
|
||||
if binding is None:
|
||||
return None
|
||||
node_job = cls._parse_node_job(binding)
|
||||
if node_job is None:
|
||||
return None
|
||||
return list(_effective_declared_outputs(node_job.declared_outputs))
|
||||
|
||||
@staticmethod
|
||||
def _draft_variable_session():
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
return sessionmaker(bind=db.engine, expire_on_commit=False)()
|
||||
|
||||
@staticmethod
|
||||
def _draft_node_variables(*, session: Any, app_id: str, node_id: str, user_id: str) -> list[tuple[str, str | None]]:
|
||||
from services.workflow_draft_variable_service import WorkflowDraftVariableService
|
||||
|
||||
variables = WorkflowDraftVariableService(session=session).list_node_variables(app_id, node_id, user_id)
|
||||
return [(variable.name, variable.value_type.value) for variable in variables.variables]
|
||||
|
||||
@staticmethod
|
||||
def _draft_system_variables(*, session: Any, app_id: str, user_id: str) -> list[tuple[str, str | None]]:
|
||||
from services.workflow_draft_variable_service import WorkflowDraftVariableService
|
||||
|
||||
variables = WorkflowDraftVariableService(session=session).list_system_variables(app_id, user_id)
|
||||
return [(variable.name, variable.value_type.value) for variable in variables.variables]
|
||||
|
||||
@staticmethod
|
||||
def _dataset_rows(*, tenant_id: str, dataset_ids: list[str]) -> dict[str, Any]:
|
||||
"""Tenant-scoped dataset lookup tolerating malformed ids.
|
||||
|
||||
Mention ids come from user-editable prompt text; a non-UUID id can never
|
||||
match a dataset row, so it is simply absent from the result (-> missing/
|
||||
placeholder semantics) instead of breaking the UUID-typed query.
|
||||
"""
|
||||
from uuid import UUID
|
||||
|
||||
from services.dataset_service import DatasetService
|
||||
|
||||
valid_ids: list[str] = []
|
||||
for dataset_id in dataset_ids:
|
||||
try:
|
||||
UUID(dataset_id)
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
valid_ids.append(dataset_id)
|
||||
if not valid_ids:
|
||||
return {}
|
||||
rows, _ = DatasetService.get_datasets_by_ids(valid_ids, tenant_id)
|
||||
return {str(row.id): row for row in rows}
|
||||
|
||||
@staticmethod
|
||||
def _workspace_dify_tools(*, tenant_id: str, user_id: str) -> list[dict[str, Any]]:
|
||||
"""Workspace Dify Plugin tools, same source as the tool selector.
|
||||
|
||||
A plugin-daemon outage must degrade the slash menu to an empty tools
|
||||
tab, not break the whole candidates endpoint.
|
||||
"""
|
||||
from services.tools.builtin_tools_manage_service import BuiltinToolManageService
|
||||
|
||||
try:
|
||||
providers = BuiltinToolManageService.list_builtin_tools(user_id, tenant_id)
|
||||
except Exception:
|
||||
logger.warning("candidates: failed to list workspace tools for tenant %s", tenant_id, exc_info=True)
|
||||
return []
|
||||
tools: list[dict[str, Any]] = []
|
||||
for provider in providers:
|
||||
for tool in provider.tools or []:
|
||||
tools.append(
|
||||
{
|
||||
"id": f"{provider.name}/{tool.name}",
|
||||
"name": tool.name,
|
||||
"description": tool.label.en_US if tool.label else tool.name,
|
||||
"provider": provider.name,
|
||||
"plugin_id": provider.plugin_id or None,
|
||||
}
|
||||
)
|
||||
return tools
|
||||
|
||||
@classmethod
|
||||
def calculate_impact(cls, *, tenant_id: str, current_snapshot_id: str) -> dict[str, Any]:
|
||||
bindings = list(
|
||||
|
||||
@ -4,17 +4,6 @@ from typing import Any
|
||||
from pydantic import ValidationError
|
||||
|
||||
from services.agent.errors import AgentSoulLockedError, InvalidComposerConfigError, PlaintextSecretNotAllowedError
|
||||
from services.agent.prompt_mentions import (
|
||||
MAX_MENTIONS_PER_PROMPT,
|
||||
NODE_JOB_PROMPT_ALLOWED_KINDS,
|
||||
SOUL_PROMPT_ALLOWED_KINDS,
|
||||
MentionKind,
|
||||
MentionResolver,
|
||||
build_node_job_mention_resolver,
|
||||
build_soul_mention_resolver,
|
||||
find_malformed_mention_markers,
|
||||
parse_prompt_mentions,
|
||||
)
|
||||
from services.entities.agent_entities import (
|
||||
AgentSoulConfig,
|
||||
ComposerSavePayload,
|
||||
@ -57,158 +46,6 @@ class ComposerConfigValidator:
|
||||
cls.validate_agent_soul(payload.agent_soul)
|
||||
if payload.node_job is not None:
|
||||
cls.validate_node_job(payload.node_job)
|
||||
cls._validate_prompt_mentions(payload)
|
||||
|
||||
@classmethod
|
||||
def _validate_prompt_mentions(cls, payload: ComposerSavePayload) -> None:
|
||||
"""ENG-616 §2.4 allowlists + ENG-617 §5.2 human-must-be-referenced.
|
||||
|
||||
Error messages start with a stable code token (``mention_kind_not_allowed``
|
||||
/ ``mention_limit_exceeded`` / ``human_involvement_not_referenced``) so
|
||||
the frontend can switch on it.
|
||||
"""
|
||||
if payload.agent_soul is not None:
|
||||
cls._validate_surface_mentions(
|
||||
prompt=payload.agent_soul.prompt.system_prompt,
|
||||
allowed=SOUL_PROMPT_ALLOWED_KINDS,
|
||||
surface="agent soul prompt",
|
||||
)
|
||||
cls._require_human_mentions(
|
||||
prompt=payload.agent_soul.prompt.system_prompt,
|
||||
contacts=payload.agent_soul.human.contacts,
|
||||
surface="agent soul prompt",
|
||||
)
|
||||
if payload.node_job is not None:
|
||||
cls._validate_surface_mentions(
|
||||
prompt=payload.node_job.workflow_prompt,
|
||||
allowed=NODE_JOB_PROMPT_ALLOWED_KINDS,
|
||||
surface="workflow job prompt",
|
||||
)
|
||||
cls._require_human_mentions(
|
||||
prompt=payload.node_job.workflow_prompt,
|
||||
contacts=payload.node_job.human_contacts,
|
||||
surface="workflow job prompt",
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _validate_surface_mentions(cls, *, prompt: str, allowed: frozenset[MentionKind], surface: str) -> None:
|
||||
mentions = parse_prompt_mentions(prompt)
|
||||
if len(mentions) > MAX_MENTIONS_PER_PROMPT:
|
||||
raise InvalidComposerConfigError(
|
||||
f"mention_limit_exceeded: {surface} has {len(mentions)} mentions, "
|
||||
f"exceeding the limit of {MAX_MENTIONS_PER_PROMPT}."
|
||||
)
|
||||
for mention in mentions:
|
||||
if mention.kind not in allowed:
|
||||
raise InvalidComposerConfigError(
|
||||
f"mention_kind_not_allowed: {surface} cannot reference {mention.kind.value} (id={mention.ref_id})."
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _require_human_mentions(cls, *, prompt: str, contacts: list[Any], surface: str) -> None:
|
||||
"""ENG-617 §5.2 (PRD: human involvement must be slash-referenced or save errors).
|
||||
|
||||
Every configured human contact must appear as ``{{#human:<id>#}}`` in the
|
||||
corresponding prompt. A contact matches via any identity alias; contacts
|
||||
carrying no identity at all cannot be referenced and are skipped.
|
||||
"""
|
||||
if not contacts:
|
||||
return
|
||||
mentioned = {mention.ref_id for mention in parse_prompt_mentions(prompt) if mention.kind == MentionKind.HUMAN}
|
||||
for contact in contacts:
|
||||
aliases = {
|
||||
alias
|
||||
for alias in (contact.id, contact.contact_id, contact.human_id, contact.email, contact.name)
|
||||
if alias
|
||||
}
|
||||
if not aliases:
|
||||
continue
|
||||
if aliases.isdisjoint(mentioned):
|
||||
display = contact.name or contact.email or contact.id or "human involvement"
|
||||
raise InvalidComposerConfigError(
|
||||
f"human_involvement_not_referenced: configured human involvement '{display}' "
|
||||
f"must be referenced in the {surface} via the slash menu."
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def collect_soft_findings(
|
||||
cls,
|
||||
payload: ComposerSavePayload,
|
||||
*,
|
||||
existing_dataset_ids: set[str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""ENG-617 §5.3/§5.4 soft findings — never block save.
|
||||
|
||||
``warnings`` carries ``mention_target_missing`` / ``mention_malformed``
|
||||
entries; ``knowledge_retrieval_placeholder`` keeps dangling knowledge
|
||||
mentions with a placeholder name (0522 consensus) instead of dropping or
|
||||
rejecting them. With ``existing_dataset_ids`` provided, configured-but-
|
||||
deleted datasets surface as placeholders too.
|
||||
"""
|
||||
warnings: list[dict[str, Any]] = []
|
||||
placeholders: list[dict[str, str]] = []
|
||||
|
||||
surfaces: list[tuple[str, str, MentionResolver, frozenset[MentionKind]]] = []
|
||||
if payload.agent_soul is not None:
|
||||
surfaces.append(
|
||||
(
|
||||
"agent_soul",
|
||||
payload.agent_soul.prompt.system_prompt,
|
||||
build_soul_mention_resolver(payload.agent_soul),
|
||||
SOUL_PROMPT_ALLOWED_KINDS,
|
||||
)
|
||||
)
|
||||
if payload.node_job is not None:
|
||||
surfaces.append(
|
||||
(
|
||||
"node_job",
|
||||
payload.node_job.workflow_prompt,
|
||||
build_node_job_mention_resolver(payload.node_job),
|
||||
NODE_JOB_PROMPT_ALLOWED_KINDS,
|
||||
)
|
||||
)
|
||||
|
||||
for surface, prompt, resolver, allowed in surfaces:
|
||||
for mention in parse_prompt_mentions(prompt):
|
||||
if mention.kind not in allowed:
|
||||
continue # hard-rejected by validate_save_payload
|
||||
resolved = resolver(mention)
|
||||
if mention.kind == MentionKind.KNOWLEDGE:
|
||||
dangling = resolved is None or (
|
||||
existing_dataset_ids is not None and mention.ref_id not in existing_dataset_ids
|
||||
)
|
||||
if dangling:
|
||||
placeholders.append(
|
||||
{
|
||||
"id": mention.ref_id,
|
||||
"placeholder_name": mention.label or f"Knowledge {mention.ref_id[:8]}",
|
||||
}
|
||||
)
|
||||
continue
|
||||
if resolved is None:
|
||||
warnings.append(
|
||||
{
|
||||
"code": "mention_target_missing",
|
||||
"surface": surface,
|
||||
"kind": mention.kind.value,
|
||||
"id": mention.ref_id,
|
||||
"message": f"{mention.kind.value} mention (id={mention.ref_id}) does not match "
|
||||
"any configured item.",
|
||||
}
|
||||
)
|
||||
for marker in find_malformed_mention_markers(prompt):
|
||||
warnings.append(
|
||||
{
|
||||
"code": "mention_malformed",
|
||||
"surface": surface,
|
||||
"kind": None,
|
||||
"id": None,
|
||||
"message": f"mention-shaped marker {marker!r} is malformed and will be "
|
||||
"degraded to plain text at runtime.",
|
||||
}
|
||||
)
|
||||
|
||||
return {"warnings": warnings, "knowledge_retrieval_placeholder": placeholders}
|
||||
|
||||
@classmethod
|
||||
def validate_agent_soul(cls, agent_soul: AgentSoulConfig) -> None:
|
||||
|
||||
@ -1,264 +0,0 @@
|
||||
"""Prompt mention (slash-reference) serialization contract — ENG-616.
|
||||
|
||||
Slash-menu insertions are stored inline in the plain-string prompt as tokens:
|
||||
|
||||
[§<kind>:<id>[:<label>]§]
|
||||
|
||||
``kind`` is a fixed lowercase word; ``id`` points at an item in the Agent config
|
||||
lists (mentions are pointers — the entity itself lives in ``skills_files`` /
|
||||
``tools`` / ``knowledge.datasets`` / ``human.contacts`` /
|
||||
``previous_node_output_refs`` / ``declared_outputs``); ``label`` is an optional
|
||||
plain-text fallback only (the backend always re-resolves by id, so renames never
|
||||
break references). A single ``:`` separates all three fields; ``label`` is the
|
||||
trailing remainder and may itself contain ``:``.
|
||||
|
||||
The ``[§…§]`` wrapper uses the section sign ``§`` (U+00A7), which never appears
|
||||
in Dify template syntax (``{{var}}`` / ``{{#a.b#}}``) nor in normal prompt text,
|
||||
so these tokens can never collide with the existing template parsers. Runtime
|
||||
expansion (and the final scrub that guarantees no internal marker ever reaches
|
||||
the model) is owned by the run-request builders.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from enum import StrEnum
|
||||
|
||||
from models.agent_config_entities import (
|
||||
AgentHumanContactConfig,
|
||||
AgentSoulConfig,
|
||||
WorkflowNodeJobConfig,
|
||||
WorkflowPreviousNodeOutputRef,
|
||||
)
|
||||
|
||||
|
||||
class MentionKind(StrEnum):
|
||||
SKILL = "skill"
|
||||
FILE = "file"
|
||||
TOOL = "tool"
|
||||
CLI_TOOL = "cli_tool"
|
||||
KNOWLEDGE = "knowledge"
|
||||
HUMAN = "human"
|
||||
NODE_OUTPUT = "node_output"
|
||||
OUTPUT = "output"
|
||||
|
||||
|
||||
MENTION_PATTERN = re.compile(
|
||||
r"\[§(skill|file|tool|cli_tool|knowledge|human|node_output|output):([^:§]+?)(?::([^§]*?))?§\]"
|
||||
)
|
||||
# Anything mention-shaped (``[§word:…§]``) that the strict pattern did not consume
|
||||
# — unknown kinds, malformed bodies. The ``§`` wrapper + a kind-word + ``:``
|
||||
# requirement keeps legacy ``{{#histories#}}`` / ``{{var}}`` template forms and
|
||||
# ordinary bracketed text out of scope.
|
||||
_RESIDUAL_MENTION_PATTERN = re.compile(r"\[§([A-Za-z_][A-Za-z0-9_]*:[^§]*?)§\]")
|
||||
|
||||
MAX_MENTIONS_PER_PROMPT = 200
|
||||
MAX_MENTION_FIELD_LENGTH = 255
|
||||
|
||||
# Per-surface allowlists (design §2.4): the soul prompt may only reference
|
||||
# soul-owned entities; the workflow job prompt may only reference run-scoped ones.
|
||||
SOUL_PROMPT_ALLOWED_KINDS = frozenset(
|
||||
{
|
||||
MentionKind.SKILL,
|
||||
MentionKind.FILE,
|
||||
MentionKind.TOOL,
|
||||
MentionKind.CLI_TOOL,
|
||||
MentionKind.KNOWLEDGE,
|
||||
MentionKind.HUMAN,
|
||||
}
|
||||
)
|
||||
NODE_JOB_PROMPT_ALLOWED_KINDS = frozenset({MentionKind.NODE_OUTPUT, MentionKind.OUTPUT, MentionKind.HUMAN})
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PromptMention:
|
||||
kind: MentionKind
|
||||
ref_id: str
|
||||
label: str | None
|
||||
start: int
|
||||
end: int
|
||||
raw: str
|
||||
|
||||
|
||||
# Returns the model-readable replacement for a mention, or None when the id does
|
||||
# not resolve (the expander then degrades to label/id).
|
||||
MentionResolver = Callable[[PromptMention], str | None]
|
||||
|
||||
|
||||
def parse_prompt_mentions(prompt: str) -> list[PromptMention]:
|
||||
"""Extract well-formed mentions. Oversized id/label tokens are skipped here
|
||||
(treated as malformed) — the runtime scrub still degrades them safely."""
|
||||
mentions: list[PromptMention] = []
|
||||
for match in MENTION_PATTERN.finditer(prompt or ""):
|
||||
ref_id = match.group(2)
|
||||
label = match.group(3)
|
||||
if len(ref_id) > MAX_MENTION_FIELD_LENGTH or (label is not None and len(label) > MAX_MENTION_FIELD_LENGTH):
|
||||
continue
|
||||
mentions.append(
|
||||
PromptMention(
|
||||
kind=MentionKind(match.group(1)),
|
||||
ref_id=ref_id,
|
||||
label=label or None,
|
||||
start=match.start(),
|
||||
end=match.end(),
|
||||
raw=match.group(0),
|
||||
)
|
||||
)
|
||||
return mentions
|
||||
|
||||
|
||||
def expand_prompt_mentions(prompt: str, resolver: MentionResolver) -> str:
|
||||
"""Replace every mention with resolver output, degrading unresolved ones to
|
||||
their label (then id), and scrub any residual mention-shaped marker so no
|
||||
frontend-internal token ever reaches the model."""
|
||||
if not prompt:
|
||||
return prompt
|
||||
|
||||
def _replace(match: re.Match[str]) -> str:
|
||||
ref_id = match.group(2)
|
||||
label = match.group(3) or None
|
||||
fallback = (label or ref_id)[:MAX_MENTION_FIELD_LENGTH]
|
||||
if len(ref_id) > MAX_MENTION_FIELD_LENGTH or (label is not None and len(label) > MAX_MENTION_FIELD_LENGTH):
|
||||
return fallback
|
||||
mention = PromptMention(
|
||||
kind=MentionKind(match.group(1)),
|
||||
ref_id=ref_id,
|
||||
label=label,
|
||||
start=match.start(),
|
||||
end=match.end(),
|
||||
raw=match.group(0),
|
||||
)
|
||||
resolved = resolver(mention)
|
||||
if resolved is None or not resolved.strip():
|
||||
return fallback
|
||||
return resolved[:MAX_MENTION_FIELD_LENGTH]
|
||||
|
||||
return scrub_mention_markers(MENTION_PATTERN.sub(_replace, prompt))
|
||||
|
||||
|
||||
def find_malformed_mention_markers(prompt: str) -> list[str]:
|
||||
"""Mention-shaped markers the strict grammar does not accept (unknown kind,
|
||||
oversized id/label, broken body). Soft-flagged at validate; the runtime
|
||||
scrub still degrades them safely."""
|
||||
if not prompt:
|
||||
return []
|
||||
parsed_spans = {(mention.start, mention.end) for mention in parse_prompt_mentions(prompt)}
|
||||
return [match.group(0) for match in _RESIDUAL_MENTION_PATTERN.finditer(prompt) if match.span() not in parsed_spans]
|
||||
|
||||
|
||||
def scrub_mention_markers(text: str) -> str:
|
||||
"""Degrade any residual mention-shaped ``[§kind:…§]`` marker to readable text."""
|
||||
|
||||
def _degrade(match: re.Match[str]) -> str:
|
||||
# inner is ``kind:id[:label]``; prefer the label, else the id.
|
||||
parts = match.group(1).split(":", 2)
|
||||
if len(parts) >= 3 and parts[2].strip():
|
||||
return parts[2].strip()[:MAX_MENTION_FIELD_LENGTH]
|
||||
if len(parts) >= 2 and parts[1].strip():
|
||||
return parts[1].strip()[:MAX_MENTION_FIELD_LENGTH]
|
||||
return match.group(1)[:MAX_MENTION_FIELD_LENGTH]
|
||||
|
||||
return _RESIDUAL_MENTION_PATTERN.sub(_degrade, text)
|
||||
|
||||
|
||||
def build_soul_mention_resolver(agent_soul: AgentSoulConfig) -> MentionResolver:
|
||||
"""Resolve soul-surface mentions to canonical display names from the soul config."""
|
||||
|
||||
def _resolve(mention: PromptMention) -> str | None:
|
||||
match mention.kind:
|
||||
case MentionKind.SKILL:
|
||||
for skill in agent_soul.skills_files.skills:
|
||||
if mention.ref_id in (skill.id, skill.name):
|
||||
return skill.name or skill.id
|
||||
case MentionKind.FILE:
|
||||
for file in agent_soul.skills_files.files:
|
||||
if mention.ref_id in (file.id, file.name):
|
||||
return file.name or file.id
|
||||
case MentionKind.TOOL:
|
||||
for tool in agent_soul.tools.dify_tools:
|
||||
aliases = {tool.tool_name} | {
|
||||
f"{prefix}/{tool.tool_name}"
|
||||
for prefix in (tool.provider, tool.provider_id, tool.plugin_id)
|
||||
if prefix
|
||||
}
|
||||
if mention.ref_id in aliases:
|
||||
return tool.name or tool.tool_name
|
||||
case MentionKind.CLI_TOOL:
|
||||
for cli_tool in agent_soul.tools.cli_tools:
|
||||
if cli_tool.name and mention.ref_id == cli_tool.name:
|
||||
return cli_tool.name
|
||||
case MentionKind.KNOWLEDGE:
|
||||
for dataset in agent_soul.knowledge.datasets:
|
||||
if mention.ref_id == dataset.id:
|
||||
return dataset.name or dataset.id
|
||||
case MentionKind.HUMAN:
|
||||
return _resolve_human_contact(agent_soul.human.contacts, mention.ref_id)
|
||||
case _:
|
||||
return None
|
||||
return None
|
||||
|
||||
return _resolve
|
||||
|
||||
|
||||
def build_node_job_mention_resolver(node_job: WorkflowNodeJobConfig) -> MentionResolver:
|
||||
"""Resolve job-surface mentions. ``node_output`` expands to the stored
|
||||
reference name only — values stay in the Workflow context block (design §4.2)."""
|
||||
|
||||
def _resolve(mention: PromptMention) -> str | None:
|
||||
match mention.kind:
|
||||
case MentionKind.NODE_OUTPUT:
|
||||
for ref in node_job.previous_node_output_refs:
|
||||
selector = _selector_from_ref(ref)
|
||||
if selector and f"{selector[0]}.{selector[1]}" == mention.ref_id:
|
||||
return ref.name or mention.label or mention.ref_id
|
||||
case MentionKind.OUTPUT:
|
||||
for output in node_job.declared_outputs:
|
||||
if output.name == mention.ref_id:
|
||||
return f"{output.name} ({output.type.value})"
|
||||
case MentionKind.HUMAN:
|
||||
return _resolve_human_contact(node_job.human_contacts, mention.ref_id)
|
||||
case _:
|
||||
return None
|
||||
return None
|
||||
|
||||
return _resolve
|
||||
|
||||
|
||||
def _resolve_human_contact(contacts: list[AgentHumanContactConfig], ref_id: str) -> str | None:
|
||||
for contact in contacts:
|
||||
if ref_id in (contact.id, contact.contact_id, contact.human_id):
|
||||
channel = contact.channel or contact.method or contact.contact_method
|
||||
who = contact.name or contact.email or ref_id
|
||||
return f"{channel.upper()} · {who}" if channel else who
|
||||
return None
|
||||
|
||||
|
||||
def _selector_from_ref(ref: WorkflowPreviousNodeOutputRef) -> tuple[str, str] | None:
|
||||
for candidate in (ref.selector, ref.variable_selector, ref.value_selector):
|
||||
if isinstance(candidate, list) and len(candidate) >= 2:
|
||||
return str(candidate[0]), str(candidate[1])
|
||||
if ref.node_id:
|
||||
output = ref.output or ref.variable or ref.key
|
||||
if output:
|
||||
return ref.node_id, output
|
||||
return None
|
||||
|
||||
|
||||
__all__ = [
|
||||
"MAX_MENTIONS_PER_PROMPT",
|
||||
"MAX_MENTION_FIELD_LENGTH",
|
||||
"MENTION_PATTERN",
|
||||
"NODE_JOB_PROMPT_ALLOWED_KINDS",
|
||||
"SOUL_PROMPT_ALLOWED_KINDS",
|
||||
"MentionKind",
|
||||
"MentionResolver",
|
||||
"PromptMention",
|
||||
"build_node_job_mention_resolver",
|
||||
"build_soul_mention_resolver",
|
||||
"expand_prompt_mentions",
|
||||
"find_malformed_mention_markers",
|
||||
"parse_prompt_mentions",
|
||||
"scrub_mention_markers",
|
||||
]
|
||||
@ -91,5 +91,3 @@ class ComposerCandidatesResponse(BaseModel):
|
||||
allowed_node_job_candidates: dict[str, Any] = Field(default_factory=dict)
|
||||
allowed_soul_candidates: dict[str, Any] = Field(default_factory=dict)
|
||||
capabilities: ComposerCandidateCapabilities = Field(default_factory=ComposerCandidateCapabilities)
|
||||
# True when any candidate list was clipped to the per-list cap (ENG-615 §3.3).
|
||||
truncated: bool = False
|
||||
|
||||
@ -24,7 +24,6 @@ from controllers.console.agent.roster import (
|
||||
AgentRosterVersionDetailApi,
|
||||
AgentRosterVersionsApi,
|
||||
)
|
||||
from models.model import AppMode
|
||||
from services.entities.agent_entities import ComposerSaveStrategy, ComposerVariant
|
||||
|
||||
|
||||
@ -112,22 +111,6 @@ def _candidates_response(variant: str) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def _get_app_model_modes(view) -> list[AppMode]:
|
||||
current = view
|
||||
while current is not None:
|
||||
closure = getattr(current, "__closure__", None)
|
||||
if closure is not None:
|
||||
for cell in closure:
|
||||
try:
|
||||
value = cell.cell_contents
|
||||
except ValueError:
|
||||
continue
|
||||
if isinstance(value, list) and all(isinstance(item, AppMode) for item in value):
|
||||
return value
|
||||
current = getattr(current, "__wrapped__", None)
|
||||
return []
|
||||
|
||||
|
||||
class _PayloadWithDescription(Protocol):
|
||||
description: object
|
||||
|
||||
@ -306,12 +289,12 @@ def test_workflow_composer_get_put_validate_candidates_impact_and_save(
|
||||
)
|
||||
assert saved_state["save_options"] == ["node_job_only"]
|
||||
assert unwrap(WorkflowAgentComposerValidateApi.post)(
|
||||
WorkflowAgentComposerValidateApi(), "tenant-1", app_model, "node-1"
|
||||
) == {"result": "success", "errors": [], "warnings": [], "knowledge_retrieval_placeholder": []}
|
||||
WorkflowAgentComposerValidateApi(), app_model, "node-1"
|
||||
) == {"result": "success", "errors": []}
|
||||
assert (
|
||||
unwrap(WorkflowAgentComposerCandidatesApi.get)(
|
||||
WorkflowAgentComposerCandidatesApi(), "tenant-1", account_id, app_model, "node-1"
|
||||
)["variant"]
|
||||
unwrap(WorkflowAgentComposerCandidatesApi.get)(WorkflowAgentComposerCandidatesApi(), app_model, "node-1")[
|
||||
"variant"
|
||||
]
|
||||
== "workflow"
|
||||
)
|
||||
with app.test_request_context(json=payload):
|
||||
@ -366,20 +349,9 @@ def test_agent_app_composer_get_put_validate_and_candidates(
|
||||
unwrap(AgentAppComposerApi.put)(AgentAppComposerApi(), "tenant-1", account_id, app_model)["variant"]
|
||||
== "agent_app"
|
||||
)
|
||||
assert unwrap(AgentAppComposerValidateApi.post)(AgentAppComposerValidateApi(), "tenant-1", app_model) == {
|
||||
assert unwrap(AgentAppComposerValidateApi.post)(AgentAppComposerValidateApi(), app_model) == {
|
||||
"result": "success",
|
||||
"errors": [],
|
||||
"warnings": [],
|
||||
"knowledge_retrieval_placeholder": [],
|
||||
}
|
||||
agent_app_candidates = unwrap(AgentAppComposerCandidatesApi.get)(
|
||||
AgentAppComposerCandidatesApi(), "tenant-1", account_id, app_model
|
||||
)
|
||||
agent_app_candidates = unwrap(AgentAppComposerCandidatesApi.get)(AgentAppComposerCandidatesApi(), app_model)
|
||||
assert agent_app_candidates["variant"] == "agent_app"
|
||||
|
||||
|
||||
def test_agent_app_composer_routes_are_agent_mode_only() -> None:
|
||||
assert _get_app_model_modes(AgentAppComposerApi.get) == [AppMode.AGENT]
|
||||
assert _get_app_model_modes(AgentAppComposerApi.put) == [AppMode.AGENT]
|
||||
assert _get_app_model_modes(AgentAppComposerValidateApi.post) == [AppMode.AGENT]
|
||||
assert _get_app_model_modes(AgentAppComposerCandidatesApi.get) == [AppMode.AGENT]
|
||||
|
||||
@ -14,7 +14,7 @@ import pytest
|
||||
from flask import Flask
|
||||
|
||||
from controllers.console.auth.activate import ActivateApi, ActivateCheckApi
|
||||
from controllers.console.error import AccountInFreezeError, AlreadyActivateError
|
||||
from controllers.console.error import AlreadyActivateError
|
||||
from models.account import AccountStatus
|
||||
|
||||
|
||||
@ -255,47 +255,6 @@ class TestActivateApi:
|
||||
with pytest.raises(AlreadyActivateError):
|
||||
api.post()
|
||||
|
||||
@patch("controllers.console.auth.activate.dify_config.BILLING_ENABLED", True)
|
||||
@patch("controllers.console.auth.activate.BillingService.is_email_in_freeze")
|
||||
@patch("controllers.console.auth.activate.RegisterService.get_invitation_if_token_valid")
|
||||
@patch("controllers.console.auth.activate.RegisterService.revoke_token")
|
||||
@patch("controllers.console.auth.activate.db")
|
||||
def test_activation_rejects_account_in_billing_freeze(
|
||||
self,
|
||||
mock_db,
|
||||
mock_revoke_token,
|
||||
mock_get_invitation,
|
||||
mock_is_email_in_freeze,
|
||||
app: Flask,
|
||||
mock_invitation,
|
||||
mock_account,
|
||||
):
|
||||
"""Frozen deleted-account emails cannot be reactivated through invitation links."""
|
||||
mock_account.email = "Invitee@Example.com"
|
||||
mock_get_invitation.return_value = mock_invitation
|
||||
mock_is_email_in_freeze.return_value = True
|
||||
|
||||
with app.test_request_context(
|
||||
"/activate",
|
||||
method="POST",
|
||||
json={
|
||||
"workspace_id": "workspace-123",
|
||||
"email": "invitee@example.com",
|
||||
"token": "valid_token",
|
||||
"name": "John Doe",
|
||||
"interface_language": "en-US",
|
||||
"timezone": "UTC",
|
||||
},
|
||||
):
|
||||
api = ActivateApi()
|
||||
with pytest.raises(AccountInFreezeError):
|
||||
api.post()
|
||||
|
||||
mock_is_email_in_freeze.assert_called_once_with("Invitee@Example.com")
|
||||
mock_revoke_token.assert_not_called()
|
||||
mock_db.session.commit.assert_not_called()
|
||||
assert mock_account.status == AccountStatus.PENDING
|
||||
|
||||
@patch("controllers.console.auth.activate.RegisterService.get_invitation_with_case_fallback")
|
||||
@patch("controllers.console.auth.activate.RegisterService.revoke_token")
|
||||
@patch("controllers.console.auth.activate.db")
|
||||
|
||||
@ -208,41 +208,3 @@ def test_accepts_body_emits_expect_through_guard_stack():
|
||||
|
||||
apidoc = getattr(view, "__apidoc__", {})
|
||||
assert apidoc.get("expect") # body schema advertised via @openapi_ns.expect
|
||||
|
||||
|
||||
def _response_model_name(entry) -> str:
|
||||
"""Extract the model name from a flask-restx __apidoc__ response entry.
|
||||
|
||||
flask-restx stores responses as ``(description, model, kwargs)`` tuples
|
||||
where ``model.name`` is the registered schema name.
|
||||
"""
|
||||
if isinstance(entry, tuple) and len(entry) >= 2:
|
||||
model = entry[1]
|
||||
return getattr(model, "name", "") or ""
|
||||
return ""
|
||||
|
||||
|
||||
def test_accepts_documents_422_error_response(app):
|
||||
from controllers.openapi._errors import ErrorBody
|
||||
|
||||
@accepts(query=ContractQuery)
|
||||
def view(*, query):
|
||||
return query
|
||||
|
||||
doc = getattr(view, "__apidoc__", {})
|
||||
responses = doc.get("responses", {})
|
||||
assert "422" in responses
|
||||
assert _response_model_name(responses["422"]) == ErrorBody.__name__
|
||||
|
||||
|
||||
def test_returns_documents_default_error_response(app):
|
||||
from controllers.openapi._errors import ErrorBody
|
||||
|
||||
@returns(200, ContractResp)
|
||||
def view():
|
||||
return ContractResp(value=1)
|
||||
|
||||
doc = getattr(view, "__apidoc__", {})
|
||||
responses = doc.get("responses", {})
|
||||
assert "default" in responses
|
||||
assert _response_model_name(responses["default"]) == ErrorBody.__name__
|
||||
|
||||
@ -1,337 +0,0 @@
|
||||
"""Wire-contract tests for the canonical /openapi/v1 error body."""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from werkzeug.exceptions import (
|
||||
BadGateway,
|
||||
BadRequest,
|
||||
Conflict,
|
||||
Forbidden,
|
||||
InternalServerError,
|
||||
NotFound,
|
||||
Unauthorized,
|
||||
UnprocessableEntity,
|
||||
)
|
||||
|
||||
from controllers.common.errors import (
|
||||
BlockedFileExtensionError,
|
||||
FileTooLargeError,
|
||||
NoFileUploadedError,
|
||||
TooManyFilesError,
|
||||
UnsupportedFileTypeError,
|
||||
)
|
||||
from controllers.openapi._errors import (
|
||||
ErrorBody,
|
||||
ErrorDetail,
|
||||
FilenameNotExists,
|
||||
MemberLicenseExceeded,
|
||||
MemberLimitExceeded,
|
||||
OpenApiError,
|
||||
OpenApiErrorCode,
|
||||
OpenApiErrorFormatter,
|
||||
)
|
||||
from controllers.service_api.app.error import (
|
||||
AppUnavailableError,
|
||||
CompletionRequestError,
|
||||
ConversationCompletedError,
|
||||
ProviderModelCurrentlyNotSupportError,
|
||||
ProviderNotInitializeError,
|
||||
ProviderQuotaExceededError,
|
||||
)
|
||||
from controllers.web.error import InvokeRateLimitError as InvokeRateLimitHttpError
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fmt() -> OpenApiErrorFormatter:
|
||||
return OpenApiErrorFormatter()
|
||||
|
||||
|
||||
class TestErrorBodyModel:
|
||||
def test_minimal_body_serializes_without_optional_fields(self):
|
||||
body = ErrorBody(code=OpenApiErrorCode.NOT_FOUND, message="app not found", status=404)
|
||||
|
||||
wire = body.model_dump(mode="json", exclude_none=True)
|
||||
|
||||
assert wire == {"code": "not_found", "message": "app not found", "status": 404}
|
||||
|
||||
def test_full_body_round_trips(self):
|
||||
body = ErrorBody(
|
||||
code=OpenApiErrorCode.INVALID_PARAM,
|
||||
message="Request validation failed",
|
||||
status=422,
|
||||
hint="check the request payload",
|
||||
details=[ErrorDetail(type="int_parsing", loc=["page"], msg="must be >= 1")],
|
||||
)
|
||||
|
||||
wire = body.model_dump(mode="json", exclude_none=True)
|
||||
|
||||
assert wire["details"] == [{"type": "int_parsing", "loc": ["page"], "msg": "must be >= 1"}]
|
||||
assert ErrorBody.model_validate(wire) == body
|
||||
|
||||
def test_code_field_is_open_string_for_forward_compat(self):
|
||||
# Old CLIs must not hard-fail when a future server adds a code, so the
|
||||
# schema type is str; enum membership is enforced by the formatter tests.
|
||||
body = ErrorBody.model_validate({"code": "some_future_code", "message": "x", "status": 400})
|
||||
|
||||
assert body.code == "some_future_code"
|
||||
|
||||
|
||||
class TestOpenApiErrorFormatter:
|
||||
def test_plain_werkzeug_exception_maps_code_from_status(self, fmt):
|
||||
e = NotFound("app not found")
|
||||
data = {"code": "not_found", "message": "app not found", "status": 404}
|
||||
|
||||
wire = fmt.finalize(e, data, 404)
|
||||
|
||||
assert wire == {"code": "not_found", "message": "app not found", "status": 404}
|
||||
|
||||
def test_422_maps_to_invalid_param(self, fmt):
|
||||
e = UnprocessableEntity("workspace_id is required for name-based lookup")
|
||||
data = {"code": "unprocessable_entity", "message": e.description, "status": 422}
|
||||
|
||||
wire = fmt.finalize(e, data, 422)
|
||||
|
||||
assert wire["code"] == "invalid_param"
|
||||
|
||||
def test_flask_restx_abort_data_path_yields_canonical_body(self, fmt):
|
||||
# Simulates _contract.py's abort(422, message=..., errors=...): flask_restx
|
||||
# attaches kwargs to e.data, which handle_error would otherwise put on the
|
||||
# wire verbatim (no code/status).
|
||||
e = UnprocessableEntity()
|
||||
e.data = {
|
||||
"message": "Request validation failed",
|
||||
"errors": [{"type": "int_parsing", "loc": ["page"], "msg": "must be >= 1", "extra": "drop me"}],
|
||||
}
|
||||
data = {"code": "unprocessable_entity", "message": e.description, "status": 422}
|
||||
|
||||
wire = fmt.finalize(e, data, 422)
|
||||
|
||||
assert wire["code"] == "invalid_param"
|
||||
assert wire["message"] == "Request validation failed"
|
||||
assert wire["status"] == 422
|
||||
assert wire["details"] == [{"type": "int_parsing", "loc": ["page"], "msg": "must be >= 1"}]
|
||||
# the override channel now carries the canonical body
|
||||
assert e.data == wire
|
||||
|
||||
def test_finalize_is_idempotent(self, fmt):
|
||||
e = UnprocessableEntity()
|
||||
e.data = {
|
||||
"message": "Request validation failed",
|
||||
"errors": [{"type": "int_parsing", "loc": ["page"], "msg": "must be >= 1"}],
|
||||
}
|
||||
data = {"code": "unprocessable_entity", "message": e.description, "status": 422}
|
||||
|
||||
first = fmt.finalize(e, data, 422)
|
||||
second = fmt.finalize(e, data, 422)
|
||||
|
||||
assert second == first
|
||||
|
||||
def test_malformed_canonical_details_falls_back_instead_of_raising(self, fmt):
|
||||
# finalize runs inside the framework error handler; a ValidationError
|
||||
# escaping it would replace the response with an unformatted 500
|
||||
e = UnprocessableEntity()
|
||||
e.data = {"message": "broken", "details": [{"bad": "shape"}]}
|
||||
data = {"code": "unprocessable_entity", "message": "broken", "status": 422}
|
||||
|
||||
wire = fmt.finalize(e, data, 422)
|
||||
|
||||
assert wire == {"code": "invalid_param", "message": "Unprocessable Entity", "status": 422}
|
||||
|
||||
def test_base_http_exception_error_code_wins_over_status_map(self, fmt):
|
||||
e = ProviderQuotaExceededError()
|
||||
data = dict(e.data)
|
||||
|
||||
wire = fmt.finalize(e, data, 400)
|
||||
|
||||
assert wire["code"] == "provider_quota_exceeded"
|
||||
assert wire["status"] == 400
|
||||
|
||||
def test_hint_attribute_is_emitted(self, fmt):
|
||||
e = Conflict("seat limit")
|
||||
e.hint = "remove a member first"
|
||||
data = {"code": "conflict", "message": "seat limit", "status": 409}
|
||||
|
||||
wire = fmt.finalize(e, data, 409)
|
||||
|
||||
assert wire["hint"] == "remove a member first"
|
||||
|
||||
def test_params_shape_becomes_details(self, fmt):
|
||||
e = ValueError("is required")
|
||||
data = {"code": "invalid_param", "message": "is required", "params": "email", "status": 400}
|
||||
|
||||
wire = fmt.finalize(e, data, 400)
|
||||
|
||||
assert "params" not in wire
|
||||
assert wire["details"] == [{"type": "invalid", "loc": ["email"], "msg": "is required"}]
|
||||
|
||||
def test_catch_all_exception_never_leaks_str_e(self, fmt):
|
||||
e = RuntimeError("postgres password=hunter2 connection refused")
|
||||
data = {"message": str(e), "code": "unknown", "status": 500}
|
||||
|
||||
wire = fmt.finalize(e, data, 500)
|
||||
|
||||
assert wire["code"] == "internal_server_error"
|
||||
assert "hunter2" not in wire["message"]
|
||||
|
||||
def test_unmapped_status_falls_back_to_unknown(self, fmt):
|
||||
from werkzeug.exceptions import Gone
|
||||
|
||||
e = Gone()
|
||||
data = {"code": "gone", "message": e.description, "status": 410}
|
||||
|
||||
wire = fmt.finalize(e, data, 410)
|
||||
|
||||
assert wire["code"] == "unknown"
|
||||
|
||||
def test_openapi_error_subclass_is_throw_and_done(self, fmt):
|
||||
# The dedicated throwable: subclass declares status + code + message once,
|
||||
# call sites just `raise`; the formatter emits everything verbatim.
|
||||
class TeapotError(OpenApiError):
|
||||
code = 418
|
||||
error_code = OpenApiErrorCode.INVALID_PARAM
|
||||
description = "kettle says no"
|
||||
|
||||
e = TeapotError(details=[ErrorDetail(type="invalid", loc=["kettle"], msg="too hot")])
|
||||
data = {"code": "im_a_teapot", "message": e.description, "status": 418}
|
||||
|
||||
wire = fmt.finalize(e, data, 418)
|
||||
|
||||
assert wire["code"] == OpenApiErrorCode.INVALID_PARAM
|
||||
assert wire["message"] == TeapotError.description
|
||||
assert wire["details"] == [{"type": "invalid", "loc": ["kettle"], "msg": "too hot"}]
|
||||
|
||||
def test_openapi_error_message_override(self, fmt):
|
||||
e = OpenApiError("custom reason")
|
||||
data = {"code": "bad_request", "message": e.description, "status": 400}
|
||||
|
||||
wire = fmt.finalize(e, data, 400)
|
||||
|
||||
assert wire["message"] == "custom reason"
|
||||
assert wire["code"] == "bad_request"
|
||||
|
||||
def test_every_emitted_code_is_an_enum_member(self, fmt):
|
||||
# Guard against the formatter inventing codes outside the contract.
|
||||
cases = [
|
||||
(NotFound("x"), {"code": "not_found", "message": "x", "status": 404}, 404),
|
||||
(ProviderQuotaExceededError(), dict(ProviderQuotaExceededError().data), 400),
|
||||
(ValueError("x"), {"code": "invalid_param", "message": "x", "status": 400}, 400),
|
||||
]
|
||||
for e, data, status in cases:
|
||||
wire = fmt.finalize(e, data, status)
|
||||
assert wire["code"] in {c.value for c in OpenApiErrorCode}
|
||||
|
||||
|
||||
class TestQuotaExceptions:
|
||||
@pytest.mark.parametrize("exc_class", [MemberLimitExceeded, MemberLicenseExceeded])
|
||||
def test_quota_exception_carries_declared_code_and_message(self, fmt, exc_class):
|
||||
# Single source: assertions read the class attributes, no re-typed strings.
|
||||
e = exc_class()
|
||||
data = {"code": "forbidden", "message": e.description, "status": 403}
|
||||
|
||||
wire = fmt.finalize(e, data, 403)
|
||||
|
||||
assert wire["code"] == exc_class.error_code
|
||||
assert wire["message"] == exc_class.description
|
||||
assert wire["hint"] == exc_class.hint
|
||||
assert wire["status"] == 403
|
||||
|
||||
|
||||
class TestWireContract:
|
||||
"""End-to-end: request in, canonical JSON out, through the real openapi blueprint."""
|
||||
|
||||
def test_accepts_422_carries_code_status_details(self, openapi_app, bypass_pipeline):
|
||||
client = openapi_app.test_client()
|
||||
|
||||
resp = client.get("/openapi/v1/apps?page=0")
|
||||
|
||||
assert resp.status_code == 422
|
||||
wire = resp.get_json()
|
||||
ErrorBody.model_validate(wire)
|
||||
assert wire["code"] == "invalid_param"
|
||||
assert wire["status"] == 422
|
||||
assert wire["details"]
|
||||
|
||||
def test_unknown_route_404_is_canonical_without_route_suggestions(self, openapi_app):
|
||||
client = openapi_app.test_client()
|
||||
|
||||
resp = client.get("/openapi/v1/definitely-not-a-route")
|
||||
|
||||
assert resp.status_code == 404
|
||||
wire = resp.get_json()
|
||||
ErrorBody.model_validate(wire)
|
||||
assert wire["code"] == "not_found"
|
||||
assert "did you mean" not in wire["message"].lower()
|
||||
|
||||
def test_404_outside_blueprint_prefix_is_not_claimed(self, openapi_app):
|
||||
# catch_all_404s wraps the app-level exception handler; the prefix
|
||||
# guard must keep non-/openapi/v1 paths on the app's own 404 handling
|
||||
client = openapi_app.test_client()
|
||||
|
||||
resp = client.get("/console/definitely-not-a-route")
|
||||
|
||||
assert resp.status_code == 404
|
||||
# not intercepted → Flask's default HTML 404, not the canonical JSON body
|
||||
assert "application/json" not in (resp.content_type or "")
|
||||
|
||||
@patch("controllers.openapi.oauth_device.DeviceFlowRedis")
|
||||
def test_oauth_device_token_keeps_rfc8628_shape(self, mock_redis_cls, openapi_app):
|
||||
store = MagicMock()
|
||||
mock_redis_cls.return_value = store
|
||||
store.record_poll.return_value = None # not SlowDownDecision.SLOW_DOWN
|
||||
store.load_by_device_code.return_value = None # unknown code → expired_token
|
||||
|
||||
client = openapi_app.test_client()
|
||||
|
||||
resp = client.post(
|
||||
"/openapi/v1/oauth/device/token",
|
||||
json={"client_id": "difyctl", "device_code": "nope"},
|
||||
)
|
||||
|
||||
assert resp.status_code == 400
|
||||
wire = resp.get_json()
|
||||
assert wire == {"error": "expired_token"}
|
||||
|
||||
|
||||
ERROR_MATRIX = [
|
||||
(BadRequest("x"), 400, "bad_request"),
|
||||
(Unauthorized("x"), 401, "unauthorized"),
|
||||
(Forbidden("x"), 403, "forbidden"),
|
||||
(NotFound("x"), 404, "not_found"),
|
||||
(Conflict("x"), 409, "conflict"),
|
||||
(UnprocessableEntity("x"), 422, "invalid_param"),
|
||||
(InternalServerError(), 500, "internal_server_error"),
|
||||
(BadGateway("x"), 502, "bad_gateway"),
|
||||
(AppUnavailableError(), 400, "app_unavailable"),
|
||||
(ConversationCompletedError(), 400, "conversation_completed"),
|
||||
(ProviderNotInitializeError(), 400, "provider_not_initialize"),
|
||||
(ProviderQuotaExceededError(), 400, "provider_quota_exceeded"),
|
||||
(ProviderModelCurrentlyNotSupportError(), 400, "model_currently_not_support"),
|
||||
(CompletionRequestError(), 400, "completion_request_error"),
|
||||
(InvokeRateLimitHttpError(), 429, "rate_limit_error"),
|
||||
(FileTooLargeError(), 413, "file_too_large"),
|
||||
(UnsupportedFileTypeError(), 415, "unsupported_file_type"),
|
||||
(NoFileUploadedError(), 400, "no_file_uploaded"),
|
||||
(TooManyFilesError(), 400, "too_many_files"),
|
||||
(FilenameNotExists(), 400, "filename_not_exists"),
|
||||
(BlockedFileExtensionError(), 400, "file_extension_blocked"),
|
||||
(MemberLimitExceeded(), 403, "member_limit_exceeded"),
|
||||
(MemberLicenseExceeded(), 403, "member_license_exceeded"),
|
||||
]
|
||||
|
||||
|
||||
class TestErrorMatrix:
|
||||
@pytest.mark.parametrize(
|
||||
("exc", "status", "expected_code"),
|
||||
ERROR_MATRIX,
|
||||
ids=lambda v: type(v).__name__ if isinstance(v, Exception) else str(v),
|
||||
)
|
||||
def test_every_known_error_path_yields_canonical_code(self, fmt, exc, status, expected_code):
|
||||
data = dict(getattr(exc, "data", None) or {"message": str(exc), "status": status})
|
||||
|
||||
wire = fmt.finalize(exc, data, status)
|
||||
|
||||
assert wire["code"] == expected_code
|
||||
assert wire["status"] == status
|
||||
assert wire["code"] in {c.value for c in OpenApiErrorCode}
|
||||
ErrorBody.model_validate(wire)
|
||||
@ -29,10 +29,9 @@ import pytest
|
||||
from flask import Flask
|
||||
from flask.views import MethodView
|
||||
from pydantic import ValidationError
|
||||
from werkzeug.exceptions import BadRequest, NotFound, UnprocessableEntity
|
||||
from werkzeug.exceptions import BadRequest, Forbidden, NotFound, UnprocessableEntity
|
||||
|
||||
from controllers.openapi import bp as openapi_bp
|
||||
from controllers.openapi._errors import MemberLicenseExceeded, MemberLimitExceeded
|
||||
from controllers.openapi._models import MemberInvitePayload, MemberRoleUpdatePayload
|
||||
from controllers.openapi.workspaces import (
|
||||
WorkspaceMemberApi,
|
||||
@ -508,7 +507,11 @@ def _invite_request(app, ws_id: str, acct_id: uuid.UUID):
|
||||
|
||||
|
||||
def test_invite_blocked_by_saas_members_cap(app, bypass_pipeline, monkeypatch):
|
||||
"""SaaS billing plan member cap → MemberLimitExceeded (403)."""
|
||||
"""SaaS billing plan member cap → 403 with `members.limit_exceeded`.
|
||||
|
||||
Verifies the envelope shape the CLI error-mapper relies on (code +
|
||||
message + hint on the wire body).
|
||||
"""
|
||||
ws_id = str(uuid.uuid4())
|
||||
acct_id = uuid.uuid4()
|
||||
api = WorkspaceMembersApi()
|
||||
@ -535,14 +538,18 @@ def test_invite_blocked_by_saas_members_cap(app, bypass_pipeline, monkeypatch):
|
||||
|
||||
with _invite_request(app, ws_id, acct_id):
|
||||
_seed(_auth_ctx(account_id=acct_id))
|
||||
with pytest.raises(MemberLimitExceeded):
|
||||
with pytest.raises(Forbidden) as exc_info:
|
||||
api.post.__wrapped__(api, workspace_id=ws_id, auth_data=_auth_data(acct_id))
|
||||
|
||||
body = exc_info.value.response.json
|
||||
assert body["code"] == "members.limit_exceeded"
|
||||
assert "Subscription member limit" in body["message"]
|
||||
assert body["hint"]
|
||||
invite_mock.assert_not_called()
|
||||
|
||||
|
||||
def test_invite_blocked_by_ee_workspace_members_license(app, bypass_pipeline, monkeypatch):
|
||||
"""EE License workspace_members cap → MemberLicenseExceeded (403).
|
||||
"""EE License workspace_members cap → 403 with `workspace_members.license_exceeded`.
|
||||
|
||||
Note: billing.enabled is False (EE without SaaS billing); only the
|
||||
license cap fires.
|
||||
@ -577,9 +584,13 @@ def test_invite_blocked_by_ee_workspace_members_license(app, bypass_pipeline, mo
|
||||
|
||||
with _invite_request(app, ws_id, acct_id):
|
||||
_seed(_auth_ctx(account_id=acct_id))
|
||||
with pytest.raises(MemberLicenseExceeded):
|
||||
with pytest.raises(Forbidden) as exc_info:
|
||||
api.post.__wrapped__(api, workspace_id=ws_id, auth_data=_auth_data(acct_id))
|
||||
|
||||
body = exc_info.value.response.json
|
||||
assert body["code"] == "workspace_members.license_exceeded"
|
||||
assert "license" in body["message"].lower()
|
||||
assert body["hint"]
|
||||
invite_mock.assert_not_called()
|
||||
|
||||
|
||||
|
||||
@ -599,40 +599,3 @@ def test_effective_declared_outputs_passthrough_when_user_declared():
|
||||
declared = [DeclaredOutputConfig(name="summary", type=DeclaredOutputType.STRING)]
|
||||
effective = WorkflowAgentRuntimeRequestBuilder.effective_declared_outputs(declared)
|
||||
assert list(effective) == declared
|
||||
|
||||
|
||||
def test_mentions_expand_in_soul_and_job_prompts_without_token_leak():
|
||||
"""ENG-616: slash-menu mention tokens expand to canonical names; node_output
|
||||
mentions expand to the reference name only (the value stays in the Workflow
|
||||
context user prompt), and no ``[§…§]`` marker leaks into the request."""
|
||||
import json
|
||||
|
||||
context = _context()
|
||||
context.snapshot.config_snapshot = AgentSoulConfig(
|
||||
prompt={"system_prompt": "Careful. Ask [§human:c-1:EMAIL · DAVE§] when unsure."},
|
||||
model=AgentSoulModelConfig(plugin_id="langgenius/openai", model_provider="openai", model="gpt-test"),
|
||||
human={"contacts": [{"id": "c-1", "name": "David Hayes", "channel": "email"}]},
|
||||
)
|
||||
context.binding.node_job_config = WorkflowNodeJobConfig.model_validate(
|
||||
{
|
||||
"workflow_prompt": (
|
||||
"Read [§node_output:previous-node.text:PREV/text§] and produce [§output:summary§]. "
|
||||
"Unknown [§knowledge:gone:旧手册§] degrades."
|
||||
),
|
||||
"previous_node_output_refs": [
|
||||
{"selector": ["previous-node", "text"], "name": "PREV/text"},
|
||||
],
|
||||
"declared_outputs": [{"name": "summary", "type": "string"}],
|
||||
}
|
||||
)
|
||||
|
||||
result = WorkflowAgentRuntimeRequestBuilder(credentials_provider=FakeCredentialsProvider()).build(context)
|
||||
|
||||
dumped = result.request.model_dump(mode="json")
|
||||
assert dumped["composition"]["layers"][0]["config"]["prefix"] == ("Careful. Ask EMAIL · David Hayes when unsure.")
|
||||
assert dumped["composition"]["layers"][1]["config"]["prefix"] == (
|
||||
"Read PREV/text and produce summary (string). Unknown 旧手册 degrades."
|
||||
)
|
||||
# the value still rides the Workflow context block, not the job prompt
|
||||
assert "Previous result" in dumped["composition"]["layers"][2]["config"]["user"]
|
||||
assert "[§" not in json.dumps(dumped["composition"]["layers"][:3])
|
||||
|
||||
@ -1,51 +0,0 @@
|
||||
"""Unit tests for the shared workflow graph topology helper (ENG-615)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from core.workflow.graph_topology import WorkflowGraphTopology
|
||||
|
||||
_GRAPH = {
|
||||
"nodes": [
|
||||
{"id": "start"},
|
||||
{"id": "llm-1"},
|
||||
{"id": "llm-2"},
|
||||
{"id": "agent"},
|
||||
{"id": "end"},
|
||||
],
|
||||
"edges": [
|
||||
{"source": "start", "target": "llm-1"},
|
||||
{"source": "start", "target": "llm-2"},
|
||||
{"source": "llm-1", "target": "agent"},
|
||||
{"source": "llm-2", "target": "agent"},
|
||||
{"source": "agent", "target": "end"},
|
||||
# ghost edge: source node was deleted from nodes[]
|
||||
{"source": "ghost", "target": "agent"},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_upstream_node_ids_collects_all_ancestors_excluding_ghosts():
|
||||
topology = WorkflowGraphTopology.from_graph(_GRAPH)
|
||||
assert topology.upstream_node_ids("agent") == {"start", "llm-1", "llm-2"}
|
||||
|
||||
|
||||
def test_upstream_node_ids_differ_per_target_node():
|
||||
topology = WorkflowGraphTopology.from_graph(_GRAPH)
|
||||
assert topology.upstream_node_ids("llm-1") == {"start"}
|
||||
assert topology.upstream_node_ids("end") == {"start", "llm-1", "llm-2", "agent"}
|
||||
assert topology.upstream_node_ids("start") == set()
|
||||
|
||||
|
||||
def test_is_upstream_kept_for_publish_validation():
|
||||
topology = WorkflowGraphTopology.from_graph(_GRAPH)
|
||||
assert topology.is_upstream(source_node_id="start", target_node_id="end")
|
||||
assert not topology.is_upstream(source_node_id="end", target_node_id="start")
|
||||
|
||||
|
||||
def test_cycle_safe():
|
||||
graph = {
|
||||
"nodes": [{"id": "a"}, {"id": "b"}],
|
||||
"edges": [{"source": "a", "target": "b"}, {"source": "b", "target": "a"}],
|
||||
}
|
||||
topology = WorkflowGraphTopology.from_graph(graph)
|
||||
assert topology.upstream_node_ids("a") == {"b"}
|
||||
@ -148,7 +148,6 @@ def test_save_workflow_composer_dispatches_save_strategy(monkeypatch, strategy,
|
||||
tenant_id="tenant-1", app_id="app-1", node_id="node-1", account_id="account-1", payload=payload
|
||||
)
|
||||
|
||||
assert result.pop("validation") == {"warnings": [], "knowledge_retrieval_placeholder": []}
|
||||
assert result == {"state": "ok"}
|
||||
assert calls
|
||||
assert fake_session.commits == 1
|
||||
@ -190,7 +189,6 @@ def test_save_agent_app_composer_creates_agent_when_missing(monkeypatch):
|
||||
tenant_id="tenant-1", app_id="app-1", account_id="account-1", payload=payload
|
||||
)
|
||||
|
||||
assert result.pop("validation") == {"warnings": [], "knowledge_retrieval_placeholder": []}
|
||||
assert result == {"loaded": True}
|
||||
assert fake_session.added[0].name == "Analyst"
|
||||
assert fake_session.added[0].active_config_snapshot_id == "version-1"
|
||||
@ -224,7 +222,6 @@ def test_save_agent_app_composer_updates_current_version(monkeypatch):
|
||||
tenant_id="tenant-1", app_id="app-1", account_id="account-1", payload=payload
|
||||
)
|
||||
|
||||
assert result.pop("validation") == {"warnings": [], "knowledge_retrieval_placeholder": []}
|
||||
assert result == {"loaded": True}
|
||||
assert updated["operation"].value == "save_current_version"
|
||||
assert fake_session._scalar == []
|
||||
@ -238,28 +235,12 @@ def test_agent_app_composer_candidates_and_impact(monkeypatch):
|
||||
]
|
||||
monkeypatch.setattr(composer_service.db, "session", FakeSession(scalars=[bindings]))
|
||||
|
||||
# Candidates assembly is covered in test_composer_candidates.py; here we stub
|
||||
# the IO loaders and assert the response envelope per variant (ENG-615).
|
||||
def _no_draft_workflow(**kwargs):
|
||||
raise ValueError("draft workflow not found")
|
||||
|
||||
monkeypatch.setattr(AgentComposerService, "_get_draft_workflow", _no_draft_workflow)
|
||||
monkeypatch.setattr(AgentComposerService, "_load_agent_app_soul", lambda **kwargs: None)
|
||||
monkeypatch.setattr(AgentComposerService, "_workspace_dify_tools", lambda **kwargs: [])
|
||||
|
||||
workflow_candidates = AgentComposerService.get_workflow_candidates(
|
||||
tenant_id="tenant-1", app_id="app-1", node_id="node-1", user_id="account-1"
|
||||
)
|
||||
agent_app_candidates = AgentComposerService.get_agent_app_candidates(
|
||||
tenant_id="tenant-1", app_id="app-1", user_id="account-1"
|
||||
)
|
||||
workflow_candidates = AgentComposerService.get_workflow_candidates(app_id="app-1")
|
||||
agent_app_candidates = AgentComposerService.get_agent_app_candidates(app_id="app-1")
|
||||
impact = AgentComposerService.calculate_impact(tenant_id="tenant-1", current_snapshot_id="version-1")
|
||||
|
||||
assert workflow_candidates["variant"] == "workflow"
|
||||
assert workflow_candidates["allowed_node_job_candidates"]["previous_node_outputs"] == []
|
||||
assert workflow_candidates["truncated"] is False
|
||||
assert agent_app_candidates["variant"] == "agent_app"
|
||||
assert agent_app_candidates["allowed_soul_candidates"]["dify_tools"] == []
|
||||
assert impact["workflow_node_count"] == 2
|
||||
assert impact["bindings"][1]["node_id"] == "node-2"
|
||||
|
||||
@ -894,27 +875,3 @@ class TestListWorkflowsReferencingAppAgent:
|
||||
service = AgentRosterService(session)
|
||||
|
||||
assert service.list_workflows_referencing_app_agent(tenant_id="tenant-1", app_id="app-1") == []
|
||||
|
||||
|
||||
def test_dataset_rows_filters_malformed_ids(monkeypatch):
|
||||
"""Mention ids are user-editable text: a non-UUID id must read as missing
|
||||
(placeholder semantics), never reach the UUID-typed dataset query (E2E 500)."""
|
||||
captured = {}
|
||||
|
||||
def fake_get_datasets_by_ids(ids, tenant_id):
|
||||
captured["ids"] = ids
|
||||
return [], 0
|
||||
|
||||
import services.dataset_service as dataset_service_module
|
||||
|
||||
monkeypatch.setattr(dataset_service_module.DatasetService, "get_datasets_by_ids", fake_get_datasets_by_ids)
|
||||
|
||||
valid = "550e8400-e29b-41d4-a716-446655440000"
|
||||
rows = AgentComposerService._dataset_rows(tenant_id="tenant-1", dataset_ids=["9999dead-beef", valid])
|
||||
assert rows == {}
|
||||
assert captured["ids"] == [valid]
|
||||
|
||||
# all-malformed input never touches the DB
|
||||
captured.clear()
|
||||
assert AgentComposerService._dataset_rows(tenant_id="tenant-1", dataset_ids=["nope"]) == {}
|
||||
assert captured == {}
|
||||
|
||||
@ -1,204 +0,0 @@
|
||||
"""Unit tests for slash-menu candidates assembly (ENG-615)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
from fields.agent_fields import AgentComposerCandidatesResponse
|
||||
from models.agent_config_entities import AgentSoulConfig, DeclaredOutputConfig, DeclaredOutputType
|
||||
from services.agent.composer_candidates import (
|
||||
MAX_CANDIDATES_PER_LIST,
|
||||
previous_node_output_candidates,
|
||||
soul_candidates,
|
||||
)
|
||||
|
||||
_GRAPH = {
|
||||
"nodes": [
|
||||
{
|
||||
"id": "start-1",
|
||||
"data": {
|
||||
"type": "start",
|
||||
"title": "START",
|
||||
"variables": [{"variable": "tenders", "type": "file-list"}],
|
||||
},
|
||||
},
|
||||
{"id": "llm-1", "data": {"type": "llm", "title": "LLM"}},
|
||||
{"id": "agent-up", "data": {"type": "agent", "version": "2", "title": "Upstream Agent"}},
|
||||
{"id": "agent-target", "data": {"type": "agent", "version": "2", "title": "Target Agent"}},
|
||||
{"id": "end", "data": {"type": "end", "title": "END"}},
|
||||
],
|
||||
"edges": [
|
||||
{"source": "start-1", "target": "llm-1"},
|
||||
{"source": "llm-1", "target": "agent-up"},
|
||||
{"source": "agent-up", "target": "agent-target"},
|
||||
{"source": "agent-target", "target": "end"},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _declared_loader(nid: str) -> list[DeclaredOutputConfig] | None:
|
||||
if nid == "agent-up":
|
||||
return [DeclaredOutputConfig(name="summary", type=DeclaredOutputType.STRING)]
|
||||
return None
|
||||
|
||||
|
||||
def _draft_vars(nid: str) -> list[tuple[str, str | None]]:
|
||||
if nid == "llm-1":
|
||||
return [("text", "string")]
|
||||
return []
|
||||
|
||||
|
||||
def _collect(node_id: str, *, system_vars=()):
|
||||
entries, truncated = previous_node_output_candidates(
|
||||
graph=_GRAPH,
|
||||
node_id=node_id,
|
||||
declared_outputs_loader=_declared_loader,
|
||||
draft_variables_loader=_draft_vars,
|
||||
system_variables_loader=lambda: list(system_vars),
|
||||
)
|
||||
return entries, truncated
|
||||
|
||||
|
||||
def test_upstream_outputs_follow_inspector_semantics():
|
||||
entries, truncated = _collect("agent-target", system_vars=[("query", "string")])
|
||||
|
||||
assert truncated is False
|
||||
by_node = {}
|
||||
for entry in entries:
|
||||
by_node.setdefault(entry["node_id"], []).append(entry)
|
||||
|
||||
# sys vars ride as a pseudo node, run-derived
|
||||
assert by_node["sys"][0]["selector"] == ["sys", "query"]
|
||||
assert by_node["sys"][0]["inferred"] is True
|
||||
# start variables are static graph facts
|
||||
start = by_node["start-1"][0]
|
||||
assert start["selector"] == ["start-1", "tenders"]
|
||||
assert start["name"] == "START/tenders"
|
||||
assert start["inferred"] is False
|
||||
assert start["value_type"] == "file-list"
|
||||
# agent v2 upstream node uses its declared outputs
|
||||
agent = by_node["agent-up"][0]
|
||||
assert agent["output"] == "summary"
|
||||
assert agent["value_type"] == "string"
|
||||
assert agent["inferred"] is False
|
||||
# other kinds fall back to draft variables (inferred)
|
||||
llm = by_node["llm-1"][0]
|
||||
assert llm["output"] == "text"
|
||||
assert llm["inferred"] is True
|
||||
# the target node itself and downstream nodes never appear
|
||||
assert "agent-target" not in by_node
|
||||
assert "end" not in by_node
|
||||
|
||||
|
||||
def test_results_differ_per_node_id():
|
||||
entries_target, _ = _collect("agent-target")
|
||||
entries_llm, _ = _collect("llm-1")
|
||||
|
||||
assert {e["node_id"] for e in entries_target} == {"start-1", "llm-1", "agent-up"}
|
||||
assert {e["node_id"] for e in entries_llm} == {"start-1"}
|
||||
|
||||
|
||||
def test_previous_outputs_capped_and_flagged():
|
||||
graph = {
|
||||
"nodes": [{"id": "start-1", "data": {"type": "start", "title": "S", "variables": []}}, {"id": "t"}],
|
||||
"edges": [{"source": "start-1", "target": "t"}],
|
||||
}
|
||||
many: list[tuple[str, str | None]] = [(f"v{i}", "string") for i in range(MAX_CANDIDATES_PER_LIST + 5)]
|
||||
entries, truncated = previous_node_output_candidates(
|
||||
graph=graph,
|
||||
node_id="t",
|
||||
declared_outputs_loader=lambda nid: None,
|
||||
draft_variables_loader=lambda nid: [],
|
||||
system_variables_loader=lambda: many,
|
||||
)
|
||||
assert len(entries) == MAX_CANDIDATES_PER_LIST
|
||||
assert truncated is True
|
||||
|
||||
|
||||
def _soul() -> AgentSoulConfig:
|
||||
return AgentSoulConfig.model_validate(
|
||||
{
|
||||
"skills_files": {
|
||||
"skills": [{"id": "sk-1", "name": "tender-analyzer"}],
|
||||
"files": [{"id": "f-1", "name": "qna_report.pdf"}],
|
||||
},
|
||||
"tools": {
|
||||
"cli_tools": [{"name": "ffmpeg"}, {"name": "disabled-one", "enabled": False}],
|
||||
},
|
||||
"knowledge": {"datasets": [{"id": "ds-1", "name": "旧名"}, {"id": "ds-gone", "name": "已删"}]},
|
||||
"human": {"contacts": [{"id": "c-1", "name": "David Hayes", "channel": "email"}]},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_soul_candidates_lists_configured_items_only():
|
||||
lists, truncated = soul_candidates(
|
||||
agent_soul=_soul(),
|
||||
dataset_lookup=lambda ids: {"ds-1": SimpleNamespace(name="产品手册", description="desc")},
|
||||
workspace_tools_loader=lambda: [
|
||||
{"id": "tavily/tavily_search", "name": "tavily_search", "provider": "tavily", "plugin_id": "lg/tavily"}
|
||||
],
|
||||
)
|
||||
|
||||
assert truncated is False
|
||||
assert [item["kind"] for item in lists["skills_files"]] == ["skill", "file"]
|
||||
assert [item["name"] for item in lists["cli_tools"]] == ["ffmpeg"]
|
||||
# enriched from DB; dangling dataset kept with missing flag (placeholder, 0522)
|
||||
knowledge = {item["id"]: item for item in lists["knowledge_datasets"]}
|
||||
assert knowledge["ds-1"]["name"] == "产品手册"
|
||||
assert knowledge["ds-1"]["missing"] is False
|
||||
assert knowledge["ds-gone"]["missing"] is True
|
||||
assert knowledge["ds-gone"]["name"] == "已删"
|
||||
assert lists["human_contacts"][0]["id"] == "c-1"
|
||||
assert lists["dify_tools"][0]["id"] == "tavily/tavily_search"
|
||||
|
||||
|
||||
def test_candidates_response_preserves_skill_and_file_candidate_shapes():
|
||||
response = AgentComposerCandidatesResponse.model_validate(
|
||||
{
|
||||
"variant": "agent_app",
|
||||
"allowed_node_job_candidates": {},
|
||||
"allowed_soul_candidates": {
|
||||
"skills_files": [
|
||||
{"kind": "skill", "id": "sk-1", "name": "tender-analyzer", "path": "skills/tender.md"},
|
||||
{
|
||||
"kind": "file",
|
||||
"id": "f-1",
|
||||
"name": "qna_report.pdf",
|
||||
"transfer_method": "local_file",
|
||||
"reference": "upload-1",
|
||||
"url": "https://files.example/qna_report.pdf",
|
||||
},
|
||||
]
|
||||
},
|
||||
"capabilities": {"human_roster_available": False},
|
||||
}
|
||||
).model_dump(mode="json")
|
||||
|
||||
skill, file = response["allowed_soul_candidates"]["skills_files"]
|
||||
assert skill["kind"] == "skill"
|
||||
assert skill["path"] == "skills/tender.md"
|
||||
assert file["kind"] == "file"
|
||||
assert file["transfer_method"] == "local_file"
|
||||
assert file["reference"] == "upload-1"
|
||||
assert file["url"] == "https://files.example/qna_report.pdf"
|
||||
|
||||
|
||||
def test_soul_candidates_empty_config_yields_empty_lists():
|
||||
lists, truncated = soul_candidates(
|
||||
agent_soul=None,
|
||||
dataset_lookup=lambda ids: {},
|
||||
workspace_tools_loader=lambda: [],
|
||||
)
|
||||
assert truncated is False
|
||||
assert all(value == [] for value in lists.values())
|
||||
|
||||
|
||||
def test_soul_candidates_caps_lists():
|
||||
lists, truncated = soul_candidates(
|
||||
agent_soul=None,
|
||||
dataset_lookup=lambda ids: {},
|
||||
workspace_tools_loader=lambda: [{"id": str(i)} for i in range(MAX_CANDIDATES_PER_LIST + 1)],
|
||||
)
|
||||
assert len(lists["dify_tools"]) == MAX_CANDIDATES_PER_LIST
|
||||
assert truncated is True
|
||||
@ -1,188 +0,0 @@
|
||||
"""Composer save/validate mention rules (ENG-616 §2.4 allowlists)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from services.agent.composer_validator import ComposerConfigValidator
|
||||
from services.agent.errors import InvalidComposerConfigError
|
||||
from services.entities.agent_entities import ComposerSavePayload
|
||||
|
||||
|
||||
def _soul_payload(system_prompt: str) -> ComposerSavePayload:
|
||||
return ComposerSavePayload.model_validate(
|
||||
{
|
||||
"variant": "agent_app",
|
||||
"agent_soul": {"prompt": {"system_prompt": system_prompt}},
|
||||
"save_strategy": "save_to_current_version",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _node_job_payload(workflow_prompt: str) -> ComposerSavePayload:
|
||||
return ComposerSavePayload.model_validate(
|
||||
{
|
||||
"variant": "workflow",
|
||||
"node_job": {"workflow_prompt": workflow_prompt},
|
||||
"save_strategy": "node_job_only",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_soul_prompt_accepts_soul_kinds():
|
||||
payload = _soul_payload("Use [§skill:s1§] [§file:f1§] [§tool:p/t§] [§cli_tool:c§] [§knowledge:k1§] [§human:h1§]")
|
||||
ComposerConfigValidator.validate_save_payload(payload)
|
||||
|
||||
|
||||
def test_soul_prompt_rejects_node_output_mention():
|
||||
with pytest.raises(InvalidComposerConfigError, match="mention_kind_not_allowed"):
|
||||
ComposerConfigValidator.validate_save_payload(_soul_payload("Read [§node_output:n1.text§]"))
|
||||
|
||||
|
||||
def test_soul_prompt_rejects_declared_output_mention():
|
||||
with pytest.raises(InvalidComposerConfigError, match="mention_kind_not_allowed"):
|
||||
ComposerConfigValidator.validate_save_payload(_soul_payload("Produce [§output:report§]"))
|
||||
|
||||
|
||||
def test_node_job_prompt_accepts_job_kinds():
|
||||
payload = _node_job_payload("Read [§node_output:n1.text:START/text§], produce [§output:report§], ask [§human:h1§]")
|
||||
ComposerConfigValidator.validate_save_payload(payload)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("token", ["[§skill:s1§]", "[§tool:p/t§]", "[§cli_tool:c§]", "[§knowledge:k1§]"])
|
||||
def test_node_job_prompt_rejects_soul_only_kinds(token: str):
|
||||
with pytest.raises(InvalidComposerConfigError, match="mention_kind_not_allowed"):
|
||||
ComposerConfigValidator.validate_save_payload(_node_job_payload(f"Use {token}"))
|
||||
|
||||
|
||||
def test_mention_limit_enforced():
|
||||
prompt = " ".join(f"[§human:h{i}§]" for i in range(201))
|
||||
with pytest.raises(InvalidComposerConfigError, match="mention_limit_exceeded"):
|
||||
ComposerConfigValidator.validate_save_payload(_soul_payload(prompt))
|
||||
|
||||
|
||||
def test_prompt_without_mentions_still_passes():
|
||||
ComposerConfigValidator.validate_save_payload(_soul_payload("plain prompt, {{var}} and {{#context#}} untouched"))
|
||||
|
||||
|
||||
# ── ENG-617: human must be referenced (hard) ─────────────────────────────────
|
||||
|
||||
|
||||
def _soul_payload_with_human(system_prompt: str) -> ComposerSavePayload:
|
||||
return ComposerSavePayload.model_validate(
|
||||
{
|
||||
"variant": "agent_app",
|
||||
"agent_soul": {
|
||||
"prompt": {"system_prompt": system_prompt},
|
||||
"human": {
|
||||
"contacts": [{"id": "c-1", "name": "David Hayes", "email": "david@acme.com", "channel": "email"}]
|
||||
},
|
||||
},
|
||||
"save_strategy": "save_to_current_version",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_configured_human_without_mention_is_rejected():
|
||||
with pytest.raises(InvalidComposerConfigError, match="human_involvement_not_referenced"):
|
||||
ComposerConfigValidator.validate_save_payload(_soul_payload_with_human("no human reference here"))
|
||||
|
||||
|
||||
def test_configured_human_referenced_by_id_passes():
|
||||
ComposerConfigValidator.validate_save_payload(_soul_payload_with_human("ask [§human:c-1§] when unsure"))
|
||||
|
||||
|
||||
def test_configured_human_referenced_by_email_alias_passes():
|
||||
ComposerConfigValidator.validate_save_payload(_soul_payload_with_human("ask [§human:david@acme.com§]"))
|
||||
|
||||
|
||||
def test_node_job_human_must_be_referenced_too():
|
||||
payload = ComposerSavePayload.model_validate(
|
||||
{
|
||||
"variant": "workflow",
|
||||
"node_job": {
|
||||
"workflow_prompt": "do the work",
|
||||
"human_contacts": [{"id": "c-2", "name": "Reviewer"}],
|
||||
},
|
||||
"save_strategy": "node_job_only",
|
||||
}
|
||||
)
|
||||
with pytest.raises(InvalidComposerConfigError, match="human_involvement_not_referenced"):
|
||||
ComposerConfigValidator.validate_save_payload(payload)
|
||||
|
||||
payload.node_job.workflow_prompt = "escalate to [§human:c-2§]"
|
||||
ComposerConfigValidator.validate_save_payload(payload)
|
||||
|
||||
|
||||
def test_identity_less_human_contact_is_skipped():
|
||||
payload = ComposerSavePayload.model_validate(
|
||||
{
|
||||
"variant": "agent_app",
|
||||
"agent_soul": {
|
||||
"prompt": {"system_prompt": "plain"},
|
||||
"human": {"contacts": [{"channel": "email"}]},
|
||||
},
|
||||
"save_strategy": "save_to_current_version",
|
||||
}
|
||||
)
|
||||
ComposerConfigValidator.validate_save_payload(payload)
|
||||
|
||||
|
||||
# ── ENG-617: soft findings ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _findings(payload: ComposerSavePayload, **kwargs):
|
||||
return ComposerConfigValidator.collect_soft_findings(payload, **kwargs)
|
||||
|
||||
|
||||
def test_dangling_knowledge_mention_becomes_placeholder_with_label():
|
||||
payload = _soul_payload("ground in [§knowledge:gone-1:旧产品手册§]")
|
||||
findings = _findings(payload)
|
||||
assert findings["knowledge_retrieval_placeholder"] == [{"id": "gone-1", "placeholder_name": "旧产品手册"}]
|
||||
assert findings["warnings"] == []
|
||||
|
||||
|
||||
def test_dangling_knowledge_without_label_gets_fallback_name():
|
||||
findings = _findings(_soul_payload("see [§knowledge:deadbeef-cafe§]"))
|
||||
assert findings["knowledge_retrieval_placeholder"] == [
|
||||
{"id": "deadbeef-cafe", "placeholder_name": "Knowledge deadbeef"}
|
||||
]
|
||||
|
||||
|
||||
def test_configured_but_deleted_dataset_surfaces_as_placeholder():
|
||||
payload = ComposerSavePayload.model_validate(
|
||||
{
|
||||
"variant": "agent_app",
|
||||
"agent_soul": {
|
||||
"prompt": {"system_prompt": "see [§knowledge:ds-1:产品手册§]"},
|
||||
"knowledge": {"datasets": [{"id": "ds-1", "name": "产品手册"}]},
|
||||
},
|
||||
"save_strategy": "save_to_current_version",
|
||||
}
|
||||
)
|
||||
# configured + DB row exists -> clean
|
||||
assert _findings(payload, existing_dataset_ids={"ds-1"})["knowledge_retrieval_placeholder"] == []
|
||||
# configured but deleted in DB -> placeholder
|
||||
assert _findings(payload, existing_dataset_ids=set())["knowledge_retrieval_placeholder"] == [
|
||||
{"id": "ds-1", "placeholder_name": "产品手册"}
|
||||
]
|
||||
|
||||
|
||||
def test_unresolved_non_knowledge_mentions_warn_target_missing():
|
||||
findings = _findings(_soul_payload("use [§skill:nope:Ghost Skill§] and [§human:missing§]"))
|
||||
codes = [(w["code"], w["kind"]) for w in findings["warnings"]]
|
||||
assert ("mention_target_missing", "skill") in codes
|
||||
assert ("mention_target_missing", "human") in codes
|
||||
assert findings["knowledge_retrieval_placeholder"] == []
|
||||
|
||||
|
||||
def test_malformed_marker_warns_but_does_not_block():
|
||||
payload = _soul_payload("hello [§wat:x:y§] world")
|
||||
ComposerConfigValidator.validate_save_payload(payload) # no hard error
|
||||
findings = _findings(payload)
|
||||
assert [w["code"] for w in findings["warnings"]] == ["mention_malformed"]
|
||||
|
||||
|
||||
def test_clean_prompt_yields_empty_findings():
|
||||
findings = _findings(_soul_payload("plain prompt with {{#context#}} legacy form"))
|
||||
assert findings == {"warnings": [], "knowledge_retrieval_placeholder": []}
|
||||
@ -1,179 +0,0 @@
|
||||
"""Unit tests for the prompt mention contract (ENG-616).
|
||||
|
||||
Token form: ``[§<kind>:<id>[:<label>]§]``. Mentions are pointers into the Agent
|
||||
config lists; expansion replaces them with canonical names and the scrub pass
|
||||
guarantees no mention-shaped marker survives to the model.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from models.agent_config_entities import AgentSoulConfig, WorkflowNodeJobConfig
|
||||
from services.agent.prompt_mentions import (
|
||||
MAX_MENTION_FIELD_LENGTH,
|
||||
NODE_JOB_PROMPT_ALLOWED_KINDS,
|
||||
SOUL_PROMPT_ALLOWED_KINDS,
|
||||
MentionKind,
|
||||
build_node_job_mention_resolver,
|
||||
build_soul_mention_resolver,
|
||||
expand_prompt_mentions,
|
||||
parse_prompt_mentions,
|
||||
scrub_mention_markers,
|
||||
)
|
||||
|
||||
# ── parse ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_parse_extracts_kind_id_and_optional_label():
|
||||
prompt = "Use [§skill:abc-1:tender-analyzer§] then ask [§human:c-1§]."
|
||||
mentions = parse_prompt_mentions(prompt)
|
||||
|
||||
assert [(m.kind, m.ref_id, m.label) for m in mentions] == [
|
||||
(MentionKind.SKILL, "abc-1", "tender-analyzer"),
|
||||
(MentionKind.HUMAN, "c-1", None),
|
||||
]
|
||||
assert prompt[mentions[0].start : mentions[0].end] == mentions[0].raw
|
||||
|
||||
|
||||
def test_parse_supports_ids_with_slash_and_dot():
|
||||
mentions = parse_prompt_mentions("[§tool:langgenius/tavily/tavily_search:tavily§] [§node_output:node-1.tenders§]")
|
||||
assert mentions[0].ref_id == "langgenius/tavily/tavily_search"
|
||||
assert mentions[1].ref_id == "node-1.tenders"
|
||||
|
||||
|
||||
def test_parse_ignores_legacy_template_forms_and_unknown_kinds():
|
||||
prompt = "{{var}} {{#context#}} {{#sys.query#}} [§bogus_kind:x§]"
|
||||
assert parse_prompt_mentions(prompt) == []
|
||||
|
||||
|
||||
def test_parse_skips_oversized_id_or_label():
|
||||
long_id = "x" * (MAX_MENTION_FIELD_LENGTH + 1)
|
||||
assert parse_prompt_mentions(f"[§skill:{long_id}§]") == []
|
||||
|
||||
|
||||
# ── expand + scrub ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_expand_uses_resolver_and_degrades_unresolved_to_label_then_id():
|
||||
prompt = "A [§skill:s1:Skill One§] B [§human:h1:EMAIL · DAVE§] C [§knowledge:k1§]"
|
||||
|
||||
def resolver(mention):
|
||||
return "resolved-skill" if mention.kind == MentionKind.SKILL else None
|
||||
|
||||
expanded = expand_prompt_mentions(prompt, resolver)
|
||||
assert expanded == "A resolved-skill B EMAIL · DAVE C k1"
|
||||
assert "[§" not in expanded
|
||||
|
||||
|
||||
def test_expand_scrubs_unknown_kind_tokens_but_keeps_legacy_forms():
|
||||
prompt = "x [§wat:id-1:Label§] y {{#context#}} z {{#node.var#}}"
|
||||
expanded = expand_prompt_mentions(prompt, lambda m: None)
|
||||
# unknown mention-shaped token degraded to its label; legacy forms untouched
|
||||
assert expanded == "x Label y {{#context#}} z {{#node.var#}}"
|
||||
|
||||
|
||||
def test_scrub_degrades_colon_tokens_without_label_to_id_part():
|
||||
assert scrub_mention_markers("see [§weird_kind:some-id§]") == "see some-id"
|
||||
|
||||
|
||||
def test_expand_empty_prompt_is_noop():
|
||||
assert expand_prompt_mentions("", lambda m: "x") == ""
|
||||
|
||||
|
||||
# ── soul resolver ─────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def soul() -> AgentSoulConfig:
|
||||
return AgentSoulConfig.model_validate(
|
||||
{
|
||||
"skills_files": {
|
||||
"skills": [{"id": "sk-1", "name": "tender-analyzer"}],
|
||||
"files": [{"id": "f-1", "name": "qna_report.pdf"}],
|
||||
},
|
||||
"tools": {
|
||||
"dify_tools": [
|
||||
{
|
||||
"plugin_id": "langgenius/tavily",
|
||||
"provider": "tavily",
|
||||
"tool_name": "tavily_search",
|
||||
"credential_type": "unauthorized",
|
||||
},
|
||||
],
|
||||
"cli_tools": [{"name": "ffmpeg"}],
|
||||
},
|
||||
"knowledge": {"datasets": [{"id": "ds-1", "name": "产品手册"}]},
|
||||
"human": {"contacts": [{"id": "c-1", "name": "David Hayes", "channel": "email"}]},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_soul_resolver_resolves_each_kind(soul: AgentSoulConfig):
|
||||
resolver = build_soul_mention_resolver(soul)
|
||||
prompt = (
|
||||
"Use [§skill:sk-1§] with [§file:f-1§], search via "
|
||||
"[§tool:tavily/tavily_search:tavily§], run [§cli_tool:ffmpeg§], "
|
||||
"ground in [§knowledge:ds-1§], ask [§human:c-1§]."
|
||||
)
|
||||
|
||||
expanded = expand_prompt_mentions(prompt, resolver)
|
||||
|
||||
assert expanded == (
|
||||
"Use tender-analyzer with qna_report.pdf, search via tavily_search, "
|
||||
"run ffmpeg, ground in 产品手册, ask EMAIL · David Hayes."
|
||||
)
|
||||
|
||||
|
||||
def test_soul_resolver_unknown_ids_degrade(soul: AgentSoulConfig):
|
||||
expanded = expand_prompt_mentions("[§knowledge:missing:旧产品手册§]", build_soul_mention_resolver(soul))
|
||||
assert expanded == "旧产品手册"
|
||||
|
||||
|
||||
# ── node-job resolver ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def node_job() -> WorkflowNodeJobConfig:
|
||||
return WorkflowNodeJobConfig.model_validate(
|
||||
{
|
||||
"workflow_prompt": "",
|
||||
"previous_node_output_refs": [{"selector": ["start-1", "tenders"], "name": "START/tenders"}],
|
||||
# declared output names are JSON-schema-friendly identifiers (no dots)
|
||||
"declared_outputs": [{"name": "qna_report", "type": "file"}],
|
||||
"human_contacts": [{"id": "c-1", "name": "David Hayes", "channel": "email"}],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_node_job_resolver_resolves_each_kind(node_job: WorkflowNodeJobConfig):
|
||||
resolver = build_node_job_mention_resolver(node_job)
|
||||
prompt = "Read [§node_output:start-1.tenders§] and produce [§output:qna_report§]; if unsure contact [§human:c-1§]."
|
||||
|
||||
expanded = expand_prompt_mentions(prompt, resolver)
|
||||
|
||||
assert expanded == ("Read START/tenders and produce qna_report (file); if unsure contact EMAIL · David Hayes.")
|
||||
|
||||
|
||||
def test_node_job_resolver_matches_ref_by_node_id_and_output_fields():
|
||||
node_job = WorkflowNodeJobConfig.model_validate(
|
||||
{"previous_node_output_refs": [{"node_id": "n-2", "output": "text"}]}
|
||||
)
|
||||
expanded = expand_prompt_mentions("[§node_output:n-2.text:LLM/text§]", build_node_job_mention_resolver(node_job))
|
||||
# ref has no display name -> degrade to the mention label
|
||||
assert expanded == "LLM/text"
|
||||
|
||||
|
||||
# ── allowlists ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_per_surface_allowlists_match_design():
|
||||
assert {
|
||||
MentionKind.SKILL,
|
||||
MentionKind.FILE,
|
||||
MentionKind.TOOL,
|
||||
MentionKind.CLI_TOOL,
|
||||
MentionKind.KNOWLEDGE,
|
||||
MentionKind.HUMAN,
|
||||
} == SOUL_PROMPT_ALLOWED_KINDS
|
||||
assert {MentionKind.NODE_OUTPUT, MentionKind.OUTPUT, MentionKind.HUMAN} == NODE_JOB_PROMPT_ALLOWED_KINDS
|
||||
@ -1,4 +1,3 @@
|
||||
import type { ErrorBody } from '@dify/contracts/api/openapi/types.gen'
|
||||
import type { ErrorCodeValue, ExitCodeValue } from './codes'
|
||||
import type { ErrorEnvelope, PrintableError } from './format'
|
||||
import { ErrorCode, exitFor } from './codes'
|
||||
@ -84,7 +83,6 @@ type HttpClientErrorOptions = BaseErrorOptions & {
|
||||
readonly method?: string
|
||||
readonly url?: string
|
||||
readonly rawResponse?: string
|
||||
readonly serverError?: ErrorBody
|
||||
}
|
||||
|
||||
export class HttpClientError extends BaseError {
|
||||
@ -92,7 +90,6 @@ export class HttpClientError extends BaseError {
|
||||
readonly method?: string
|
||||
readonly url?: string
|
||||
readonly rawResponse?: string
|
||||
readonly serverError?: ErrorBody
|
||||
|
||||
constructor(opts: HttpClientErrorOptions) {
|
||||
super(opts)
|
||||
@ -100,7 +97,6 @@ export class HttpClientError extends BaseError {
|
||||
this.method = opts.method
|
||||
this.url = opts.url
|
||||
this.rawResponse = opts.rawResponse
|
||||
this.serverError = opts.serverError
|
||||
}
|
||||
|
||||
override toEnvelope(): ErrorEnvelope {
|
||||
@ -113,8 +109,6 @@ export class HttpClientError extends BaseError {
|
||||
envelope.error.url = this.url
|
||||
if (this.rawResponse !== undefined)
|
||||
envelope.error.raw_response = this.rawResponse
|
||||
if (this.serverError !== undefined)
|
||||
envelope.error.server = this.serverError
|
||||
return envelope
|
||||
}
|
||||
|
||||
@ -125,7 +119,6 @@ export class HttpClientError extends BaseError {
|
||||
method: this.method,
|
||||
url: this.url,
|
||||
rawResponse: this.rawResponse,
|
||||
serverError: this.serverError,
|
||||
}
|
||||
}
|
||||
|
||||
@ -152,8 +145,4 @@ export class HttpClientError extends BaseError {
|
||||
}
|
||||
return new HttpClientError({ ...this.snapshot(), rawResponse })
|
||||
}
|
||||
|
||||
withServerError(serverError: ErrorBody): HttpClientError {
|
||||
return new HttpClientError({ ...this.snapshot(), serverError })
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,101 +0,0 @@
|
||||
import type { ErrorBody } from '@dify/contracts/api/openapi/types.gen'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { HttpClientError } from './base'
|
||||
import { ErrorCode } from './codes'
|
||||
import { formatErrorForCli } from './format'
|
||||
|
||||
type ValidationErrorOverrides = {
|
||||
readonly cliHint?: string
|
||||
readonly serverHint?: string
|
||||
readonly details?: ErrorBody['details']
|
||||
}
|
||||
|
||||
function validationError(overrides: ValidationErrorOverrides = {}): HttpClientError {
|
||||
const details
|
||||
= overrides.details
|
||||
?? [
|
||||
{ type: 'int_parsing', loc: ['page'], msg: 'must be >= 1' },
|
||||
{ type: 'missing', loc: ['inputs', 'query'], msg: 'field required' },
|
||||
]
|
||||
return new HttpClientError({
|
||||
code: ErrorCode.Server4xxOther,
|
||||
message: 'Request validation failed',
|
||||
httpStatus: 422,
|
||||
hint: overrides.cliHint,
|
||||
serverError: {
|
||||
code: 'invalid_param',
|
||||
message: 'Request validation failed',
|
||||
status: 422,
|
||||
hint: overrides.serverHint,
|
||||
details,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
describe('formatErrorForCli — human', () => {
|
||||
it('prints server code, message, and details without verbose', () => {
|
||||
const out = formatErrorForCli(validationError({ serverHint: 'check the page parameter' }), { isErrTTY: false })
|
||||
|
||||
expect(out).toContain('invalid_param: Request validation failed')
|
||||
expect(out).toContain('- page: must be >= 1 (int_parsing)')
|
||||
expect(out).toContain('- inputs.query: field required (missing)')
|
||||
expect(out).toContain('check the page parameter')
|
||||
expect(out).not.toContain('raw_response')
|
||||
})
|
||||
|
||||
it('falls back to cli code when no server code', () => {
|
||||
const err = new HttpClientError({ code: ErrorCode.Server5xx, message: 'server error (HTTP 502)', httpStatus: 502 })
|
||||
|
||||
const out = formatErrorForCli(err, { isErrTTY: false })
|
||||
|
||||
expect(out).toContain('server_5xx: server error (HTTP 502)')
|
||||
})
|
||||
|
||||
it('server hint wins over cli hint; cli hint fills when server sent none', () => {
|
||||
const withCliHint = validationError({ cliHint: 'cli fallback hint', serverHint: 'check the page parameter', details: [] })
|
||||
expect(formatErrorForCli(withCliHint, { isErrTTY: false })).toContain('check the page parameter')
|
||||
expect(formatErrorForCli(withCliHint, { isErrTTY: false })).not.toContain('cli fallback hint')
|
||||
|
||||
// no server hint → cli hint shown
|
||||
const noServerHint = new HttpClientError({
|
||||
code: ErrorCode.AuthExpired,
|
||||
message: 'session expired',
|
||||
hint: 'run difyctl auth login',
|
||||
})
|
||||
expect(formatErrorForCli(noServerHint, { isErrTTY: false })).toContain('run difyctl auth login')
|
||||
})
|
||||
|
||||
it('omits the loc prefix when a detail has no loc', () => {
|
||||
const out = formatErrorForCli(
|
||||
validationError({ details: [{ type: 'invalid', loc: [], msg: 'body required' }] }),
|
||||
{ isErrTTY: false },
|
||||
)
|
||||
|
||||
expect(out).toContain('- body required (invalid)')
|
||||
expect(out).not.toContain('- : body required')
|
||||
})
|
||||
|
||||
it('renders request and http_status lines', () => {
|
||||
const err = new HttpClientError({
|
||||
code: ErrorCode.Server5xx,
|
||||
message: 'upstream boom',
|
||||
httpStatus: 502,
|
||||
method: 'GET',
|
||||
url: 'https://api.dify.ai/v1/me',
|
||||
})
|
||||
const out = formatErrorForCli(err, { isErrTTY: false })
|
||||
expect(out).toContain('request: GET https://api.dify.ai/v1/me')
|
||||
expect(out).toContain('http_status: 502')
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatErrorForCli — json', () => {
|
||||
it('envelope nests the whole server error', () => {
|
||||
const out = JSON.parse(formatErrorForCli(validationError(), { format: 'json' }))
|
||||
|
||||
expect(out.error.server.code).toBe('invalid_param')
|
||||
expect(out.error.server.details).toHaveLength(2)
|
||||
expect(out.error.code).toBe('server_4xx_other')
|
||||
})
|
||||
})
|
||||
@ -1,4 +1,3 @@
|
||||
import type { ErrorBody } from '@dify/contracts/api/openapi/types.gen'
|
||||
import { isVerbose } from '@/framework/context'
|
||||
import { redactBearer } from '@/http/sanitize'
|
||||
import { colorEnabled, colorScheme } from '@/sys/io/color'
|
||||
@ -17,7 +16,6 @@ export type ErrorEnvelope = {
|
||||
method?: string
|
||||
url?: string
|
||||
raw_response?: string
|
||||
server?: ErrorBody
|
||||
}
|
||||
}
|
||||
|
||||
@ -47,16 +45,9 @@ function renderEnvelope(env: ErrorEnvelope): string {
|
||||
function renderHuman(env: ErrorEnvelope, isErrTTY: boolean): string {
|
||||
const cs = colorScheme(colorEnabled(isErrTTY))
|
||||
const e = env.error
|
||||
const server = e.server
|
||||
const headerCode = server?.code ?? e.code
|
||||
const lines: string[] = [`${headerCode}: ${e.message}`]
|
||||
for (const d of server?.details ?? []) {
|
||||
const loc = (d.loc ?? []).join('.')
|
||||
lines.push(` - ${loc ? `${loc}: ` : ''}${d.msg} (${d.type})`)
|
||||
}
|
||||
const hint = server?.hint ?? e.hint
|
||||
if (hint !== undefined && hint !== null)
|
||||
lines.push(`${cs.magenta('hint:')} ${cs.cyan(hint)}`)
|
||||
const lines: string[] = [`${e.code}: ${e.message}`]
|
||||
if (e.hint !== undefined)
|
||||
lines.push(`${cs.magenta('hint:')} ${cs.cyan(e.hint)}`)
|
||||
if (e.method !== undefined && e.url !== undefined)
|
||||
lines.push(`request: ${e.method} ${e.url}`)
|
||||
if (e.http_status !== undefined)
|
||||
|
||||
@ -1,73 +0,0 @@
|
||||
import type { HttpClientError } from '@/errors/base'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { ErrorCode } from '@/errors/codes'
|
||||
import { classifyResponse } from './error-mapper'
|
||||
|
||||
function res(status: number, body: unknown): Response {
|
||||
return new Response(typeof body === 'string' ? body : JSON.stringify(body), {
|
||||
status,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
})
|
||||
}
|
||||
|
||||
const req = new Request('https://dify.test/openapi/v1/apps')
|
||||
|
||||
function classified(status: number, body: unknown): Promise<HttpClientError> {
|
||||
return classifyResponse(req, res(status, body))
|
||||
}
|
||||
|
||||
describe('classifyResponse — canonical ErrorBody', () => {
|
||||
it('attaches the parsed body whole as serverError', async () => {
|
||||
const body = {
|
||||
code: 'invalid_param',
|
||||
message: 'Request validation failed',
|
||||
status: 422,
|
||||
hint: 'check the page parameter',
|
||||
details: [{ type: 'int_parsing', loc: ['page'], msg: 'must be >= 1' }],
|
||||
}
|
||||
|
||||
const err = await classified(422, body)
|
||||
|
||||
expect(err.serverError).toEqual(body)
|
||||
expect(err.message).toBe('Request validation failed')
|
||||
expect(err.code).toBe(ErrorCode.Server4xxOther)
|
||||
})
|
||||
|
||||
it('401 classifies by status as AuthExpired with CLI login hint', async () => {
|
||||
const err = await classified(401, {
|
||||
code: 'unauthorized',
|
||||
message: 'session expired or revoked',
|
||||
status: 401,
|
||||
})
|
||||
|
||||
expect(err.code).toBe(ErrorCode.AuthExpired)
|
||||
expect(err.hint).toBe('run \'difyctl auth login\' to sign in again')
|
||||
})
|
||||
|
||||
it('unknown future server code is data, not behavior — status bucket decides', async () => {
|
||||
const err = await classified(409, {
|
||||
code: 'some_future_code',
|
||||
message: 'nope',
|
||||
status: 409,
|
||||
})
|
||||
|
||||
expect(err.code).toBe(ErrorCode.Server4xxOther)
|
||||
expect(err.serverError?.code).toBe('some_future_code')
|
||||
})
|
||||
})
|
||||
|
||||
describe('classifyResponse — non-conforming bodies (no fallback by design)', () => {
|
||||
it('non-JSON body yields no serverError, classification by status', async () => {
|
||||
const err = await classified(502, '<html>bad gateway</html>')
|
||||
|
||||
expect(err.code).toBe(ErrorCode.Server5xx)
|
||||
expect(err.serverError).toBeUndefined()
|
||||
})
|
||||
|
||||
it('RFC 8628 string error field yields no serverError and a generic message', async () => {
|
||||
const err = await classified(400, { error: 'slow_down' })
|
||||
|
||||
expect(err.message).toBe('request failed (HTTP 400)')
|
||||
expect(err.serverError).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@ -1,85 +1,70 @@
|
||||
import type { ErrorBody } from '@dify/contracts/api/openapi/types.gen'
|
||||
import type { ErrorCodeValue } from '@/errors/codes'
|
||||
import { zErrorBody } from '@dify/contracts/api/openapi/zod.gen'
|
||||
import { BaseError, HttpClientError, newError } from '@/errors/base'
|
||||
import { ErrorCode } from '@/errors/codes'
|
||||
import { redactBearer } from './sanitize'
|
||||
|
||||
const AUTH_EXPIRED_MESSAGE = 'session expired or revoked'
|
||||
const AUTH_LOGIN_HINT = 'run \'difyctl auth login\' to sign in again'
|
||||
|
||||
// How one HTTP status bucket classifies: CLI code, message fallback when the
|
||||
// body is not a canonical ErrorBody, optional CLI hint, raw-body retention.
|
||||
type StatusClass = {
|
||||
readonly code: ErrorCodeValue
|
||||
readonly fallbackMessage: (status: number) => string
|
||||
readonly hint?: string
|
||||
readonly includeRaw: boolean
|
||||
type WireFields = {
|
||||
code?: string
|
||||
message?: string
|
||||
hint?: string
|
||||
}
|
||||
|
||||
const AUTH_EXPIRED_CLASS: StatusClass = {
|
||||
code: ErrorCode.AuthExpired,
|
||||
fallbackMessage: () => AUTH_EXPIRED_MESSAGE,
|
||||
hint: AUTH_LOGIN_HINT,
|
||||
includeRaw: false,
|
||||
type WireEnvelope = WireFields & {
|
||||
error?: WireFields
|
||||
}
|
||||
|
||||
const SERVER_5XX_CLASS: StatusClass = {
|
||||
code: ErrorCode.Server5xx,
|
||||
fallbackMessage: status => `server error (HTTP ${status})`,
|
||||
includeRaw: true,
|
||||
}
|
||||
|
||||
const SERVER_4XX_CLASS: StatusClass = {
|
||||
code: ErrorCode.Server4xxOther,
|
||||
fallbackMessage: status => `request failed (HTTP ${status})`,
|
||||
includeRaw: true,
|
||||
}
|
||||
|
||||
function statusClass(status: number): StatusClass {
|
||||
if (status === 401)
|
||||
return AUTH_EXPIRED_CLASS
|
||||
if (status >= 500)
|
||||
return SERVER_5XX_CLASS
|
||||
return SERVER_4XX_CLASS
|
||||
}
|
||||
|
||||
function parseServerError(raw: string): ErrorBody | undefined {
|
||||
if (raw === '')
|
||||
return undefined
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = JSON.parse(raw)
|
||||
}
|
||||
catch {
|
||||
return undefined
|
||||
}
|
||||
const result = zErrorBody.safeParse(parsed)
|
||||
return result.success ? result.data : undefined
|
||||
}
|
||||
|
||||
export async function classifyResponse(request: Request, response: Response): Promise<HttpClientError> {
|
||||
async function readBody(response: Response): Promise<{ raw: string, parsed?: WireEnvelope }> {
|
||||
let raw = ''
|
||||
try {
|
||||
raw = await response.clone().text()
|
||||
raw = await response.text()
|
||||
}
|
||||
catch {
|
||||
// ignore read errors; raw stays ''
|
||||
return { raw: '' }
|
||||
}
|
||||
if (raw === '')
|
||||
return { raw }
|
||||
try {
|
||||
return { raw, parsed: JSON.parse(raw) as WireEnvelope }
|
||||
}
|
||||
catch {
|
||||
return { raw }
|
||||
}
|
||||
}
|
||||
|
||||
export async function classifyResponse(request: Request, response: Response): Promise<BaseError> {
|
||||
const { parsed, raw } = await readBody(response.clone())
|
||||
const wire: WireFields = parsed?.error ?? parsed ?? {}
|
||||
const status = response.status
|
||||
const url = redactBearer(response.url || request.url)
|
||||
const method = request.method
|
||||
|
||||
if (status === 401) {
|
||||
return HttpClientError.from(newError(
|
||||
ErrorCode.AuthExpired,
|
||||
wire.message ?? 'session expired or revoked',
|
||||
))
|
||||
.withHint(wire.hint ?? 'run \'difyctl auth login\' to sign in again')
|
||||
.withHttpStatus(status)
|
||||
.withRequest(method, url)
|
||||
}
|
||||
|
||||
const serverError = parseServerError(raw)
|
||||
const status = response.status
|
||||
const c = statusClass(status)
|
||||
return new HttpClientError({
|
||||
code: c.code,
|
||||
message: serverError?.message ?? c.fallbackMessage(status),
|
||||
hint: c.hint,
|
||||
httpStatus: status,
|
||||
method: request.method,
|
||||
url: redactBearer(response.url || request.url),
|
||||
rawResponse: c.includeRaw && raw !== '' ? raw : undefined,
|
||||
serverError,
|
||||
})
|
||||
if (status >= 500) {
|
||||
return HttpClientError.from(newError(
|
||||
ErrorCode.Server5xx,
|
||||
wire.message ?? `server error (HTTP ${status})`,
|
||||
))
|
||||
.withHttpStatus(status)
|
||||
.withRequest(method, url)
|
||||
.withRawResponse(raw)
|
||||
}
|
||||
|
||||
const err = HttpClientError.from(newError(
|
||||
ErrorCode.Server4xxOther,
|
||||
wire.message ?? `request failed (HTTP ${status})`,
|
||||
))
|
||||
.withHttpStatus(status)
|
||||
.withRequest(method, url)
|
||||
.withRawResponse(raw)
|
||||
return wire.hint !== undefined ? err.withHint(wire.hint) : err
|
||||
}
|
||||
|
||||
export function classifyTransportError(err: unknown): BaseError {
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
import type { StubServer } from '@test/fixtures/stub-server'
|
||||
import type { HttpClientError } from '@/errors/base'
|
||||
import { jsonResponder, startStubServer } from '@test/fixtures/stub-server'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { isHttpClientError } from '@/errors/base'
|
||||
@ -34,49 +33,66 @@ describe('createOpenApiClient error mapping', () => {
|
||||
await stub?.stop()
|
||||
})
|
||||
|
||||
async function classifiedError(status: number, body: unknown): Promise<HttpClientError> {
|
||||
stub = await startStubServer(cap => jsonResponder(status, body, cap))
|
||||
it('recovers Dify message + hint from a top-level 4xx envelope', async () => {
|
||||
stub = await startStubServer(cap => jsonResponder(403, { message: 'no access', hint: 'ask an admin' }, cap))
|
||||
const orpc = orpcClient(stub.url)
|
||||
|
||||
const caught = await catchErr(() => orpc.account.get())
|
||||
if (!isHttpClientError(caught))
|
||||
throw new Error(`expected HttpClientError, got: ${String(caught)}`)
|
||||
return caught
|
||||
}
|
||||
|
||||
it('recovers Dify message from a canonical ErrorBody 4xx response', async () => {
|
||||
const caught = await classifiedError(403, { code: 'access_denied', message: 'no access', status: 403 })
|
||||
|
||||
expect(caught.code).toBe(ErrorCode.Server4xxOther)
|
||||
expect(caught.httpStatus).toBe(403)
|
||||
expect(caught.message).toBe('no access')
|
||||
// Parity with the transport path: the migrated endpoint's error keeps the request
|
||||
// method/url and the raw body, so formatted errors still print the `request:` line
|
||||
// and the raw-response dump (not just message/hint).
|
||||
expect(caught.method).toBe('GET')
|
||||
expect(caught.url).toContain('/account')
|
||||
expect(caught.rawResponse).toContain('no access')
|
||||
expect(isHttpClientError(caught)).toBe(true)
|
||||
if (isHttpClientError(caught)) {
|
||||
expect(caught.code).toBe(ErrorCode.Server4xxOther)
|
||||
expect(caught.httpStatus).toBe(403)
|
||||
expect(caught.message).toBe('no access')
|
||||
expect(caught.hint).toBe('ask an admin')
|
||||
// Parity with the transport path: the migrated endpoint's error keeps the request
|
||||
// method/url and the raw body, so formatted errors still print the `request:` line
|
||||
// and the raw-response dump (not just message/hint).
|
||||
expect(caught.method).toBe('GET')
|
||||
expect(caught.url).toContain('/account')
|
||||
expect(caught.rawResponse).toContain('no access')
|
||||
}
|
||||
})
|
||||
|
||||
it('reads server message from canonical ErrorBody on 401 and keeps the auth code', async () => {
|
||||
const caught = await classifiedError(401, { code: 'unauthorized', message: 'expired', status: 401 })
|
||||
it('recovers from a nested { error: { message, hint } } envelope and keeps the auth code on 401', async () => {
|
||||
stub = await startStubServer(cap => jsonResponder(401, { error: { message: 'expired', hint: 'relogin' } }, cap))
|
||||
const orpc = orpcClient(stub.url)
|
||||
|
||||
expect(caught.code).toBe(ErrorCode.AuthExpired)
|
||||
expect(caught.httpStatus).toBe(401)
|
||||
expect(caught.message).toBe('expired')
|
||||
const caught = await catchErr(() => orpc.account.get())
|
||||
|
||||
expect(isHttpClientError(caught)).toBe(true)
|
||||
if (isHttpClientError(caught)) {
|
||||
expect(caught.code).toBe(ErrorCode.AuthExpired)
|
||||
expect(caught.httpStatus).toBe(401)
|
||||
expect(caught.message).toBe('expired')
|
||||
expect(caught.hint).toBe('relogin')
|
||||
}
|
||||
})
|
||||
|
||||
it('uses CLI default auth-login hint for non-conforming 401 body', async () => {
|
||||
const caught = await classifiedError(401, { error: 'expired' })
|
||||
it('falls back to the default auth-login hint when the body carries none', async () => {
|
||||
stub = await startStubServer(cap => jsonResponder(401, { error: 'expired' }, cap))
|
||||
const orpc = orpcClient(stub.url)
|
||||
|
||||
expect(caught.code).toBe(ErrorCode.AuthExpired)
|
||||
expect(caught.hint).toContain('difyctl auth login')
|
||||
const caught = await catchErr(() => orpc.account.get())
|
||||
|
||||
expect(isHttpClientError(caught)).toBe(true)
|
||||
if (isHttpClientError(caught)) {
|
||||
expect(caught.code).toBe(ErrorCode.AuthExpired)
|
||||
expect(caught.hint).toContain('difyctl auth login')
|
||||
}
|
||||
})
|
||||
|
||||
it('maps 5xx to Server5xx with message from canonical ErrorBody', async () => {
|
||||
const caught = await classifiedError(503, { code: 'service_unavailable', message: 'down for maintenance', status: 503 })
|
||||
it('maps 5xx to Server5xx', async () => {
|
||||
stub = await startStubServer(cap => jsonResponder(503, { message: 'down for maintenance' }, cap))
|
||||
const orpc = orpcClient(stub.url)
|
||||
|
||||
expect(caught.code).toBe(ErrorCode.Server5xx)
|
||||
expect(caught.httpStatus).toBe(503)
|
||||
expect(caught.message).toBe('down for maintenance')
|
||||
const caught = await catchErr(() => orpc.account.get())
|
||||
|
||||
expect(isHttpClientError(caught)).toBe(true)
|
||||
if (isHttpClientError(caught)) {
|
||||
expect(caught.code).toBe(ErrorCode.Server5xx)
|
||||
expect(caught.httpStatus).toBe(503)
|
||||
expect(caught.message).toBe('down for maintenance')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@ -255,6 +255,11 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/app-sidebar/nav-link/index.tsx": {
|
||||
"tailwindcss/enforce-consistent-class-order": {
|
||||
"count": 3
|
||||
}
|
||||
},
|
||||
"web/app/components/app/annotation/add-annotation-modal/edit-item/index.tsx": {
|
||||
"erasable-syntax-only/enums": {
|
||||
"count": 1
|
||||
@ -848,7 +853,7 @@
|
||||
},
|
||||
"web/app/components/base/chat/chat/chat-input-area/index.tsx": {
|
||||
"ts/no-explicit-any": {
|
||||
"count": 3
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"web/app/components/base/chat/chat/check-input-forms-hooks.ts": {
|
||||
@ -2361,6 +2366,14 @@
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"web/app/components/explore/try-app/tab.tsx": {
|
||||
"erasable-syntax-only/enums": {
|
||||
"count": 1
|
||||
},
|
||||
"react-refresh/only-export-components": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/goto-anything/actions/commands/command-bus.ts": {
|
||||
"ts/no-explicit-any": {
|
||||
"count": 2
|
||||
@ -3703,6 +3716,17 @@
|
||||
"count": 7
|
||||
}
|
||||
},
|
||||
"web/app/components/workflow/nodes/_base/components/workflow-panel/tab.tsx": {
|
||||
"erasable-syntax-only/enums": {
|
||||
"count": 1
|
||||
},
|
||||
"react-refresh/only-export-components": {
|
||||
"count": 1
|
||||
},
|
||||
"ts/no-explicit-any": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/workflow/nodes/_base/hooks/use-one-step-run.ts": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
|
||||
@ -173,7 +173,6 @@ export type AgentAppComposerResponse = {
|
||||
agent: AgentComposerAgentResponse
|
||||
agent_soul: AgentSoulConfig
|
||||
save_options: Array<ComposerSaveStrategy>
|
||||
validation?: ComposerValidationFindingsResponse
|
||||
variant: string
|
||||
}
|
||||
|
||||
@ -194,15 +193,12 @@ export type AgentComposerCandidatesResponse = {
|
||||
allowed_node_job_candidates?: AgentComposerNodeJobCandidatesResponse
|
||||
allowed_soul_candidates?: AgentComposerSoulCandidatesResponse
|
||||
capabilities?: ComposerCandidateCapabilities
|
||||
truncated?: boolean
|
||||
variant: ComposerVariant
|
||||
}
|
||||
|
||||
export type AgentComposerValidateResponse = {
|
||||
errors?: Array<string>
|
||||
knowledge_retrieval_placeholder?: Array<ComposerKnowledgePlaceholderResponse>
|
||||
result: string
|
||||
warnings?: Array<ComposerValidationWarningResponse>
|
||||
}
|
||||
|
||||
export type AgentAppFeaturesPayload = {
|
||||
@ -801,7 +797,6 @@ export type WorkflowAgentComposerResponse = {
|
||||
node_job: WorkflowNodeJobConfig
|
||||
save_options: Array<ComposerSaveStrategy>
|
||||
soul_lock: AgentComposerSoulLockResponse
|
||||
validation?: ComposerValidationFindingsResponse
|
||||
variant: string
|
||||
workflow_id?: string | null
|
||||
}
|
||||
@ -1104,11 +1099,6 @@ export type ComposerSaveStrategy
|
||||
| 'save_to_current_version'
|
||||
| 'save_to_roster'
|
||||
|
||||
export type ComposerValidationFindingsResponse = {
|
||||
knowledge_retrieval_placeholder?: Array<ComposerKnowledgePlaceholderResponse>
|
||||
warnings?: Array<ComposerValidationWarningResponse>
|
||||
}
|
||||
|
||||
export type ComposerBindingPayload = {
|
||||
agent_id?: string | null
|
||||
binding_type: 'inline_agent' | 'roster_agent'
|
||||
@ -1143,26 +1133,13 @@ export type AgentComposerSoulCandidatesResponse = {
|
||||
dify_tools?: Array<AgentComposerDifyToolCandidateResponse>
|
||||
human_contacts?: Array<AgentHumanContactConfig>
|
||||
knowledge_datasets?: Array<AgentKnowledgeDatasetConfig>
|
||||
skills_files?: Array<unknown>
|
||||
skills_files?: Array<AgentSkillRefConfig>
|
||||
}
|
||||
|
||||
export type ComposerCandidateCapabilities = {
|
||||
human_roster_available?: boolean
|
||||
}
|
||||
|
||||
export type ComposerKnowledgePlaceholderResponse = {
|
||||
id: string
|
||||
placeholder_name: string
|
||||
}
|
||||
|
||||
export type ComposerValidationWarningResponse = {
|
||||
code: string
|
||||
id?: string | null
|
||||
kind?: string | null
|
||||
message?: string | null
|
||||
surface?: string | null
|
||||
}
|
||||
|
||||
export type AgentFeatureToggleConfig = {
|
||||
enabled?: boolean
|
||||
[key: string]: unknown
|
||||
@ -1755,31 +1732,15 @@ export type AgentKnowledgeDatasetConfig = {
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export type AgentComposerSkillCandidateResponse = {
|
||||
export type AgentSkillRefConfig = {
|
||||
description?: string | null
|
||||
file_id?: string | null
|
||||
id?: string | null
|
||||
kind?: string
|
||||
name?: string | null
|
||||
path?: string | null
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export type AgentComposerFileCandidateResponse = {
|
||||
file_id?: string | null
|
||||
id?: string | null
|
||||
kind?: string
|
||||
name?: string | null
|
||||
reference?: string | null
|
||||
remote_url?: string | null
|
||||
tenant_id?: string | null
|
||||
transfer_method?: string | null
|
||||
type?: string | null
|
||||
upload_file_id?: string | null
|
||||
url?: string | null
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export type AgentModerationProviderConfig = {
|
||||
api_based_extension_id?: string | null
|
||||
inputs_config?: AgentModerationIoConfig
|
||||
@ -1980,15 +1941,6 @@ export type AgentFileRefConfig = {
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export type AgentSkillRefConfig = {
|
||||
description?: string | null
|
||||
file_id?: string | null
|
||||
id?: string | null
|
||||
name?: string | null
|
||||
path?: string | null
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export type AgentSoulDifyToolConfig = {
|
||||
credential_ref?: AgentSoulDifyToolCredentialRef
|
||||
credential_type?: 'api-key' | 'oauth2' | 'unauthorized'
|
||||
|
||||
@ -77,6 +77,14 @@ export const zAdvancedChatWorkflowRunPayload = z.object({
|
||||
query: z.string().optional().default(''),
|
||||
})
|
||||
|
||||
/**
|
||||
* AgentComposerValidateResponse
|
||||
*/
|
||||
export const zAgentComposerValidateResponse = z.object({
|
||||
errors: z.array(z.string()).optional(),
|
||||
result: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
* SimpleResultResponse
|
||||
*/
|
||||
@ -794,43 +802,6 @@ export const zComposerCandidateCapabilities = z.object({
|
||||
human_roster_available: z.boolean().optional().default(false),
|
||||
})
|
||||
|
||||
/**
|
||||
* ComposerKnowledgePlaceholderResponse
|
||||
*/
|
||||
export const zComposerKnowledgePlaceholderResponse = z.object({
|
||||
id: z.string(),
|
||||
placeholder_name: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
* ComposerValidationWarningResponse
|
||||
*/
|
||||
export const zComposerValidationWarningResponse = z.object({
|
||||
code: z.string(),
|
||||
id: z.string().nullish(),
|
||||
kind: z.string().nullish(),
|
||||
message: z.string().nullish(),
|
||||
surface: z.string().nullish(),
|
||||
})
|
||||
|
||||
/**
|
||||
* AgentComposerValidateResponse
|
||||
*/
|
||||
export const zAgentComposerValidateResponse = z.object({
|
||||
errors: z.array(z.string()).optional(),
|
||||
knowledge_retrieval_placeholder: z.array(zComposerKnowledgePlaceholderResponse).optional(),
|
||||
result: z.string(),
|
||||
warnings: z.array(zComposerValidationWarningResponse).optional(),
|
||||
})
|
||||
|
||||
/**
|
||||
* ComposerValidationFindingsResponse
|
||||
*/
|
||||
export const zComposerValidationFindingsResponse = z.object({
|
||||
knowledge_retrieval_placeholder: z.array(zComposerKnowledgePlaceholderResponse).optional(),
|
||||
warnings: z.array(zComposerValidationWarningResponse).optional(),
|
||||
})
|
||||
|
||||
/**
|
||||
* AgentFeatureToggleConfig
|
||||
*/
|
||||
@ -1792,34 +1763,16 @@ export const zAgentKnowledgeDatasetConfig = z.object({
|
||||
})
|
||||
|
||||
/**
|
||||
* AgentComposerSkillCandidateResponse
|
||||
* AgentSkillRefConfig
|
||||
*/
|
||||
export const zAgentComposerSkillCandidateResponse = z.object({
|
||||
export const zAgentSkillRefConfig = z.object({
|
||||
description: z.string().nullish(),
|
||||
file_id: z.string().max(255).nullish(),
|
||||
id: z.string().max(255).nullish(),
|
||||
kind: z.string().optional().default('skill'),
|
||||
name: z.string().max(255).nullish(),
|
||||
path: z.string().nullish(),
|
||||
})
|
||||
|
||||
/**
|
||||
* AgentComposerFileCandidateResponse
|
||||
*/
|
||||
export const zAgentComposerFileCandidateResponse = z.object({
|
||||
file_id: z.string().max(255).nullish(),
|
||||
id: z.string().max(255).nullish(),
|
||||
kind: z.string().optional().default('file'),
|
||||
name: z.string().max(255).nullish(),
|
||||
reference: z.string().max(255).nullish(),
|
||||
remote_url: z.string().nullish(),
|
||||
tenant_id: z.string().max(255).nullish(),
|
||||
transfer_method: z.string().max(64).nullish(),
|
||||
type: z.string().max(64).nullish(),
|
||||
upload_file_id: z.string().max(255).nullish(),
|
||||
url: z.string().nullish(),
|
||||
})
|
||||
|
||||
/**
|
||||
* SimpleModelConfig
|
||||
*/
|
||||
@ -2203,25 +2156,6 @@ export const zAgentFileRefConfig = z.object({
|
||||
url: z.string().nullish(),
|
||||
})
|
||||
|
||||
/**
|
||||
* WorkflowNodeJobMetadata
|
||||
*/
|
||||
export const zWorkflowNodeJobMetadata = z.object({
|
||||
agent_soul: z.record(z.string(), z.unknown()).nullish(),
|
||||
file_refs: z.array(zAgentFileRefConfig).nullish(),
|
||||
})
|
||||
|
||||
/**
|
||||
* AgentSkillRefConfig
|
||||
*/
|
||||
export const zAgentSkillRefConfig = z.object({
|
||||
description: z.string().nullish(),
|
||||
file_id: z.string().max(255).nullish(),
|
||||
id: z.string().max(255).nullish(),
|
||||
name: z.string().max(255).nullish(),
|
||||
path: z.string().nullish(),
|
||||
})
|
||||
|
||||
/**
|
||||
* AgentSoulSkillsFilesConfig
|
||||
*/
|
||||
@ -2230,6 +2164,14 @@ export const zAgentSoulSkillsFilesConfig = z.object({
|
||||
skills: z.array(zAgentSkillRefConfig).optional(),
|
||||
})
|
||||
|
||||
/**
|
||||
* WorkflowNodeJobMetadata
|
||||
*/
|
||||
export const zWorkflowNodeJobMetadata = z.object({
|
||||
agent_soul: z.record(z.string(), z.unknown()).nullish(),
|
||||
file_refs: z.array(zAgentFileRefConfig).nullish(),
|
||||
})
|
||||
|
||||
/**
|
||||
* AgentCliToolAuthorizationStatus
|
||||
*
|
||||
@ -2328,7 +2270,7 @@ export const zAgentComposerSoulCandidatesResponse = z.object({
|
||||
dify_tools: z.array(zAgentComposerDifyToolCandidateResponse).optional(),
|
||||
human_contacts: z.array(zAgentHumanContactConfig).optional(),
|
||||
knowledge_datasets: z.array(zAgentKnowledgeDatasetConfig).optional(),
|
||||
skills_files: z.array(z.unknown()).optional(),
|
||||
skills_files: z.array(zAgentSkillRefConfig).optional(),
|
||||
})
|
||||
|
||||
/**
|
||||
@ -2338,7 +2280,6 @@ export const zAgentComposerCandidatesResponse = z.object({
|
||||
allowed_node_job_candidates: zAgentComposerNodeJobCandidatesResponse.optional(),
|
||||
allowed_soul_candidates: zAgentComposerSoulCandidatesResponse.optional(),
|
||||
capabilities: zComposerCandidateCapabilities.optional(),
|
||||
truncated: z.boolean().optional().default(false),
|
||||
variant: zComposerVariant,
|
||||
})
|
||||
|
||||
@ -2616,7 +2557,6 @@ export const zAgentAppComposerResponse = z.object({
|
||||
agent: zAgentComposerAgentResponse,
|
||||
agent_soul: zAgentSoulConfig,
|
||||
save_options: z.array(zComposerSaveStrategy),
|
||||
validation: zComposerValidationFindingsResponse.optional(),
|
||||
variant: z.string(),
|
||||
})
|
||||
|
||||
@ -2651,7 +2591,6 @@ export const zWorkflowAgentComposerResponse = z.object({
|
||||
node_job: zWorkflowNodeJobConfig,
|
||||
save_options: z.array(zComposerSaveStrategy),
|
||||
soul_lock: zAgentComposerSoulLockResponse,
|
||||
validation: zComposerValidationFindingsResponse.optional(),
|
||||
variant: z.string(),
|
||||
workflow_id: z.string().nullish(),
|
||||
})
|
||||
|
||||
@ -168,20 +168,6 @@ export type DevicePollRequest = {
|
||||
device_code: string
|
||||
}
|
||||
|
||||
export type ErrorBody = {
|
||||
code: string
|
||||
details?: Array<ErrorDetail> | null
|
||||
hint?: string | null
|
||||
message: string
|
||||
status: number
|
||||
}
|
||||
|
||||
export type ErrorDetail = {
|
||||
loc?: Array<unknown>
|
||||
msg: string
|
||||
type: string
|
||||
}
|
||||
|
||||
export type FileResponse = {
|
||||
conversation_id?: string | null
|
||||
created_at?: number | null
|
||||
@ -415,12 +401,6 @@ export type GetHealthData = {
|
||||
url: '/_health'
|
||||
}
|
||||
|
||||
export type GetHealthErrors = {
|
||||
default: ErrorBody
|
||||
}
|
||||
|
||||
export type GetHealthError = GetHealthErrors[keyof GetHealthErrors]
|
||||
|
||||
export type GetHealthResponses = {
|
||||
200: HealthResponse
|
||||
}
|
||||
@ -434,12 +414,6 @@ export type GetVersionData = {
|
||||
url: '/_version'
|
||||
}
|
||||
|
||||
export type GetVersionErrors = {
|
||||
default: ErrorBody
|
||||
}
|
||||
|
||||
export type GetVersionError = GetVersionErrors[keyof GetVersionErrors]
|
||||
|
||||
export type GetVersionResponses = {
|
||||
200: ServerVersionResponse
|
||||
}
|
||||
@ -453,12 +427,6 @@ export type GetAccountData = {
|
||||
url: '/account'
|
||||
}
|
||||
|
||||
export type GetAccountErrors = {
|
||||
default: ErrorBody
|
||||
}
|
||||
|
||||
export type GetAccountError = GetAccountErrors[keyof GetAccountErrors]
|
||||
|
||||
export type GetAccountResponses = {
|
||||
200: AccountResponse
|
||||
}
|
||||
@ -475,13 +443,6 @@ export type GetAccountSessionsData = {
|
||||
url: '/account/sessions'
|
||||
}
|
||||
|
||||
export type GetAccountSessionsErrors = {
|
||||
422: ErrorBody
|
||||
default: ErrorBody
|
||||
}
|
||||
|
||||
export type GetAccountSessionsError = GetAccountSessionsErrors[keyof GetAccountSessionsErrors]
|
||||
|
||||
export type GetAccountSessionsResponses = {
|
||||
200: SessionListResponse
|
||||
}
|
||||
@ -496,13 +457,6 @@ export type DeleteAccountSessionsSelfData = {
|
||||
url: '/account/sessions/self'
|
||||
}
|
||||
|
||||
export type DeleteAccountSessionsSelfErrors = {
|
||||
default: ErrorBody
|
||||
}
|
||||
|
||||
export type DeleteAccountSessionsSelfError
|
||||
= DeleteAccountSessionsSelfErrors[keyof DeleteAccountSessionsSelfErrors]
|
||||
|
||||
export type DeleteAccountSessionsSelfResponses = {
|
||||
200: RevokeResponse
|
||||
}
|
||||
@ -519,13 +473,6 @@ export type DeleteAccountSessionsBySessionIdData = {
|
||||
url: '/account/sessions/{session_id}'
|
||||
}
|
||||
|
||||
export type DeleteAccountSessionsBySessionIdErrors = {
|
||||
default: ErrorBody
|
||||
}
|
||||
|
||||
export type DeleteAccountSessionsBySessionIdError
|
||||
= DeleteAccountSessionsBySessionIdErrors[keyof DeleteAccountSessionsBySessionIdErrors]
|
||||
|
||||
export type DeleteAccountSessionsBySessionIdResponses = {
|
||||
200: RevokeResponse
|
||||
}
|
||||
@ -547,13 +494,6 @@ export type GetAppsData = {
|
||||
url: '/apps'
|
||||
}
|
||||
|
||||
export type GetAppsErrors = {
|
||||
422: ErrorBody
|
||||
default: ErrorBody
|
||||
}
|
||||
|
||||
export type GetAppsError = GetAppsErrors[keyof GetAppsErrors]
|
||||
|
||||
export type GetAppsResponses = {
|
||||
200: AppListResponse
|
||||
}
|
||||
@ -569,13 +509,6 @@ export type GetAppsByAppIdCheckDependenciesData = {
|
||||
url: '/apps/{app_id}/check-dependencies'
|
||||
}
|
||||
|
||||
export type GetAppsByAppIdCheckDependenciesErrors = {
|
||||
default: ErrorBody
|
||||
}
|
||||
|
||||
export type GetAppsByAppIdCheckDependenciesError
|
||||
= GetAppsByAppIdCheckDependenciesErrors[keyof GetAppsByAppIdCheckDependenciesErrors]
|
||||
|
||||
export type GetAppsByAppIdCheckDependenciesResponses = {
|
||||
200: CheckDependenciesResult
|
||||
}
|
||||
@ -594,14 +527,6 @@ export type GetAppsByAppIdDescribeData = {
|
||||
url: '/apps/{app_id}/describe'
|
||||
}
|
||||
|
||||
export type GetAppsByAppIdDescribeErrors = {
|
||||
422: ErrorBody
|
||||
default: ErrorBody
|
||||
}
|
||||
|
||||
export type GetAppsByAppIdDescribeError
|
||||
= GetAppsByAppIdDescribeErrors[keyof GetAppsByAppIdDescribeErrors]
|
||||
|
||||
export type GetAppsByAppIdDescribeResponses = {
|
||||
200: AppDescribeResponse
|
||||
}
|
||||
@ -621,13 +546,6 @@ export type GetAppsByAppIdExportData = {
|
||||
url: '/apps/{app_id}/export'
|
||||
}
|
||||
|
||||
export type GetAppsByAppIdExportErrors = {
|
||||
422: ErrorBody
|
||||
default: ErrorBody
|
||||
}
|
||||
|
||||
export type GetAppsByAppIdExportError = GetAppsByAppIdExportErrors[keyof GetAppsByAppIdExportErrors]
|
||||
|
||||
export type GetAppsByAppIdExportResponses = {
|
||||
200: AppDslExportResponse
|
||||
}
|
||||
@ -657,7 +575,6 @@ export type PostAppsByAppIdFilesUploadErrors = {
|
||||
415: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
default: ErrorBody
|
||||
}
|
||||
|
||||
export type PostAppsByAppIdFilesUploadError
|
||||
@ -699,14 +616,6 @@ export type PostAppsByAppIdFormHumanInputByFormTokenData = {
|
||||
url: '/apps/{app_id}/form/human_input/{form_token}'
|
||||
}
|
||||
|
||||
export type PostAppsByAppIdFormHumanInputByFormTokenErrors = {
|
||||
422: ErrorBody
|
||||
default: ErrorBody
|
||||
}
|
||||
|
||||
export type PostAppsByAppIdFormHumanInputByFormTokenError
|
||||
= PostAppsByAppIdFormHumanInputByFormTokenErrors[keyof PostAppsByAppIdFormHumanInputByFormTokenErrors]
|
||||
|
||||
export type PostAppsByAppIdFormHumanInputByFormTokenResponses = {
|
||||
200: FormSubmitResponse
|
||||
}
|
||||
@ -723,12 +632,6 @@ export type PostAppsByAppIdRunData = {
|
||||
url: '/apps/{app_id}/run'
|
||||
}
|
||||
|
||||
export type PostAppsByAppIdRunErrors = {
|
||||
422: ErrorBody
|
||||
}
|
||||
|
||||
export type PostAppsByAppIdRunError = PostAppsByAppIdRunErrors[keyof PostAppsByAppIdRunErrors]
|
||||
|
||||
export type PostAppsByAppIdRunResponses = {
|
||||
200: {
|
||||
[key: string]: unknown
|
||||
@ -767,13 +670,6 @@ export type PostAppsByAppIdTasksByTaskIdStopData = {
|
||||
url: '/apps/{app_id}/tasks/{task_id}/stop'
|
||||
}
|
||||
|
||||
export type PostAppsByAppIdTasksByTaskIdStopErrors = {
|
||||
default: ErrorBody
|
||||
}
|
||||
|
||||
export type PostAppsByAppIdTasksByTaskIdStopError
|
||||
= PostAppsByAppIdTasksByTaskIdStopErrors[keyof PostAppsByAppIdTasksByTaskIdStopErrors]
|
||||
|
||||
export type PostAppsByAppIdTasksByTaskIdStopResponses = {
|
||||
200: TaskStopResponse
|
||||
}
|
||||
@ -867,14 +763,6 @@ export type GetPermittedExternalAppsData = {
|
||||
url: '/permitted-external-apps'
|
||||
}
|
||||
|
||||
export type GetPermittedExternalAppsErrors = {
|
||||
422: ErrorBody
|
||||
default: ErrorBody
|
||||
}
|
||||
|
||||
export type GetPermittedExternalAppsError
|
||||
= GetPermittedExternalAppsErrors[keyof GetPermittedExternalAppsErrors]
|
||||
|
||||
export type GetPermittedExternalAppsResponses = {
|
||||
200: PermittedExternalAppsListResponse
|
||||
}
|
||||
@ -889,12 +777,6 @@ export type GetWorkspacesData = {
|
||||
url: '/workspaces'
|
||||
}
|
||||
|
||||
export type GetWorkspacesErrors = {
|
||||
default: ErrorBody
|
||||
}
|
||||
|
||||
export type GetWorkspacesError = GetWorkspacesErrors[keyof GetWorkspacesErrors]
|
||||
|
||||
export type GetWorkspacesResponses = {
|
||||
200: WorkspaceListResponse
|
||||
}
|
||||
@ -910,13 +792,6 @@ export type GetWorkspacesByWorkspaceIdData = {
|
||||
url: '/workspaces/{workspace_id}'
|
||||
}
|
||||
|
||||
export type GetWorkspacesByWorkspaceIdErrors = {
|
||||
default: ErrorBody
|
||||
}
|
||||
|
||||
export type GetWorkspacesByWorkspaceIdError
|
||||
= GetWorkspacesByWorkspaceIdErrors[keyof GetWorkspacesByWorkspaceIdErrors]
|
||||
|
||||
export type GetWorkspacesByWorkspaceIdResponses = {
|
||||
200: WorkspaceDetailResponse
|
||||
}
|
||||
@ -935,8 +810,6 @@ export type PostWorkspacesByWorkspaceIdAppsImportsData = {
|
||||
|
||||
export type PostWorkspacesByWorkspaceIdAppsImportsErrors = {
|
||||
400: Import
|
||||
422: ErrorBody
|
||||
default: ErrorBody
|
||||
}
|
||||
|
||||
export type PostWorkspacesByWorkspaceIdAppsImportsError
|
||||
@ -962,7 +835,6 @@ export type PostWorkspacesByWorkspaceIdAppsImportsByImportIdConfirmData = {
|
||||
|
||||
export type PostWorkspacesByWorkspaceIdAppsImportsByImportIdConfirmErrors = {
|
||||
400: Import
|
||||
default: ErrorBody
|
||||
}
|
||||
|
||||
export type PostWorkspacesByWorkspaceIdAppsImportsByImportIdConfirmError
|
||||
@ -987,14 +859,6 @@ export type GetWorkspacesByWorkspaceIdMembersData = {
|
||||
url: '/workspaces/{workspace_id}/members'
|
||||
}
|
||||
|
||||
export type GetWorkspacesByWorkspaceIdMembersErrors = {
|
||||
422: ErrorBody
|
||||
default: ErrorBody
|
||||
}
|
||||
|
||||
export type GetWorkspacesByWorkspaceIdMembersError
|
||||
= GetWorkspacesByWorkspaceIdMembersErrors[keyof GetWorkspacesByWorkspaceIdMembersErrors]
|
||||
|
||||
export type GetWorkspacesByWorkspaceIdMembersResponses = {
|
||||
200: MemberListResponse
|
||||
}
|
||||
@ -1011,14 +875,6 @@ export type PostWorkspacesByWorkspaceIdMembersData = {
|
||||
url: '/workspaces/{workspace_id}/members'
|
||||
}
|
||||
|
||||
export type PostWorkspacesByWorkspaceIdMembersErrors = {
|
||||
422: ErrorBody
|
||||
default: ErrorBody
|
||||
}
|
||||
|
||||
export type PostWorkspacesByWorkspaceIdMembersError
|
||||
= PostWorkspacesByWorkspaceIdMembersErrors[keyof PostWorkspacesByWorkspaceIdMembersErrors]
|
||||
|
||||
export type PostWorkspacesByWorkspaceIdMembersResponses = {
|
||||
201: MemberInviteResponse
|
||||
}
|
||||
@ -1036,13 +892,6 @@ export type DeleteWorkspacesByWorkspaceIdMembersByMemberIdData = {
|
||||
url: '/workspaces/{workspace_id}/members/{member_id}'
|
||||
}
|
||||
|
||||
export type DeleteWorkspacesByWorkspaceIdMembersByMemberIdErrors = {
|
||||
default: ErrorBody
|
||||
}
|
||||
|
||||
export type DeleteWorkspacesByWorkspaceIdMembersByMemberIdError
|
||||
= DeleteWorkspacesByWorkspaceIdMembersByMemberIdErrors[keyof DeleteWorkspacesByWorkspaceIdMembersByMemberIdErrors]
|
||||
|
||||
export type DeleteWorkspacesByWorkspaceIdMembersByMemberIdResponses = {
|
||||
200: MemberActionResponse
|
||||
}
|
||||
@ -1060,14 +909,6 @@ export type PutWorkspacesByWorkspaceIdMembersByMemberIdRoleData = {
|
||||
url: '/workspaces/{workspace_id}/members/{member_id}/role'
|
||||
}
|
||||
|
||||
export type PutWorkspacesByWorkspaceIdMembersByMemberIdRoleErrors = {
|
||||
422: ErrorBody
|
||||
default: ErrorBody
|
||||
}
|
||||
|
||||
export type PutWorkspacesByWorkspaceIdMembersByMemberIdRoleError
|
||||
= PutWorkspacesByWorkspaceIdMembersByMemberIdRoleErrors[keyof PutWorkspacesByWorkspaceIdMembersByMemberIdRoleErrors]
|
||||
|
||||
export type PutWorkspacesByWorkspaceIdMembersByMemberIdRoleResponses = {
|
||||
200: MemberActionResponse
|
||||
}
|
||||
@ -1084,13 +925,6 @@ export type PostWorkspacesByWorkspaceIdSwitchData = {
|
||||
url: '/workspaces/{workspace_id}/switch'
|
||||
}
|
||||
|
||||
export type PostWorkspacesByWorkspaceIdSwitchErrors = {
|
||||
default: ErrorBody
|
||||
}
|
||||
|
||||
export type PostWorkspacesByWorkspaceIdSwitchError
|
||||
= PostWorkspacesByWorkspaceIdSwitchErrors[keyof PostWorkspacesByWorkspaceIdSwitchErrors]
|
||||
|
||||
export type PostWorkspacesByWorkspaceIdSwitchResponses = {
|
||||
200: WorkspaceDetailResponse
|
||||
}
|
||||
|
||||
@ -156,30 +156,6 @@ export const zDevicePollRequest = z.object({
|
||||
device_code: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
* ErrorDetail
|
||||
*/
|
||||
export const zErrorDetail = z.object({
|
||||
loc: z.array(z.unknown()).optional().default([]),
|
||||
msg: z.string(),
|
||||
type: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
* ErrorBody
|
||||
*
|
||||
* Canonical non-2xx body. ``code`` is typed ``str`` (not the enum) so the
|
||||
* generated client schema stays an open enum — old CLIs keep parsing when a
|
||||
* future server adds a code. Formatter tests pin emitted values to the enum.
|
||||
*/
|
||||
export const zErrorBody = z.object({
|
||||
code: z.string(),
|
||||
details: z.array(zErrorDetail).nullish(),
|
||||
hint: z.string().nullish(),
|
||||
message: z.string(),
|
||||
status: z.int(),
|
||||
})
|
||||
|
||||
/**
|
||||
* FileResponse
|
||||
*/
|
||||
|
||||
@ -39,15 +39,10 @@ describe('Tabs wrappers', () => {
|
||||
|
||||
await expect.element(screen.getByRole('tablist')).toHaveClass(
|
||||
'flex',
|
||||
'gap-4',
|
||||
)
|
||||
await expect.element(screen.getByRole('tab', { name: 'First' })).toHaveClass(
|
||||
'touch-manipulation',
|
||||
'focus-visible:outline-hidden',
|
||||
'border-b-2',
|
||||
'border-transparent',
|
||||
'data-active:border-components-tab-active',
|
||||
'data-active:text-text-primary',
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@ -26,11 +26,17 @@ type Story = StoryObj<typeof meta>
|
||||
export const Basic: Story = {
|
||||
render: () => (
|
||||
<Tabs defaultValue="overview" className="w-96">
|
||||
<TabsList>
|
||||
<TabsTab value="overview">
|
||||
<TabsList className="gap-4 border-b border-divider-subtle">
|
||||
<TabsTab
|
||||
value="overview"
|
||||
className="border-b border-transparent px-0 py-2 system-sm-medium text-text-tertiary data-active:border-text-accent data-active:text-text-primary"
|
||||
>
|
||||
Overview
|
||||
</TabsTab>
|
||||
<TabsTab value="activity">
|
||||
<TabsTab
|
||||
value="activity"
|
||||
className="border-b border-transparent px-0 py-2 system-sm-medium text-text-tertiary data-active:border-text-accent data-active:text-text-primary"
|
||||
>
|
||||
Activity
|
||||
</TabsTab>
|
||||
</TabsList>
|
||||
|
||||
@ -18,7 +18,7 @@ export function TabsList({
|
||||
}: TabsListProps) {
|
||||
return (
|
||||
<BaseTabs.List
|
||||
className={cn('flex gap-4', className)}
|
||||
className={cn('flex', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
@ -34,7 +34,7 @@ export function TabsTab({
|
||||
}: TabsTabProps) {
|
||||
return (
|
||||
<BaseTabs.Tab
|
||||
className={cn('touch-manipulation focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-state-accent-solid relative flex cursor-pointer items-center border-b-2 border-transparent pt-2.5 pb-2 system-md-semibold text-text-tertiary data-active:border-components-tab-active data-active:text-text-primary data-disabled:cursor-not-allowed data-disabled:text-text-tertiary data-disabled:opacity-30 data-active:data-disabled:text-text-primary', className)}
|
||||
className={cn('touch-manipulation focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-state-accent-solid data-disabled:cursor-not-allowed data-disabled:text-text-disabled', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
|
||||
@ -197,13 +197,13 @@ describe('TextGeneration', () => {
|
||||
expect(screen.getByTestId('run-once-input-name')).toHaveTextContent('Gamma')
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'share.generation.tabs.batch' }))
|
||||
fireEvent.click(screen.getByTestId('tab-header-item-batch'))
|
||||
expect(screen.getByRole('button', { name: 'run-batch' })).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole('tab', { name: /^share\.generation\.tabs\.saved/ }))
|
||||
fireEvent.click(screen.getByTestId('tab-header-item-saved'))
|
||||
expect(screen.getByTestId('saved-items-mock')).toHaveTextContent('2')
|
||||
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'share.generation.tabs.create' }))
|
||||
fireEvent.click(screen.getByTestId('tab-header-item-create'))
|
||||
expect(screen.getByTestId('run-once-mock')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
@ -220,7 +220,7 @@ describe('TextGeneration', () => {
|
||||
})
|
||||
expect(screen.getByTestId('result-single')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'share.generation.tabs.batch' }))
|
||||
fireEvent.click(screen.getByTestId('tab-header-item-batch'))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'run-batch' }))
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('idle')).toBeInTheDocument()
|
||||
|
||||
@ -46,8 +46,8 @@ const NavLink = ({
|
||||
const isActive = active ?? (href ? href.toLowerCase().split('/')?.pop() === formattedSegment : false)
|
||||
const NavIcon = isActive ? iconMap.selected : iconMap.normal
|
||||
const linkClassName = cn(isActive
|
||||
? 'border-t-[0.75px] border-r-[0.25px] border-b-[0.25px] border-l-[0.75px] border-effects-highlight-lightmode-off bg-components-menu-item-bg-active system-sm-semibold text-text-accent-light-mode-only'
|
||||
: 'system-sm-medium text-components-menu-item-text hover:bg-components-menu-item-bg-hover hover:text-components-menu-item-text-hover', 'flex h-8 items-center rounded-lg pr-1 pl-3')
|
||||
? 'border-b-[0.25px] border-l-[0.75px] border-r-[0.25px] border-t-[0.75px] border-effects-highlight-lightmode-off bg-components-menu-item-bg-active text-text-accent-light-mode-only system-sm-semibold'
|
||||
: 'text-components-menu-item-text system-sm-medium hover:bg-components-menu-item-bg-hover hover:text-components-menu-item-text-hover', 'flex h-8 items-center rounded-lg pl-3 pr-1')
|
||||
|
||||
const renderIcon = () => (
|
||||
<div className={cn(mode !== 'expand' && '-ml-1')}>
|
||||
|
||||
@ -6,7 +6,6 @@ import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { noop } from 'es-toolkit/function'
|
||||
import { decode } from 'html-entities'
|
||||
import Recorder from 'js-audio-recorder'
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Textarea from 'react-textarea-autosize'
|
||||
@ -14,12 +13,18 @@ import FeatureBar from '@/app/components/base/features/new-feature-panel/feature
|
||||
import { FileListInChatInput } from '@/app/components/base/file-uploader'
|
||||
import { useFile } from '@/app/components/base/file-uploader/hooks'
|
||||
import { FileContextProvider, useFileStore } from '@/app/components/base/file-uploader/store'
|
||||
import VoiceInput from '@/app/components/base/voice-input'
|
||||
import dynamic from '@/next/dynamic'
|
||||
import { TransferMethod } from '@/types/app'
|
||||
import { useCheckInputsForms } from '../check-input-forms-hooks'
|
||||
import { useTextAreaHeight } from './hooks'
|
||||
import Operation from './operation'
|
||||
|
||||
const VoiceInput = dynamic(() => import('@/app/components/base/voice-input'), { ssr: false })
|
||||
|
||||
type RecorderConstructorWithPermission = typeof import('js-audio-recorder').default & {
|
||||
getPermission: () => Promise<void>
|
||||
}
|
||||
|
||||
type ChatInputAreaProps = {
|
||||
readonly?: boolean
|
||||
botName?: string
|
||||
@ -128,12 +133,16 @@ const ChatInputArea = ({ readonly, botName, showFeatureBar, showFileUpload, feat
|
||||
}
|
||||
}
|
||||
}
|
||||
const handleShowVoiceInput = useCallback(() => {
|
||||
(Recorder as any).getPermission().then(() => {
|
||||
const handleShowVoiceInput = useCallback(async () => {
|
||||
const { default: Recorder } = await import('js-audio-recorder')
|
||||
|
||||
try {
|
||||
await (Recorder as RecorderConstructorWithPermission).getPermission()
|
||||
setShowVoiceInput(true)
|
||||
}, () => {
|
||||
}
|
||||
catch {
|
||||
toast.error(t('voiceInput.notAllow', { ns: 'common' }))
|
||||
})
|
||||
}
|
||||
}, [t])
|
||||
const operation = (<Operation ref={holdSpaceRef} readonly={readonly} fileConfig={visionConfig} speechToTextConfig={speechToTextConfig} onShowVoiceInput={handleShowVoiceInput} onSend={handleSend} theme={theme} />)
|
||||
return (
|
||||
|
||||
114
web/app/components/base/tab-header/__tests__/index.spec.tsx
Normal file
114
web/app/components/base/tab-header/__tests__/index.spec.tsx
Normal file
@ -0,0 +1,114 @@
|
||||
import { render, screen, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import TabHeader from '../index'
|
||||
|
||||
describe('TabHeader Component', () => {
|
||||
const mockItems = [
|
||||
{ id: 'tab1', name: 'General' },
|
||||
{ id: 'tab2', name: 'Settings' },
|
||||
{ id: 'tab3', name: 'Profile', isRight: true },
|
||||
{ id: 'tab4', name: 'Disabled Tab', disabled: true },
|
||||
]
|
||||
|
||||
it('should render all items with correct names', () => {
|
||||
render(<TabHeader items={mockItems} value="tab1" onChange={() => { }} />)
|
||||
|
||||
expect(screen.getByText('General')).toBeInTheDocument()
|
||||
expect(screen.getByText('Settings')).toBeInTheDocument()
|
||||
expect(screen.getByText('Profile')).toBeInTheDocument()
|
||||
expect(screen.getByText('Disabled Tab')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should separate items into left and right containers correctly', () => {
|
||||
render(<TabHeader items={mockItems} value="tab1" onChange={() => { }} />)
|
||||
|
||||
const leftContainer = screen.getByTestId('tab-header-left')
|
||||
const rightContainer = screen.getByTestId('tab-header-right')
|
||||
|
||||
// Verify children count
|
||||
expect(leftContainer.children.length).toBe(3)
|
||||
expect(rightContainer.children.length).toBe(1)
|
||||
|
||||
// Verify specific item placement using within and toContainElement
|
||||
const profileTab = screen.getByTestId('tab-header-item-tab3')
|
||||
expect(rightContainer).toContainElement(profileTab)
|
||||
|
||||
const disabledTab = screen.getByTestId('tab-header-item-tab4')
|
||||
expect(leftContainer).toContainElement(disabledTab)
|
||||
})
|
||||
|
||||
it('should apply active styles to the selected tab', () => {
|
||||
const activeClass = 'custom-active-style'
|
||||
render(
|
||||
<TabHeader
|
||||
items={mockItems}
|
||||
value="tab2"
|
||||
activeItemClassName={activeClass}
|
||||
onChange={() => { }}
|
||||
/>,
|
||||
)
|
||||
|
||||
const activeTab = screen.getByTestId('tab-header-item-tab2')
|
||||
expect(activeTab).toHaveClass('border-components-tab-active')
|
||||
expect(activeTab).toHaveClass(activeClass)
|
||||
|
||||
const inactiveTab = screen.getByTestId('tab-header-item-tab1')
|
||||
expect(inactiveTab).toHaveClass('text-text-tertiary')
|
||||
})
|
||||
|
||||
it('should call onChange when a non-disabled tab is clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
const handleChange = vi.fn()
|
||||
render(<TabHeader items={mockItems} value="tab1" onChange={handleChange} />)
|
||||
|
||||
await user.click(screen.getByText('Settings'))
|
||||
expect(handleChange).toHaveBeenCalledWith('tab2')
|
||||
})
|
||||
|
||||
it('should not call onChange when a disabled tab is clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
const handleChange = vi.fn()
|
||||
render(<TabHeader items={mockItems} value="tab1" onChange={handleChange} />)
|
||||
|
||||
const disabledTab = screen.getByTestId('tab-header-item-tab4')
|
||||
expect(disabledTab).toHaveClass('cursor-not-allowed')
|
||||
|
||||
await user.click(disabledTab)
|
||||
expect(handleChange).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should render icon and extra content when provided', () => {
|
||||
const itemsWithExtras = [
|
||||
{
|
||||
id: 'extra',
|
||||
name: 'Extra',
|
||||
icon: <span data-testid="tab-icon">🚀</span>,
|
||||
extra: <span data-testid="tab-extra">New</span>,
|
||||
},
|
||||
]
|
||||
render(<TabHeader items={itemsWithExtras} value="extra" onChange={() => { }} />)
|
||||
|
||||
expect(screen.getByTestId('tab-icon')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('tab-extra')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should apply custom class names for items and wrappers', () => {
|
||||
render(
|
||||
<TabHeader
|
||||
items={mockItems}
|
||||
value="tab1"
|
||||
itemClassName="my-text-class"
|
||||
itemWrapClassName="my-wrap-class"
|
||||
onChange={() => { }}
|
||||
/>,
|
||||
)
|
||||
|
||||
const tabWrap = screen.getByTestId('tab-header-item-tab1')
|
||||
// We target the inner div for the name class check
|
||||
const tabText = within(tabWrap).getByText('General')
|
||||
|
||||
expect(tabWrap).toHaveClass('my-wrap-class')
|
||||
expect(tabText).toHaveClass('my-text-class')
|
||||
})
|
||||
})
|
||||
66
web/app/components/base/tab-header/index.stories.tsx
Normal file
66
web/app/components/base/tab-header/index.stories.tsx
Normal file
@ -0,0 +1,66 @@
|
||||
import type { Meta, StoryObj } from '@storybook/nextjs-vite'
|
||||
import type { ITabHeaderProps } from '.'
|
||||
import { useState } from 'react'
|
||||
import TabHeader from '.'
|
||||
|
||||
const items: ITabHeaderProps['items'] = [
|
||||
{ id: 'overview', name: 'Overview' },
|
||||
{ id: 'playground', name: 'Playground' },
|
||||
{ id: 'changelog', name: 'Changelog', extra: <span className="ml-1 rounded-full bg-primary-50 px-2 py-0.5 text-xs text-primary-600">New</span> },
|
||||
{ id: 'docs', name: 'Docs', isRight: true },
|
||||
{ id: 'settings', name: 'Settings', isRight: true, disabled: true },
|
||||
]
|
||||
|
||||
const TabHeaderDemo = ({
|
||||
initialTab = 'overview',
|
||||
}: {
|
||||
initialTab?: string
|
||||
}) => {
|
||||
const [activeTab, setActiveTab] = useState(initialTab)
|
||||
|
||||
return (
|
||||
<div className="flex w-full max-w-3xl flex-col gap-6 rounded-2xl border border-divider-subtle bg-components-panel-bg p-6">
|
||||
<div className="flex items-center justify-between text-xs tracking-[0.18em] text-text-tertiary uppercase">
|
||||
<span>Tabs</span>
|
||||
<code className="rounded-md bg-background-default px-2 py-1 text-[11px] text-text-tertiary">
|
||||
active="
|
||||
{activeTab}
|
||||
"
|
||||
</code>
|
||||
</div>
|
||||
<TabHeader
|
||||
items={items}
|
||||
value={activeTab}
|
||||
onChange={setActiveTab}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const meta = {
|
||||
title: 'Base/Navigation/TabHeader',
|
||||
component: TabHeaderDemo,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
docs: {
|
||||
description: {
|
||||
component: 'Two-sided header tabs with optional right-aligned actions. Disabled items illustrate read-only states.',
|
||||
},
|
||||
},
|
||||
},
|
||||
argTypes: {
|
||||
initialTab: {
|
||||
control: 'radio',
|
||||
options: items.map(item => item.id),
|
||||
},
|
||||
},
|
||||
args: {
|
||||
initialTab: 'overview',
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
} satisfies Meta<typeof TabHeaderDemo>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Playground: Story = {}
|
||||
60
web/app/components/base/tab-header/index.tsx
Normal file
60
web/app/components/base/tab-header/index.tsx
Normal file
@ -0,0 +1,60 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import * as React from 'react'
|
||||
|
||||
type Item = {
|
||||
id: string
|
||||
name: string
|
||||
isRight?: boolean
|
||||
icon?: React.ReactNode
|
||||
extra?: React.ReactNode
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export type ITabHeaderProps = Readonly<{
|
||||
items: Item[]
|
||||
value: string
|
||||
itemClassName?: string
|
||||
itemWrapClassName?: string
|
||||
activeItemClassName?: string
|
||||
onChange: (value: string) => void
|
||||
}>
|
||||
|
||||
const TabHeader: FC<ITabHeaderProps> = ({
|
||||
items,
|
||||
value,
|
||||
itemClassName,
|
||||
itemWrapClassName,
|
||||
activeItemClassName,
|
||||
onChange,
|
||||
}) => {
|
||||
const renderItem = ({ id, name, icon, extra, disabled }: Item) => (
|
||||
<div
|
||||
key={id}
|
||||
data-testid={`tab-header-item-${id}`}
|
||||
className={cn(
|
||||
'relative flex cursor-pointer items-center border-b-2 border-transparent pt-2.5 pb-2 system-md-semibold',
|
||||
id === value ? cn('border-components-tab-active text-text-primary', activeItemClassName) : 'text-text-tertiary',
|
||||
disabled && 'cursor-not-allowed opacity-30',
|
||||
itemWrapClassName,
|
||||
)}
|
||||
onClick={() => !disabled && onChange(id)}
|
||||
>
|
||||
{icon || ''}
|
||||
<div className={cn('ml-2', itemClassName)}>{name}</div>
|
||||
{extra || ''}
|
||||
</div>
|
||||
)
|
||||
return (
|
||||
<div data-testid="tab-header" className="flex justify-between">
|
||||
<div data-testid="tab-header-left" className="flex space-x-4">
|
||||
{items.filter(item => !item.isRight).map(renderItem)}
|
||||
</div>
|
||||
<div data-testid="tab-header-right" className="flex space-x-4">
|
||||
{items.filter(item => item.isRight).map(renderItem)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export default React.memo(TabHeader)
|
||||
@ -98,6 +98,9 @@ describe('VoiceInput', () => {
|
||||
|
||||
it('should start recording on mount and show speaking state', async () => {
|
||||
render(<VoiceInput onConverted={onConverted} onCancel={onCancel} />)
|
||||
await waitFor(() => {
|
||||
expect(mockState.recorderInstances).toHaveLength(1)
|
||||
})
|
||||
// eslint-disable-next-line ts/no-explicit-any
|
||||
const recorder = mockState.recorderInstances[0] as any
|
||||
expect(recorder.start).toHaveBeenCalled()
|
||||
@ -390,8 +393,11 @@ describe('VoiceInput', () => {
|
||||
expect(await screen.findByText('common.voiceInput.speaking'))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should cleanup on unmount', () => {
|
||||
it('should cleanup on unmount', async () => {
|
||||
const { unmount } = render(<VoiceInput onConverted={onConverted} onCancel={onCancel} />)
|
||||
await waitFor(() => {
|
||||
expect(mockState.recorderInstances).toHaveLength(1)
|
||||
})
|
||||
// eslint-disable-next-line ts/no-explicit-any
|
||||
const recorder = mockState.recorderInstances[0] as any
|
||||
|
||||
@ -400,6 +406,31 @@ describe('VoiceInput', () => {
|
||||
expect(recorder.stop).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should stop without cancelling after unmount while recording start is pending', async () => {
|
||||
let resolveStart!: () => void
|
||||
mockState.startOverride = () => new Promise<void>((resolve) => {
|
||||
resolveStart = resolve
|
||||
})
|
||||
|
||||
const { unmount } = render(<VoiceInput onConverted={onConverted} onCancel={onCancel} />)
|
||||
await waitFor(() => {
|
||||
expect(mockState.recorderInstances).toHaveLength(1)
|
||||
})
|
||||
// eslint-disable-next-line ts/no-explicit-any
|
||||
const recorder = mockState.recorderInstances[0] as any
|
||||
|
||||
unmount()
|
||||
expect(recorder.stop).toHaveBeenCalledTimes(1)
|
||||
|
||||
await act(async () => {
|
||||
resolveStart()
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
expect(recorder.stop).toHaveBeenCalledTimes(2)
|
||||
expect(onCancel).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle all data in recordAnalyseData for canvas drawing', async () => {
|
||||
const allDataValues = []
|
||||
for (let i = 0; i < 256; i++) {
|
||||
|
||||
@ -1,12 +1,13 @@
|
||||
'use client'
|
||||
|
||||
import type Recorder from 'js-audio-recorder'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { useRafInterval } from 'ahooks'
|
||||
import Recorder from 'js-audio-recorder'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useParams, usePathname } from '@/next/navigation'
|
||||
import { AppSourceType, audioToText } from '@/service/share'
|
||||
import s from './index.module.css'
|
||||
import { convertToMp3 } from './utils'
|
||||
|
||||
type VoiceInputTypes = {
|
||||
onConverted: (text: string) => void
|
||||
@ -20,15 +21,11 @@ const VoiceInput = ({
|
||||
wordTimestamps,
|
||||
}: VoiceInputTypes) => {
|
||||
const { t } = useTranslation()
|
||||
const recorder = useRef(new Recorder({
|
||||
sampleBits: 16,
|
||||
sampleRate: 16000,
|
||||
numChannels: 1,
|
||||
compiling: false,
|
||||
}))
|
||||
const recorderRef = useRef<Recorder | null>(null)
|
||||
const mountedRef = useRef(false)
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null)
|
||||
const ctxRef = useRef<CanvasRenderingContext2D | null>(null)
|
||||
const drawRecordId = useRef<number | null>(null)
|
||||
const drawRecordIdRef = useRef<number | null>(null)
|
||||
const [originDuration, setOriginDuration] = useState(0)
|
||||
const [startRecord, setStartRecord] = useState(false)
|
||||
const [startConvert, setStartConvert] = useState(false)
|
||||
@ -38,11 +35,29 @@ const VoiceInput = ({
|
||||
setOriginDuration(originDuration + 1)
|
||||
}, 1000)
|
||||
|
||||
const getRecorder = useCallback(async () => {
|
||||
if (!recorderRef.current) {
|
||||
const { default: Recorder } = await import('js-audio-recorder')
|
||||
recorderRef.current = new Recorder({
|
||||
sampleBits: 16,
|
||||
sampleRate: 16000,
|
||||
numChannels: 1,
|
||||
compiling: false,
|
||||
})
|
||||
}
|
||||
|
||||
return recorderRef.current
|
||||
}, [])
|
||||
|
||||
const drawRecord = useCallback(() => {
|
||||
drawRecordId.current = requestAnimationFrame(drawRecord)
|
||||
drawRecordIdRef.current = requestAnimationFrame(drawRecord)
|
||||
const canvas = canvasRef.current!
|
||||
const ctx = ctxRef.current!
|
||||
const dataUnit8Array = recorder.current.getRecordAnalyseData()
|
||||
const currentRecorder = recorderRef.current
|
||||
if (!currentRecorder)
|
||||
return
|
||||
|
||||
const dataUnit8Array = currentRecorder.getRecordAnalyseData()
|
||||
const dataArray = [].slice.call(dataUnit8Array)
|
||||
const lineLength = Number.parseInt(`${canvas.width / 3}`)
|
||||
const gap = Number.parseInt(`${1024 / lineLength}`)
|
||||
@ -72,17 +87,22 @@ const VoiceInput = ({
|
||||
ctx.closePath()
|
||||
}, [])
|
||||
const handleStopRecorder = useCallback(async () => {
|
||||
const currentRecorder = recorderRef.current
|
||||
if (!currentRecorder)
|
||||
return
|
||||
|
||||
clearInterval()
|
||||
setStartRecord(false)
|
||||
setStartConvert(true)
|
||||
recorder.current.stop()
|
||||
if (drawRecordId.current)
|
||||
cancelAnimationFrame(drawRecordId.current)
|
||||
drawRecordId.current = null
|
||||
currentRecorder.stop()
|
||||
if (drawRecordIdRef.current)
|
||||
cancelAnimationFrame(drawRecordIdRef.current)
|
||||
drawRecordIdRef.current = null
|
||||
const canvas = canvasRef.current!
|
||||
const ctx = ctxRef.current!
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height)
|
||||
const mp3Blob = convertToMp3(recorder.current)
|
||||
const { convertToMp3 } = await import('./utils')
|
||||
const mp3Blob = convertToMp3(currentRecorder)
|
||||
const mp3File = new File([mp3Blob], 'temp.mp3', { type: 'audio/mp3' })
|
||||
const formData = new FormData()
|
||||
formData.append('file', mp3File)
|
||||
@ -114,7 +134,20 @@ const VoiceInput = ({
|
||||
}, [clearInterval, onCancel, onConverted, params.appId, params.token, pathname, wordTimestamps])
|
||||
const handleStartRecord = useCallback(async () => {
|
||||
try {
|
||||
await recorder.current.start()
|
||||
const currentRecorder = await getRecorder()
|
||||
|
||||
if (!mountedRef.current) {
|
||||
currentRecorder.stop()
|
||||
return
|
||||
}
|
||||
|
||||
await currentRecorder.start()
|
||||
|
||||
if (!mountedRef.current) {
|
||||
currentRecorder.stop()
|
||||
return
|
||||
}
|
||||
|
||||
setStartRecord(true)
|
||||
setStartConvert(false)
|
||||
|
||||
@ -122,9 +155,10 @@ const VoiceInput = ({
|
||||
drawRecord()
|
||||
}
|
||||
catch {
|
||||
onCancel()
|
||||
if (mountedRef.current)
|
||||
onCancel()
|
||||
}
|
||||
}, [drawRecord, onCancel, setStartRecord, setStartConvert])
|
||||
}, [drawRecord, getRecorder, onCancel])
|
||||
const initCanvas = useCallback(() => {
|
||||
const dpr = window.devicePixelRatio || 1
|
||||
const canvas = document.getElementById('voice-input-record') as HTMLCanvasElement
|
||||
@ -148,11 +182,14 @@ const VoiceInput = ({
|
||||
handleStopRecorder()
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true
|
||||
initCanvas()
|
||||
handleStartRecord()
|
||||
const recorderRef = recorder?.current
|
||||
return () => {
|
||||
recorderRef?.stop()
|
||||
mountedRef.current = false
|
||||
if (drawRecordIdRef.current)
|
||||
cancelAnimationFrame(drawRecordIdRef.current)
|
||||
recorderRef.current?.stop()
|
||||
}
|
||||
}, [handleStartRecord, initCanvas])
|
||||
|
||||
|
||||
@ -3,7 +3,7 @@ import { cleanup, fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { renderWithSystemFeatures as render } from '@/__tests__/utils/mock-system-features'
|
||||
import TryApp from '../index'
|
||||
import { TypeEnum } from '../types'
|
||||
import { TypeEnum } from '../tab'
|
||||
|
||||
vi.mock('@/config', async (importOriginal) => {
|
||||
const actual = await importOriginal() as object
|
||||
@ -213,7 +213,8 @@ describe('TryApp (main index.tsx)', () => {
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: 'common.operation.close' })).toBeInTheDocument()
|
||||
const buttons = document.body.querySelectorAll('button')
|
||||
expect(buttons.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -280,10 +281,15 @@ describe('TryApp (main index.tsx)', () => {
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: 'common.operation.close' })).toBeInTheDocument()
|
||||
})
|
||||
const buttons = document.body.querySelectorAll('button')
|
||||
const closeButton = Array.from(buttons).find(btn =>
|
||||
btn.querySelector('svg') || btn.className.includes('rounded-[10px]'),
|
||||
)
|
||||
expect(closeButton).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.operation.close' }))
|
||||
if (closeButton)
|
||||
fireEvent.click(closeButton)
|
||||
})
|
||||
|
||||
expect(mockOnClose).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
54
web/app/components/explore/try-app/__tests__/tab.spec.tsx
Normal file
54
web/app/components/explore/try-app/__tests__/tab.spec.tsx
Normal file
@ -0,0 +1,54 @@
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import Tab, { TypeEnum } from '../tab'
|
||||
|
||||
vi.mock('@/config', async (importOriginal) => {
|
||||
const actual = await importOriginal() as object
|
||||
return {
|
||||
...actual,
|
||||
IS_CLOUD_EDITION: true,
|
||||
}
|
||||
})
|
||||
|
||||
describe('Tab', () => {
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
})
|
||||
|
||||
it('renders tab with TRY value selected', () => {
|
||||
const mockOnChange = vi.fn()
|
||||
render(<Tab value={TypeEnum.TRY} onChange={mockOnChange} />)
|
||||
|
||||
expect(screen.getByText('explore.tryApp.tabHeader.try')).toBeInTheDocument()
|
||||
expect(screen.getByText('explore.tryApp.tabHeader.detail')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders tab with DETAIL value selected', () => {
|
||||
const mockOnChange = vi.fn()
|
||||
render(<Tab value={TypeEnum.DETAIL} onChange={mockOnChange} />)
|
||||
|
||||
expect(screen.getByText('explore.tryApp.tabHeader.try')).toBeInTheDocument()
|
||||
expect(screen.getByText('explore.tryApp.tabHeader.detail')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onChange when clicking a tab', () => {
|
||||
const mockOnChange = vi.fn()
|
||||
render(<Tab value={TypeEnum.TRY} onChange={mockOnChange} />)
|
||||
|
||||
fireEvent.click(screen.getByText('explore.tryApp.tabHeader.detail'))
|
||||
expect(mockOnChange).toHaveBeenCalledWith(TypeEnum.DETAIL)
|
||||
})
|
||||
|
||||
it('calls onChange when clicking Try tab', () => {
|
||||
const mockOnChange = vi.fn()
|
||||
render(<Tab value={TypeEnum.DETAIL} onChange={mockOnChange} />)
|
||||
|
||||
fireEvent.click(screen.getByText('explore.tryApp.tabHeader.try'))
|
||||
expect(mockOnChange).toHaveBeenCalledWith(TypeEnum.TRY)
|
||||
})
|
||||
|
||||
it('exports TypeEnum correctly', () => {
|
||||
expect(TypeEnum.TRY).toBe('try')
|
||||
expect(TypeEnum.DETAIL).toBe('detail')
|
||||
})
|
||||
})
|
||||
@ -4,11 +4,9 @@ import type { FC } from 'react'
|
||||
import type { App as AppType } from '@/models/explore'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog'
|
||||
import { Tabs, TabsList, TabsPanel, TabsTab } from '@langgenius/dify-ui/tabs'
|
||||
import { useSuspenseQuery } from '@tanstack/react-query'
|
||||
import * as React from 'react'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import AppUnavailable from '@/app/components/base/app-unavailable'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import { IS_CLOUD_EDITION } from '@/config'
|
||||
@ -17,7 +15,7 @@ import { useGetTryAppInfo } from '@/service/use-try-app'
|
||||
import App from './app'
|
||||
import AppInfo from './app-info'
|
||||
import Preview from './preview'
|
||||
import { TypeEnum } from './types'
|
||||
import Tab, { TypeEnum } from './tab'
|
||||
|
||||
type Props = {
|
||||
appId: string
|
||||
@ -34,7 +32,6 @@ const TryApp: FC<Props> = ({
|
||||
onClose,
|
||||
onCreate,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
|
||||
const isTrialApp = !!(app && app.can_trial && systemFeatures.enable_trial_app)
|
||||
const canUseTryTab = IS_CLOUD_EDITION && (app ? isTrialApp : true)
|
||||
@ -65,49 +62,25 @@ const TryApp: FC<Props> = ({
|
||||
<AppUnavailable className="size-auto" isUnknownReason />
|
||||
</div>
|
||||
) : (
|
||||
<Tabs
|
||||
value={activeType}
|
||||
onValueChange={selectedValue => setType(selectedValue)}
|
||||
className="flex h-full flex-col"
|
||||
>
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex shrink-0 justify-between pl-4">
|
||||
<TabsList>
|
||||
{IS_CLOUD_EDITION && (
|
||||
<TabsTab
|
||||
value={TypeEnum.TRY}
|
||||
disabled={app ? !isTrialApp : false}
|
||||
className="pt-2 data-active:border-util-colors-blue-brand-blue-brand-500"
|
||||
>
|
||||
<span className="system-md-semibold-uppercase">{t('tryApp.tabHeader.try', { ns: 'explore' })}</span>
|
||||
</TabsTab>
|
||||
)}
|
||||
<TabsTab
|
||||
value={TypeEnum.DETAIL}
|
||||
className="pt-2 data-active:border-util-colors-blue-brand-blue-brand-500"
|
||||
>
|
||||
<span className="system-md-semibold-uppercase">{t('tryApp.tabHeader.detail', { ns: 'explore' })}</span>
|
||||
</TabsTab>
|
||||
</TabsList>
|
||||
<Tab
|
||||
value={activeType}
|
||||
onChange={setType}
|
||||
disableTry={app ? !isTrialApp : false}
|
||||
/>
|
||||
<Button
|
||||
size="large"
|
||||
variant="tertiary"
|
||||
aria-label={t('common.operation.close')}
|
||||
className="flex size-7 items-center justify-center rounded-[10px] p-0 text-components-button-tertiary-text"
|
||||
onClick={onClose}
|
||||
>
|
||||
<span aria-hidden className="i-ri-close-line size-5" />
|
||||
<span className="i-ri-close-line size-5" />
|
||||
</Button>
|
||||
</div>
|
||||
{/* Main content */}
|
||||
<div className="mt-2 flex h-0 grow justify-between space-x-2">
|
||||
{IS_CLOUD_EDITION && (
|
||||
<TabsPanel value={TypeEnum.TRY} className="min-w-0 flex-1">
|
||||
<App appId={appId} appDetail={appDetail} />
|
||||
</TabsPanel>
|
||||
)}
|
||||
<TabsPanel value={TypeEnum.DETAIL} className="min-w-0 flex-1">
|
||||
<Preview appId={appId} appDetail={appDetail} />
|
||||
</TabsPanel>
|
||||
{activeType === TypeEnum.TRY ? <App appId={appId} appDetail={appDetail} /> : <Preview appId={appId} appDetail={appDetail} />}
|
||||
<AppInfo
|
||||
className="w-[360px] shrink-0"
|
||||
appDetail={appDetail}
|
||||
@ -116,7 +89,7 @@ const TryApp: FC<Props> = ({
|
||||
onCreate={onCreate}
|
||||
/>
|
||||
</div>
|
||||
</Tabs>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
43
web/app/components/explore/try-app/tab.tsx
Normal file
43
web/app/components/explore/try-app/tab.tsx
Normal file
@ -0,0 +1,43 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import * as React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { IS_CLOUD_EDITION } from '@/config'
|
||||
import TabHeader from '../../base/tab-header'
|
||||
|
||||
export enum TypeEnum {
|
||||
TRY = 'try',
|
||||
DETAIL = 'detail',
|
||||
}
|
||||
|
||||
type Props = {
|
||||
value: TypeEnum
|
||||
onChange: (value: TypeEnum) => void
|
||||
disableTry?: boolean
|
||||
}
|
||||
|
||||
const Tab: FC<Props> = ({
|
||||
value,
|
||||
onChange,
|
||||
disableTry,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const tabs = React.useMemo(() => {
|
||||
return [
|
||||
IS_CLOUD_EDITION ? { id: TypeEnum.TRY, name: t('tryApp.tabHeader.try', { ns: 'explore' }), disabled: disableTry } : null,
|
||||
{ id: TypeEnum.DETAIL, name: t('tryApp.tabHeader.detail', { ns: 'explore' }) },
|
||||
].filter(item => item !== null) as { id: TypeEnum, name: string }[]
|
||||
}, [t, disableTry])
|
||||
return (
|
||||
<TabHeader
|
||||
items={tabs}
|
||||
value={value}
|
||||
onChange={onChange as (value: string) => void}
|
||||
itemClassName="ml-0 system-md-semibold-uppercase"
|
||||
itemWrapClassName="pt-2"
|
||||
activeItemClassName="border-util-colors-blue-brand-blue-brand-500"
|
||||
/>
|
||||
)
|
||||
}
|
||||
export default React.memo(Tab)
|
||||
@ -1,6 +0,0 @@
|
||||
export const TypeEnum = {
|
||||
TRY: 'try',
|
||||
DETAIL: 'detail',
|
||||
} as const
|
||||
|
||||
export type TypeEnum = typeof TypeEnum[keyof typeof TypeEnum]
|
||||
@ -113,7 +113,6 @@ describe('TextGenerationSidebar', () => {
|
||||
|
||||
expect(screen.getByText('Text Generation')).toBeInTheDocument()
|
||||
expect(screen.getByText('Share description')).toBeInTheDocument()
|
||||
expect(screen.getByRole('tablist')).toHaveClass('w-full')
|
||||
expect(screen.getByTestId('run-once-mock')).toBeInTheDocument()
|
||||
expect(runOncePropsSpy).toHaveBeenCalledWith(expect.objectContaining({
|
||||
inputs: { name: 'Alice' },
|
||||
@ -135,7 +134,7 @@ describe('TextGenerationSidebar', () => {
|
||||
vars: promptConfig.prompt_variables,
|
||||
isAllFinished: true,
|
||||
}))
|
||||
expect(screen.queryByRole('tab', { name: /^share\.generation\.tabs\.saved/ })).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('tab-header-item-saved')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render saved items and allow switching back to create tab', () => {
|
||||
|
||||
@ -5,7 +5,6 @@ import type { PromptConfig, SavedMessage, TextToSpeechConfig } from '@/models/de
|
||||
import type { SiteInfo } from '@/models/share'
|
||||
import type { VisionFile, VisionSettings } from '@/types/app'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Tabs, TabsList, TabsPanel, TabsTab } from '@langgenius/dify-ui/tabs'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import SavedItems from '@/app/components/app/text-generate/saved-items'
|
||||
import AppIcon from '@/app/components/base/app-icon'
|
||||
@ -13,6 +12,7 @@ import Badge from '@/app/components/base/badge'
|
||||
import DifyLogo from '@/app/components/base/logo/dify-logo'
|
||||
import { appDefaultIconBackground } from '@/config'
|
||||
import { AccessMode } from '@/models/access-control'
|
||||
import TabHeader from '../../base/tab-header'
|
||||
import MenuDropdown from './menu-dropdown'
|
||||
import RunBatch from './run-batch'
|
||||
import RunOnce from './run-once'
|
||||
@ -71,9 +71,7 @@ const TextGenerationSidebar: FC<TextGenerationSidebarProps> = ({
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
value={currentTab}
|
||||
onValueChange={onTabChange}
|
||||
<div
|
||||
className={cn(
|
||||
'relative flex h-full shrink-0 flex-col',
|
||||
isPC ? 'w-[600px] max-w-[50%]' : resultExisted ? 'h-[calc(100%-64px)]' : '',
|
||||
@ -95,25 +93,29 @@ const TextGenerationSidebar: FC<TextGenerationSidebarProps> = ({
|
||||
{siteInfo.description && (
|
||||
<div className="system-xs-regular text-text-tertiary">{siteInfo.description}</div>
|
||||
)}
|
||||
<TabsList className="w-full">
|
||||
<TabsTab value="create">
|
||||
<span className="ml-2">{t('generation.tabs.create', { ns: 'share' })}</span>
|
||||
</TabsTab>
|
||||
<TabsTab value="batch">
|
||||
<span className="ml-2">{t('generation.tabs.batch', { ns: 'share' })}</span>
|
||||
</TabsTab>
|
||||
{!isWorkflow && (
|
||||
<TabsTab value="saved" className="ml-auto">
|
||||
<span aria-hidden className="i-ri-bookmark-3-line size-4" />
|
||||
<span className="ml-2">{t('generation.tabs.saved', { ns: 'share' })}</span>
|
||||
{savedMessages.length > 0 && (
|
||||
<Badge className="ml-1">
|
||||
{savedMessages.length}
|
||||
</Badge>
|
||||
)}
|
||||
</TabsTab>
|
||||
)}
|
||||
</TabsList>
|
||||
<TabHeader
|
||||
items={[
|
||||
{ id: 'create', name: t('generation.tabs.create', { ns: 'share' }) },
|
||||
{ id: 'batch', name: t('generation.tabs.batch', { ns: 'share' }) },
|
||||
...(!isWorkflow
|
||||
? [{
|
||||
id: 'saved',
|
||||
name: t('generation.tabs.saved', { ns: 'share' }),
|
||||
isRight: true,
|
||||
icon: <span aria-hidden className="i-ri-bookmark-3-line size-4" />,
|
||||
extra: savedMessages.length > 0
|
||||
? (
|
||||
<Badge className="ml-1">
|
||||
{savedMessages.length}
|
||||
</Badge>
|
||||
)
|
||||
: null,
|
||||
}]
|
||||
: []),
|
||||
]}
|
||||
value={currentTab}
|
||||
onChange={onTabChange}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
@ -122,7 +124,7 @@ const TextGenerationSidebar: FC<TextGenerationSidebarProps> = ({
|
||||
!isPC && resultExisted && customConfig?.remove_webapp_brand && 'rounded-b-2xl border-b-[0.5px] border-divider-regular',
|
||||
)}
|
||||
>
|
||||
<TabsPanel value="create" keepMounted>
|
||||
<div className={cn(currentTab === 'create' ? 'block' : 'hidden')}>
|
||||
<RunOnce
|
||||
siteInfo={siteInfo}
|
||||
inputs={inputs}
|
||||
@ -134,24 +136,22 @@ const TextGenerationSidebar: FC<TextGenerationSidebarProps> = ({
|
||||
onVisionFilesChange={onVisionFilesChange}
|
||||
runControl={runControl}
|
||||
/>
|
||||
</TabsPanel>
|
||||
<TabsPanel value="batch" keepMounted>
|
||||
</div>
|
||||
<div className={cn(currentTab === 'batch' ? 'block' : 'hidden')}>
|
||||
<RunBatch
|
||||
vars={promptConfig.prompt_variables}
|
||||
onSend={onBatchSend}
|
||||
isAllFinished={allTasksRun}
|
||||
/>
|
||||
</TabsPanel>
|
||||
{!isWorkflow && (
|
||||
<TabsPanel value="saved">
|
||||
<SavedItems
|
||||
className={cn(isPC ? 'mt-6' : 'mt-4')}
|
||||
isShowTextToSpeech={textToSpeechConfig?.enabled}
|
||||
list={savedMessages}
|
||||
onRemove={onRemoveSavedMessage}
|
||||
onStartCreateContent={() => onTabChange('create')}
|
||||
/>
|
||||
</TabsPanel>
|
||||
</div>
|
||||
{currentTab === 'saved' && (
|
||||
<SavedItems
|
||||
className={cn(isPC ? 'mt-6' : 'mt-4')}
|
||||
isShowTextToSpeech={textToSpeechConfig?.enabled}
|
||||
list={savedMessages}
|
||||
onRemove={onRemoveSavedMessage}
|
||||
onStartCreateContent={() => onTabChange('create')}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{!customConfig?.remove_webapp_brand && (
|
||||
@ -170,7 +170,7 @@ const TextGenerationSidebar: FC<TextGenerationSidebarProps> = ({
|
||||
: <DifyLogo size="small" />}
|
||||
</div>
|
||||
)}
|
||||
</Tabs>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -274,6 +274,18 @@ vi.mock('../last-run', () => ({
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('../tab', () => ({
|
||||
__esModule: true,
|
||||
TabType: { settings: 'settings', lastRun: 'lastRun' },
|
||||
default: ({ value, onChange }: { value: string, onChange: (value: string) => void }) => (
|
||||
<div>
|
||||
<button onClick={() => onChange('settings')}>settings-tab</button>
|
||||
<button onClick={() => onChange('lastRun')}>last-run-tab</button>
|
||||
<span>{value}</span>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('../trigger-subscription', () => ({
|
||||
TriggerSubscription: ({ children, onSubscriptionChange }: PropsWithChildren<{ onSubscriptionChange?: (value: { id: string }, callback?: () => void) => void }>) => (
|
||||
<div>
|
||||
@ -309,7 +321,7 @@ describe('workflow-panel index', () => {
|
||||
})
|
||||
|
||||
it('should render the settings panel and wire title, description, run, and close actions', async () => {
|
||||
renderWorkflowComponent(
|
||||
const { container } = renderWorkflowComponent(
|
||||
<BasePanel id="node-1" data={createData() as never}>
|
||||
<div>panel-child</div>
|
||||
</BasePanel>,
|
||||
@ -339,7 +351,8 @@ describe('workflow-panel index', () => {
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'workflow.panel.runThisStep' }))
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.operation.close' }))
|
||||
const clickableItems = container.querySelectorAll('.cursor-pointer')
|
||||
fireEvent.click(clickableItems[clickableItems.length - 1] as HTMLElement)
|
||||
|
||||
expect(mockHandleSingleRun).toHaveBeenCalledTimes(1)
|
||||
expect(mockHandleNodeSelect).toHaveBeenCalledWith('node-1', true)
|
||||
@ -382,7 +395,6 @@ describe('workflow-panel index', () => {
|
||||
)
|
||||
|
||||
expect(screen.getByText('last-run-panel')).toBeInTheDocument()
|
||||
expect(screen.getByRole('tabpanel')).toHaveClass('flex', 'flex-1', 'flex-col')
|
||||
})
|
||||
|
||||
it('should render the plain tab layout and allow last-run status updates', async () => {
|
||||
|
||||
@ -2,7 +2,6 @@ import type { FC, ReactNode } from 'react'
|
||||
import type { SimpleSubscription } from '@/app/components/plugins/plugin-detail-panel/subscription-list'
|
||||
import type { Node } from '@/app/components/workflow/types'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Tabs, TabsList, TabsPanel, TabsTab } from '@langgenius/dify-ui/tabs'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
@ -91,8 +90,8 @@ import {
|
||||
} from './helpers'
|
||||
import LastRun from './last-run'
|
||||
import useLastRun from './last-run/use-last-run'
|
||||
import Tab, { TabType } from './tab'
|
||||
import { TriggerSubscription } from './trigger-subscription'
|
||||
import { TabType } from './types'
|
||||
|
||||
type BasePanelProps = {
|
||||
children: ReactNode
|
||||
@ -481,17 +480,6 @@ const BasePanel: FC<BasePanelProps> = ({
|
||||
? t('debug.variableInspect.trigger.stop', { ns: 'workflow' })
|
||||
: runThisStepLabel
|
||||
|
||||
const panelTabs = (
|
||||
<TabsList>
|
||||
<TabsTab value={TabType.settings}>
|
||||
{t('debug.settingsTab', { ns: 'workflow' }).toLocaleUpperCase()}
|
||||
</TabsTab>
|
||||
<TabsTab value={TabType.lastRun}>
|
||||
{t('debug.lastRunTab', { ns: 'workflow' }).toLocaleUpperCase()}
|
||||
</TabsTab>
|
||||
</TabsList>
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
@ -508,10 +496,8 @@ const BasePanel: FC<BasePanelProps> = ({
|
||||
>
|
||||
<div className="h-10 w-0.5 rounded-xs bg-state-base-handle hover:h-full hover:bg-state-accent-solid active:h-full active:bg-state-accent-solid"></div>
|
||||
</div>
|
||||
<Tabs
|
||||
<div
|
||||
ref={containerRef}
|
||||
value={tabType}
|
||||
onValueChange={selectedValue => setTabType(selectedValue)}
|
||||
className={cn('flex h-full flex-col rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-lg transition-[width] ease-linear', showSingleRunPanel ? 'overflow-hidden' : 'overflow-y-auto')}
|
||||
style={{
|
||||
width: `${nodePanelWidth}px`,
|
||||
@ -572,14 +558,12 @@ const BasePanel: FC<BasePanelProps> = ({
|
||||
<HelpLink nodeType={data.type} />
|
||||
<NodeActionsDropdown id={id} data={data} showHelpLink={false} />
|
||||
<div className="mx-3 h-3.5 w-px bg-divider-regular" />
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('common.operation.close')}
|
||||
className="flex size-6 cursor-pointer items-center justify-center rounded-md hover:bg-state-base-hover focus-visible:ring-1 focus-visible:ring-components-input-border-hover focus-visible:outline-hidden"
|
||||
<div
|
||||
className="flex size-6 cursor-pointer items-center justify-center"
|
||||
onClick={() => handleNodeSelect(id, true)}
|
||||
>
|
||||
<RiCloseLine aria-hidden className="size-4 text-text-tertiary" />
|
||||
</button>
|
||||
<RiCloseLine className="size-4 text-text-tertiary" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-2">
|
||||
@ -600,7 +584,10 @@ const BasePanel: FC<BasePanelProps> = ({
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between pr-3 pl-4">
|
||||
{panelTabs}
|
||||
<Tab
|
||||
value={tabType}
|
||||
onChange={setTabType}
|
||||
/>
|
||||
<AuthorizedInNode
|
||||
pluginPayload={{
|
||||
provider: currToolCollection?.name || '',
|
||||
@ -622,7 +609,10 @@ const BasePanel: FC<BasePanelProps> = ({
|
||||
isAuthorized={currentDataSource.is_authorized}
|
||||
>
|
||||
<div className="flex items-center justify-between pr-3 pl-4">
|
||||
{panelTabs}
|
||||
<Tab
|
||||
value={tabType}
|
||||
onChange={setTabType}
|
||||
/>
|
||||
<AuthorizedInDataSourceNode
|
||||
onJumpToDataSourcePage={handleJumpToDataSourcePage}
|
||||
authorizationsNum={3}
|
||||
@ -637,68 +627,76 @@ const BasePanel: FC<BasePanelProps> = ({
|
||||
subscriptionIdSelected={data.subscription_id}
|
||||
onSubscriptionChange={handleSubscriptionChange}
|
||||
>
|
||||
{panelTabs}
|
||||
<Tab
|
||||
value={tabType}
|
||||
onChange={setTabType}
|
||||
/>
|
||||
</TriggerSubscription>
|
||||
)
|
||||
}
|
||||
{
|
||||
!needsToolAuth && !currentDataSource && !currentTriggerPlugin && (
|
||||
<div className="flex items-center justify-between pr-3 pl-4">
|
||||
{panelTabs}
|
||||
<Tab
|
||||
value={tabType}
|
||||
onChange={setTabType}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
<Split />
|
||||
</div>
|
||||
<TabsPanel value={TabType.settings} className="flex flex-1 flex-col overflow-y-auto">
|
||||
<div>
|
||||
{cloneElement(children as any, {
|
||||
id,
|
||||
data,
|
||||
panelProps: {
|
||||
getInputVars,
|
||||
toVarInputs,
|
||||
runInputData,
|
||||
setRunInputData,
|
||||
runResult,
|
||||
runInputDataRef,
|
||||
},
|
||||
})}
|
||||
{tabType === TabType.settings && (
|
||||
<div className="flex flex-1 flex-col overflow-y-auto">
|
||||
<div>
|
||||
{cloneElement(children as any, {
|
||||
id,
|
||||
data,
|
||||
panelProps: {
|
||||
getInputVars,
|
||||
toVarInputs,
|
||||
runInputData,
|
||||
setRunInputData,
|
||||
runResult,
|
||||
runInputDataRef,
|
||||
},
|
||||
})}
|
||||
</div>
|
||||
<Split />
|
||||
{
|
||||
hasRetryNode(data.type) && (
|
||||
<RetryOnPanel
|
||||
id={id}
|
||||
data={data}
|
||||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
hasErrorHandleNode(data.type) && (
|
||||
<ErrorHandleOnPanel
|
||||
id={id}
|
||||
data={data}
|
||||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
!!availableNextBlocks.length && (
|
||||
<div className="border-t-[0.5px] border-divider-regular p-4">
|
||||
<div className="mb-1 flex items-center system-sm-semibold-uppercase text-text-secondary">
|
||||
{t('panel.nextStep', { ns: 'workflow' }).toLocaleUpperCase()}
|
||||
</div>
|
||||
<div className="mb-2 system-xs-regular text-text-tertiary">
|
||||
{t('panel.addNextStep', { ns: 'workflow' })}
|
||||
</div>
|
||||
<NextStep selectedNode={selectedNode} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
{readmeEntranceComponent}
|
||||
</div>
|
||||
<Split />
|
||||
{
|
||||
hasRetryNode(data.type) && (
|
||||
<RetryOnPanel
|
||||
id={id}
|
||||
data={data}
|
||||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
hasErrorHandleNode(data.type) && (
|
||||
<ErrorHandleOnPanel
|
||||
id={id}
|
||||
data={data}
|
||||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
!!availableNextBlocks.length && (
|
||||
<div className="border-t-[0.5px] border-divider-regular p-4">
|
||||
<div className="mb-1 flex items-center system-sm-semibold-uppercase text-text-secondary">
|
||||
{t('panel.nextStep', { ns: 'workflow' }).toLocaleUpperCase()}
|
||||
</div>
|
||||
<div className="mb-2 system-xs-regular text-text-tertiary">
|
||||
{t('panel.addNextStep', { ns: 'workflow' })}
|
||||
</div>
|
||||
<NextStep selectedNode={selectedNode} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
{readmeEntranceComponent}
|
||||
</TabsPanel>
|
||||
)}
|
||||
|
||||
<TabsPanel value={TabType.lastRun} className="flex flex-1 flex-col">
|
||||
{tabType === TabType.lastRun && (
|
||||
<LastRun
|
||||
appId={appDetail?.id || ''}
|
||||
nodeId={id}
|
||||
@ -712,9 +710,9 @@ const BasePanel: FC<BasePanelProps> = ({
|
||||
isPaused={isPaused}
|
||||
{...passedLogParams}
|
||||
/>
|
||||
</TabsPanel>
|
||||
)}
|
||||
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -38,7 +38,7 @@ import { BlockEnum } from '@/app/components/workflow/types'
|
||||
import { isSupportCustomRunForm } from '@/app/components/workflow/utils'
|
||||
import { VALUE_SELECTOR_DELIMITER as DELIMITER } from '@/config'
|
||||
import { useInvalidLastRun } from '@/service/use-workflow'
|
||||
import { TabType } from '../types'
|
||||
import { TabType } from '../tab'
|
||||
|
||||
const singleRunFormParamsHooks: Record<BlockEnum, any> = {
|
||||
[BlockEnum.LLM]: useLLMSingleRunFormParams,
|
||||
|
||||
@ -0,0 +1,35 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import * as React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import TabHeader from '@/app/components/base/tab-header'
|
||||
|
||||
export enum TabType {
|
||||
settings = 'settings',
|
||||
lastRun = 'lastRun',
|
||||
relations = 'relations',
|
||||
}
|
||||
|
||||
type Props = {
|
||||
value: TabType
|
||||
onChange: (value: TabType) => void
|
||||
}
|
||||
|
||||
const Tab: FC<Props> = ({
|
||||
value,
|
||||
onChange,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<TabHeader
|
||||
items={[
|
||||
{ id: TabType.settings, name: t('debug.settingsTab', { ns: 'workflow' }).toLocaleUpperCase() },
|
||||
{ id: TabType.lastRun, name: t('debug.lastRunTab', { ns: 'workflow' }).toLocaleUpperCase() },
|
||||
]}
|
||||
itemClassName="ml-0"
|
||||
value={value}
|
||||
onChange={onChange as any}
|
||||
/>
|
||||
)
|
||||
}
|
||||
export default React.memo(Tab)
|
||||
@ -1,7 +0,0 @@
|
||||
export const TabType = {
|
||||
settings: 'settings',
|
||||
lastRun: 'lastRun',
|
||||
relations: 'relations',
|
||||
} as const
|
||||
|
||||
export type TabType = typeof TabType[keyof typeof TabType]
|
||||
Reference in New Issue
Block a user