mirror of
https://github.com/langgenius/dify.git
synced 2026-05-26 20:07:46 +08:00
Merge branch 'main' into feat/model-type-migration-script
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@ -257,5 +257,5 @@ scripts/stress-test/reports/
|
||||
|
||||
# Code Agent Folder
|
||||
.qoder/*
|
||||
.context/*
|
||||
.context/
|
||||
.eslintcache
|
||||
|
||||
@ -30,7 +30,7 @@ from clients.agent_backend.factory import create_agent_backend_run_client
|
||||
from clients.agent_backend.fake_client import FakeAgentBackendRunClient, FakeAgentBackendScenario
|
||||
from clients.agent_backend.request_builder import (
|
||||
AGENT_SOUL_PROMPT_LAYER_ID,
|
||||
DIFY_PLUGIN_CONTEXT_LAYER_ID,
|
||||
DIFY_EXECUTION_CONTEXT_LAYER_ID,
|
||||
WORKFLOW_NODE_JOB_PROMPT_LAYER_ID,
|
||||
WORKFLOW_USER_PROMPT_LAYER_ID,
|
||||
AgentBackendModelConfig,
|
||||
@ -42,7 +42,7 @@ from clients.agent_backend.request_builder import (
|
||||
|
||||
__all__ = [
|
||||
"AGENT_SOUL_PROMPT_LAYER_ID",
|
||||
"DIFY_PLUGIN_CONTEXT_LAYER_ID",
|
||||
"DIFY_EXECUTION_CONTEXT_LAYER_ID",
|
||||
"WORKFLOW_NODE_JOB_PROMPT_LAYER_ID",
|
||||
"WORKFLOW_USER_PROMPT_LAYER_ID",
|
||||
"AgentBackendError",
|
||||
|
||||
@ -4,7 +4,9 @@ This module is intentionally an adapter, not a wire DTO package. The emitted
|
||||
object is always ``dify_agent.protocol.CreateRunRequest`` so the Agent backend
|
||||
protocol has a single owner. API-only context such as Agent Soul vs workflow job
|
||||
prompt is preserved in layer names and metadata until the dedicated product
|
||||
schemas land in later phases.
|
||||
schemas land in later phases. Dify-owned execution identifiers are emitted as an
|
||||
explicit ``dify.execution_context`` layer so the run request stays fully
|
||||
composition-driven.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@ -15,18 +17,19 @@ from agenton.compositor import CompositorSessionSnapshot
|
||||
from agenton.layers import ExitIntent
|
||||
from agenton_collections.layers.plain import PLAIN_PROMPT_LAYER_TYPE_ID, PromptLayerConfig
|
||||
from dify_agent.layers.dify_plugin import (
|
||||
DIFY_PLUGIN_LAYER_TYPE_ID,
|
||||
DIFY_PLUGIN_LLM_LAYER_TYPE_ID,
|
||||
DifyPluginCredentialValue,
|
||||
DifyPluginLayerConfig,
|
||||
DifyPluginLLMLayerConfig,
|
||||
)
|
||||
from dify_agent.layers.execution_context import (
|
||||
DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID,
|
||||
DifyExecutionContextLayerConfig,
|
||||
)
|
||||
from dify_agent.layers.output import DIFY_OUTPUT_LAYER_TYPE_ID, DifyOutputLayerConfig
|
||||
from dify_agent.protocol import (
|
||||
DIFY_AGENT_MODEL_LAYER_ID,
|
||||
DIFY_AGENT_OUTPUT_LAYER_ID,
|
||||
CreateRunRequest,
|
||||
ExecutionContext,
|
||||
LayerExitSignals,
|
||||
RunComposition,
|
||||
RunLayerSpec,
|
||||
@ -37,17 +40,15 @@ from pydantic import BaseModel, ConfigDict, Field, JsonValue, field_validator
|
||||
AGENT_SOUL_PROMPT_LAYER_ID = "agent_soul_prompt"
|
||||
WORKFLOW_NODE_JOB_PROMPT_LAYER_ID = "workflow_node_job_prompt"
|
||||
WORKFLOW_USER_PROMPT_LAYER_ID = "workflow_user_prompt"
|
||||
DIFY_PLUGIN_CONTEXT_LAYER_ID = "plugin"
|
||||
DIFY_EXECUTION_CONTEXT_LAYER_ID = "execution_context"
|
||||
|
||||
|
||||
class AgentBackendModelConfig(BaseModel):
|
||||
"""API-side model/plugin selection before it is converted to Dify Agent layers."""
|
||||
|
||||
tenant_id: str
|
||||
plugin_id: str
|
||||
model_provider: str
|
||||
model: str
|
||||
user_id: str | None = None
|
||||
credentials: dict[str, DifyPluginCredentialValue] = Field(default_factory=dict)
|
||||
model_settings: dict[str, JsonValue] = Field(default_factory=dict)
|
||||
|
||||
@ -73,7 +74,7 @@ class AgentBackendWorkflowNodeRunInput(BaseModel):
|
||||
"""Inputs needed to build the first workflow-node-oriented Agent backend run request."""
|
||||
|
||||
model: AgentBackendModelConfig
|
||||
execution_context: ExecutionContext
|
||||
execution_context: DifyExecutionContextLayerConfig
|
||||
workflow_node_job_prompt: str
|
||||
user_prompt: str
|
||||
agent_soul_prompt: str | None = None
|
||||
@ -125,21 +126,18 @@ class AgentBackendRunRequestBuilder:
|
||||
config=PromptLayerConfig(user=run_input.user_prompt),
|
||||
),
|
||||
RunLayerSpec(
|
||||
name=DIFY_PLUGIN_CONTEXT_LAYER_ID,
|
||||
type=DIFY_PLUGIN_LAYER_TYPE_ID,
|
||||
name=DIFY_EXECUTION_CONTEXT_LAYER_ID,
|
||||
type=DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID,
|
||||
metadata=run_input.metadata,
|
||||
config=DifyPluginLayerConfig(
|
||||
tenant_id=run_input.model.tenant_id,
|
||||
plugin_id=run_input.model.plugin_id,
|
||||
user_id=run_input.model.user_id,
|
||||
),
|
||||
config=run_input.execution_context,
|
||||
),
|
||||
RunLayerSpec(
|
||||
name=DIFY_AGENT_MODEL_LAYER_ID,
|
||||
type=DIFY_PLUGIN_LLM_LAYER_TYPE_ID,
|
||||
deps={"plugin": DIFY_PLUGIN_CONTEXT_LAYER_ID},
|
||||
deps={"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID},
|
||||
metadata=run_input.metadata,
|
||||
config=DifyPluginLLMLayerConfig(
|
||||
plugin_id=run_input.model.plugin_id,
|
||||
model_provider=run_input.model.model_provider,
|
||||
model=run_input.model.model,
|
||||
credentials=run_input.model.credentials,
|
||||
@ -165,7 +163,6 @@ class AgentBackendRunRequestBuilder:
|
||||
|
||||
return CreateRunRequest(
|
||||
composition=RunComposition(layers=layers),
|
||||
execution_context=run_input.execution_context,
|
||||
purpose=run_input.purpose,
|
||||
idempotency_key=run_input.idempotency_key,
|
||||
metadata=run_input.metadata,
|
||||
|
||||
@ -22,9 +22,6 @@ from core.memory.token_buffer_memory import TokenBufferMemory
|
||||
from core.model_manager import ModelInstance
|
||||
from core.prompt.utils.extract_thread_messages import extract_thread_messages
|
||||
from core.tools.__base.tool import Tool
|
||||
from core.tools.entities.tool_entities import (
|
||||
ToolParameter,
|
||||
)
|
||||
from core.tools.tool_manager import ToolManager
|
||||
from core.tools.utils.dataset_retriever_tool import DatasetRetrieverTool
|
||||
from extensions.ext_database import db
|
||||
@ -150,44 +147,9 @@ class BaseAgentRunner(AppRunner):
|
||||
message_tool = PromptMessageTool(
|
||||
name=tool.tool_name,
|
||||
description=tool_entity.entity.description.llm,
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"required": [],
|
||||
},
|
||||
parameters=tool_entity.get_llm_parameters_json_schema(),
|
||||
)
|
||||
|
||||
parameters = tool_entity.get_merged_runtime_parameters()
|
||||
for parameter in parameters:
|
||||
if parameter.form != ToolParameter.ToolParameterForm.LLM:
|
||||
continue
|
||||
|
||||
parameter_type = parameter.type.as_normal_type()
|
||||
if parameter.type in {
|
||||
ToolParameter.ToolParameterType.SYSTEM_FILES,
|
||||
ToolParameter.ToolParameterType.FILE,
|
||||
ToolParameter.ToolParameterType.FILES,
|
||||
}:
|
||||
continue
|
||||
enum = []
|
||||
if parameter.type == ToolParameter.ToolParameterType.SELECT:
|
||||
enum = [option.value for option in parameter.options] if parameter.options else []
|
||||
|
||||
message_tool.parameters["properties"][parameter.name] = (
|
||||
{
|
||||
"type": parameter_type,
|
||||
"description": parameter.llm_description or "",
|
||||
}
|
||||
if parameter.input_schema is None
|
||||
else parameter.input_schema
|
||||
)
|
||||
|
||||
if len(enum) > 0:
|
||||
message_tool.parameters["properties"][parameter.name]["enum"] = enum
|
||||
|
||||
if parameter.required:
|
||||
message_tool.parameters["required"].append(parameter.name)
|
||||
|
||||
return message_tool, tool_entity
|
||||
|
||||
def _convert_dataset_retriever_tool_to_prompt_message_tool(self, tool: DatasetRetrieverTool) -> PromptMessageTool:
|
||||
@ -252,40 +214,7 @@ class BaseAgentRunner(AppRunner):
|
||||
"""
|
||||
update prompt message tool
|
||||
"""
|
||||
# try to get tool runtime parameters
|
||||
tool_runtime_parameters = tool.get_runtime_parameters()
|
||||
|
||||
for parameter in tool_runtime_parameters:
|
||||
if parameter.form != ToolParameter.ToolParameterForm.LLM:
|
||||
continue
|
||||
|
||||
parameter_type = parameter.type.as_normal_type()
|
||||
if parameter.type in {
|
||||
ToolParameter.ToolParameterType.SYSTEM_FILES,
|
||||
ToolParameter.ToolParameterType.FILE,
|
||||
ToolParameter.ToolParameterType.FILES,
|
||||
}:
|
||||
continue
|
||||
enum = []
|
||||
if parameter.type == ToolParameter.ToolParameterType.SELECT:
|
||||
enum = [option.value for option in parameter.options] if parameter.options else []
|
||||
|
||||
prompt_tool.parameters["properties"][parameter.name] = (
|
||||
{
|
||||
"type": parameter_type,
|
||||
"description": parameter.llm_description or "",
|
||||
}
|
||||
if parameter.input_schema is None
|
||||
else parameter.input_schema
|
||||
)
|
||||
|
||||
if len(enum) > 0:
|
||||
prompt_tool.parameters["properties"][parameter.name]["enum"] = enum
|
||||
|
||||
if parameter.required:
|
||||
if parameter.name not in prompt_tool.parameters["required"]:
|
||||
prompt_tool.parameters["required"].append(parameter.name)
|
||||
|
||||
prompt_tool.parameters = tool.get_llm_parameters_json_schema()
|
||||
return prompt_tool
|
||||
|
||||
def create_agent_thought(
|
||||
|
||||
@ -126,34 +126,89 @@ class Tool(ABC):
|
||||
message_id: str | None = None,
|
||||
) -> list[ToolParameter]:
|
||||
"""
|
||||
get merged runtime parameters
|
||||
Get the effective parameter declarations for this tool.
|
||||
|
||||
Runtime parameters override declared parameters by name and append new
|
||||
parameters, but the returned list is always detached from the tool's
|
||||
cached declarations so callers can safely mutate it while building
|
||||
downstream schemas.
|
||||
|
||||
:return: merged runtime parameters
|
||||
"""
|
||||
parameters = self.entity.parameters
|
||||
parameters = parameters.copy()
|
||||
user_parameters = self.get_runtime_parameters() or []
|
||||
user_parameters = user_parameters.copy()
|
||||
parameters = [deepcopy(parameter) for parameter in self.entity.parameters or []]
|
||||
user_parameters = [
|
||||
deepcopy(parameter)
|
||||
for parameter in self.get_runtime_parameters(
|
||||
conversation_id=conversation_id,
|
||||
app_id=app_id,
|
||||
message_id=message_id,
|
||||
)
|
||||
or []
|
||||
]
|
||||
|
||||
parameter_indexes = {parameter.name: index for index, parameter in enumerate(parameters)}
|
||||
|
||||
# override parameters
|
||||
for parameter in user_parameters:
|
||||
# check if parameter in tool parameters
|
||||
for tool_parameter in parameters:
|
||||
if tool_parameter.name == parameter.name:
|
||||
# override parameter
|
||||
tool_parameter.type = parameter.type
|
||||
tool_parameter.form = parameter.form
|
||||
tool_parameter.required = parameter.required
|
||||
tool_parameter.default = parameter.default
|
||||
tool_parameter.options = parameter.options
|
||||
tool_parameter.llm_description = parameter.llm_description
|
||||
break
|
||||
else:
|
||||
# add new parameter
|
||||
existing_index = parameter_indexes.get(parameter.name)
|
||||
if existing_index is None:
|
||||
parameter_indexes[parameter.name] = len(parameters)
|
||||
parameters.append(parameter)
|
||||
continue
|
||||
parameters[existing_index] = parameter
|
||||
|
||||
return parameters
|
||||
|
||||
def get_llm_parameters_json_schema(
|
||||
self,
|
||||
conversation_id: str | None = None,
|
||||
app_id: str | None = None,
|
||||
message_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Build the model-visible JSON schema from effective tool parameters.
|
||||
|
||||
Hidden/manual parameters stay available for invocation preparation on the
|
||||
API side, but are intentionally omitted from the LLM-facing schema.
|
||||
"""
|
||||
schema: dict[str, Any] = {
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"required": [],
|
||||
}
|
||||
|
||||
for parameter in self.get_merged_runtime_parameters(
|
||||
conversation_id=conversation_id,
|
||||
app_id=app_id,
|
||||
message_id=message_id,
|
||||
):
|
||||
if parameter.form != ToolParameter.ToolParameterForm.LLM:
|
||||
continue
|
||||
|
||||
if parameter.type in {
|
||||
ToolParameter.ToolParameterType.SYSTEM_FILES,
|
||||
ToolParameter.ToolParameterType.FILE,
|
||||
ToolParameter.ToolParameterType.FILES,
|
||||
}:
|
||||
continue
|
||||
|
||||
parameter_schema: dict[str, Any] = (
|
||||
{
|
||||
"type": parameter.type.as_normal_type(),
|
||||
"description": parameter.llm_description or "",
|
||||
}
|
||||
if parameter.input_schema is None
|
||||
else deepcopy(parameter.input_schema)
|
||||
)
|
||||
parameter_schema.setdefault("description", parameter.llm_description or "")
|
||||
|
||||
if parameter.type == ToolParameter.ToolParameterType.SELECT and parameter.options:
|
||||
parameter_schema["enum"] = [option.value for option in parameter.options]
|
||||
|
||||
schema["properties"][parameter.name] = parameter_schema
|
||||
if parameter.required:
|
||||
schema["required"].append(parameter.name)
|
||||
|
||||
return schema
|
||||
|
||||
def create_image_message(
|
||||
self,
|
||||
image: str,
|
||||
|
||||
@ -4,7 +4,8 @@ from collections.abc import Mapping, Sequence
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Literal, Protocol, cast
|
||||
|
||||
from dify_agent.protocol import CreateRunRequest, ExecutionContext
|
||||
from dify_agent.layers.execution_context import DifyExecutionContextLayerConfig
|
||||
from dify_agent.protocol import CreateRunRequest
|
||||
|
||||
from clients.agent_backend import (
|
||||
AgentBackendModelConfig,
|
||||
@ -105,16 +106,20 @@ class WorkflowAgentRuntimeRequestBuilder:
|
||||
request = self._request_builder.build_for_workflow_node(
|
||||
AgentBackendWorkflowNodeRunInput(
|
||||
model=AgentBackendModelConfig(
|
||||
tenant_id=context.dify_context.tenant_id,
|
||||
plugin_id=agent_soul.model.plugin_id,
|
||||
model_provider=agent_soul.model.model_provider,
|
||||
model=agent_soul.model.model,
|
||||
user_id=context.dify_context.user_id,
|
||||
credentials=self._normalize_credentials(credentials),
|
||||
model_settings=cast(dict[str, Any], agent_soul.model.model_settings),
|
||||
),
|
||||
execution_context=ExecutionContext(
|
||||
# The execution-context layer is now the only public protocol
|
||||
# carrier for Dify tenant/user/run identifiers. ``user_id`` must
|
||||
# be forwarded here because downstream plugin-daemon provider and
|
||||
# tool clients read it from this layer rather than from any
|
||||
# parallel top-level request field.
|
||||
execution_context=DifyExecutionContextLayerConfig(
|
||||
tenant_id=context.dify_context.tenant_id,
|
||||
user_id=context.dify_context.user_id,
|
||||
app_id=context.dify_context.app_id,
|
||||
workflow_id=context.workflow_id,
|
||||
workflow_run_id=context.workflow_run_id,
|
||||
|
||||
@ -2,12 +2,12 @@ from collections.abc import Iterator
|
||||
|
||||
import pytest
|
||||
from dify_agent.client import DifyAgentHTTPError, DifyAgentStreamError, DifyAgentTimeoutError, DifyAgentValidationError
|
||||
from dify_agent.layers.execution_context import DifyExecutionContextLayerConfig
|
||||
from dify_agent.protocol import (
|
||||
CancelRunRequest,
|
||||
CancelRunResponse,
|
||||
CreateRunRequest,
|
||||
CreateRunResponse,
|
||||
ExecutionContext,
|
||||
RunEvent,
|
||||
RunStartedEvent,
|
||||
RunStatusResponse,
|
||||
@ -29,12 +29,11 @@ def _request():
|
||||
return AgentBackendRunRequestBuilder().build_for_workflow_node(
|
||||
AgentBackendWorkflowNodeRunInput(
|
||||
model=AgentBackendModelConfig(
|
||||
tenant_id="tenant-1",
|
||||
plugin_id="langgenius/openai",
|
||||
model_provider="openai",
|
||||
model="gpt-test",
|
||||
),
|
||||
execution_context=ExecutionContext(tenant_id="tenant-1", invoke_from="workflow_run"),
|
||||
execution_context=DifyExecutionContextLayerConfig(tenant_id="tenant-1", invoke_from="workflow_run"),
|
||||
workflow_node_job_prompt="Do the task.",
|
||||
user_prompt="hello",
|
||||
)
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
from dify_agent.protocol import ExecutionContext
|
||||
from dify_agent.layers.execution_context import DifyExecutionContextLayerConfig
|
||||
|
||||
from clients.agent_backend import (
|
||||
AgentBackendModelConfig,
|
||||
@ -13,12 +13,11 @@ def _request():
|
||||
return AgentBackendRunRequestBuilder().build_for_workflow_node(
|
||||
AgentBackendWorkflowNodeRunInput(
|
||||
model=AgentBackendModelConfig(
|
||||
tenant_id="tenant-1",
|
||||
plugin_id="langgenius/openai",
|
||||
model_provider="openai",
|
||||
model="gpt-test",
|
||||
),
|
||||
execution_context=ExecutionContext(tenant_id="tenant-1", invoke_from="workflow_run"),
|
||||
execution_context=DifyExecutionContextLayerConfig(tenant_id="tenant-1", invoke_from="workflow_run"),
|
||||
workflow_node_job_prompt="Do the task.",
|
||||
user_prompt="hello",
|
||||
)
|
||||
|
||||
@ -1,18 +1,19 @@
|
||||
import pytest
|
||||
from agenton.layers import ExitIntent
|
||||
from agenton_collections.layers.plain import PLAIN_PROMPT_LAYER_TYPE_ID
|
||||
from dify_agent.layers.dify_plugin import DIFY_PLUGIN_LAYER_TYPE_ID, DIFY_PLUGIN_LLM_LAYER_TYPE_ID
|
||||
from dify_agent.layers.dify_plugin import DIFY_PLUGIN_LLM_LAYER_TYPE_ID
|
||||
from dify_agent.layers.execution_context import DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID, DifyExecutionContextLayerConfig
|
||||
from dify_agent.layers.output import DIFY_OUTPUT_LAYER_TYPE_ID
|
||||
from dify_agent.protocol import (
|
||||
DIFY_AGENT_MODEL_LAYER_ID,
|
||||
DIFY_AGENT_OUTPUT_LAYER_ID,
|
||||
CreateRunRequest,
|
||||
ExecutionContext,
|
||||
)
|
||||
from pydantic import ValidationError
|
||||
|
||||
from clients.agent_backend import (
|
||||
AGENT_SOUL_PROMPT_LAYER_ID,
|
||||
DIFY_EXECUTION_CONTEXT_LAYER_ID,
|
||||
WORKFLOW_NODE_JOB_PROMPT_LAYER_ID,
|
||||
WORKFLOW_USER_PROMPT_LAYER_ID,
|
||||
AgentBackendModelConfig,
|
||||
@ -26,15 +27,14 @@ from clients.agent_backend import (
|
||||
def _run_input() -> AgentBackendWorkflowNodeRunInput:
|
||||
return AgentBackendWorkflowNodeRunInput(
|
||||
model=AgentBackendModelConfig(
|
||||
tenant_id="tenant-1",
|
||||
plugin_id="langgenius/openai",
|
||||
user_id="user-1",
|
||||
model_provider="openai",
|
||||
model="gpt-test",
|
||||
credentials={"api_key": "secret-key"},
|
||||
),
|
||||
execution_context=ExecutionContext(
|
||||
execution_context=DifyExecutionContextLayerConfig(
|
||||
tenant_id="tenant-1",
|
||||
user_id="user-1",
|
||||
workflow_id="workflow-1",
|
||||
workflow_run_id="workflow-run-1",
|
||||
node_id="node-1",
|
||||
@ -64,13 +64,11 @@ def test_request_builder_outputs_dify_agent_create_run_request():
|
||||
AGENT_SOUL_PROMPT_LAYER_ID,
|
||||
WORKFLOW_NODE_JOB_PROMPT_LAYER_ID,
|
||||
WORKFLOW_USER_PROMPT_LAYER_ID,
|
||||
"plugin",
|
||||
DIFY_EXECUTION_CONTEXT_LAYER_ID,
|
||||
DIFY_AGENT_MODEL_LAYER_ID,
|
||||
DIFY_AGENT_OUTPUT_LAYER_ID,
|
||||
]
|
||||
assert request.on_exit.default is ExitIntent.DELETE
|
||||
assert request.execution_context is not None
|
||||
assert request.execution_context.node_execution_id == "node-execution-1"
|
||||
assert request.idempotency_key == "workflow-run-1:node-execution-1"
|
||||
assert request.metadata == {"workflow_id": "workflow-1", "node_id": "node-1"}
|
||||
|
||||
@ -94,9 +92,11 @@ def test_request_builder_sets_model_and_output_layer_contract_ids():
|
||||
request = AgentBackendRunRequestBuilder().build_for_workflow_node(_run_input())
|
||||
layers = {layer.name: layer for layer in request.composition.layers}
|
||||
|
||||
assert layers["plugin"].type == DIFY_PLUGIN_LAYER_TYPE_ID
|
||||
assert layers[DIFY_EXECUTION_CONTEXT_LAYER_ID].type == DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID
|
||||
assert layers[DIFY_EXECUTION_CONTEXT_LAYER_ID].config.user_id == "user-1"
|
||||
assert layers[DIFY_AGENT_MODEL_LAYER_ID].type == DIFY_PLUGIN_LLM_LAYER_TYPE_ID
|
||||
assert layers[DIFY_AGENT_MODEL_LAYER_ID].deps == {"plugin": "plugin"}
|
||||
assert layers[DIFY_AGENT_MODEL_LAYER_ID].config.plugin_id == "langgenius/openai"
|
||||
assert layers[DIFY_AGENT_MODEL_LAYER_ID].deps == {"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID}
|
||||
assert layers[DIFY_AGENT_OUTPUT_LAYER_ID].type == DIFY_OUTPUT_LAYER_TYPE_ID
|
||||
|
||||
|
||||
@ -113,12 +113,11 @@ def test_request_builder_rejects_blank_prompts():
|
||||
with pytest.raises(ValidationError):
|
||||
AgentBackendWorkflowNodeRunInput(
|
||||
model=AgentBackendModelConfig(
|
||||
tenant_id="tenant-1",
|
||||
plugin_id="langgenius/openai",
|
||||
model_provider="openai",
|
||||
model="gpt-test",
|
||||
),
|
||||
execution_context=ExecutionContext(tenant_id="tenant-1", invoke_from="workflow_run"),
|
||||
execution_context=DifyExecutionContextLayerConfig(tenant_id="tenant-1", invoke_from="workflow_run"),
|
||||
workflow_node_job_prompt=" ",
|
||||
user_prompt="hello",
|
||||
)
|
||||
|
||||
@ -61,79 +61,20 @@ class TestRepack:
|
||||
|
||||
|
||||
class TestUpdatePromptTool:
|
||||
def build_param(self, mocker: MockerFixture, **kwargs):
|
||||
p = mocker.MagicMock()
|
||||
p.form = kwargs.get("form")
|
||||
|
||||
mock_type = mocker.MagicMock()
|
||||
mock_type.as_normal_type.return_value = "string"
|
||||
p.type = mock_type
|
||||
|
||||
p.name = kwargs.get("name", "p1")
|
||||
p.llm_description = "desc"
|
||||
p.input_schema = kwargs.get("input_schema")
|
||||
p.options = kwargs.get("options")
|
||||
p.required = kwargs.get("required", False)
|
||||
return p
|
||||
|
||||
def test_skip_non_llm(self, runner, mocker: MockerFixture):
|
||||
def test_replaces_prompt_tool_parameters_with_tool_schema(self, runner, mocker: MockerFixture):
|
||||
tool = mocker.MagicMock()
|
||||
param = self.build_param(mocker, form="NOT_LLM")
|
||||
tool.get_runtime_parameters.return_value = [param]
|
||||
schema = {
|
||||
"type": "object",
|
||||
"properties": {"p1": {"type": "string", "description": "desc"}},
|
||||
"required": ["p1"],
|
||||
}
|
||||
tool.get_llm_parameters_json_schema.return_value = schema
|
||||
|
||||
prompt_tool = mocker.MagicMock()
|
||||
prompt_tool.parameters = {"properties": {}, "required": []}
|
||||
|
||||
result = runner.update_prompt_message_tool(tool, prompt_tool)
|
||||
assert result.parameters["properties"] == {}
|
||||
|
||||
def test_enum_and_required(self, runner, mocker: MockerFixture):
|
||||
option = mocker.MagicMock(value="opt1")
|
||||
param = self.build_param(
|
||||
mocker,
|
||||
form=module.ToolParameter.ToolParameterForm.LLM,
|
||||
options=[option],
|
||||
required=True,
|
||||
)
|
||||
|
||||
tool = mocker.MagicMock()
|
||||
tool.get_runtime_parameters.return_value = [param]
|
||||
|
||||
prompt_tool = mocker.MagicMock()
|
||||
prompt_tool.parameters = {"properties": {}, "required": []}
|
||||
|
||||
result = runner.update_prompt_message_tool(tool, prompt_tool)
|
||||
assert "p1" in result.parameters["required"]
|
||||
|
||||
def test_skip_file_type_param(self, runner, mocker: MockerFixture):
|
||||
tool = mocker.MagicMock()
|
||||
param = self.build_param(mocker, form=module.ToolParameter.ToolParameterForm.LLM)
|
||||
param.type = module.ToolParameter.ToolParameterType.FILE
|
||||
tool.get_runtime_parameters.return_value = [param]
|
||||
|
||||
prompt_tool = mocker.MagicMock()
|
||||
prompt_tool.parameters = {"properties": {}, "required": []}
|
||||
|
||||
result = runner.update_prompt_message_tool(tool, prompt_tool)
|
||||
assert result.parameters["properties"] == {}
|
||||
|
||||
def test_duplicate_required_not_duplicated(self, runner, mocker: MockerFixture):
|
||||
tool = mocker.MagicMock()
|
||||
|
||||
param = self.build_param(
|
||||
mocker,
|
||||
form=module.ToolParameter.ToolParameterForm.LLM,
|
||||
required=True,
|
||||
)
|
||||
|
||||
tool.get_runtime_parameters.return_value = [param]
|
||||
|
||||
prompt_tool = mocker.MagicMock()
|
||||
prompt_tool.parameters = {"properties": {}, "required": ["p1"]}
|
||||
|
||||
result = runner.update_prompt_message_tool(tool, prompt_tool)
|
||||
|
||||
assert result.parameters["required"].count("p1") == 1
|
||||
assert result.parameters == schema
|
||||
|
||||
|
||||
# ==========================================================
|
||||
@ -383,57 +324,21 @@ class TestConvertToolToPromptMessageTool:
|
||||
def test_basic_conversion(self, runner, mocker: MockerFixture):
|
||||
tool = mocker.MagicMock(tool_name="tool1")
|
||||
|
||||
runtime_param = mocker.MagicMock()
|
||||
runtime_param.form = module.ToolParameter.ToolParameterForm.LLM
|
||||
runtime_param.name = "param1"
|
||||
runtime_param.llm_description = "desc"
|
||||
runtime_param.required = True
|
||||
runtime_param.input_schema = None
|
||||
runtime_param.options = None
|
||||
|
||||
mock_type = mocker.MagicMock()
|
||||
mock_type.as_normal_type.return_value = "string"
|
||||
runtime_param.type = mock_type
|
||||
|
||||
tool_entity = mocker.MagicMock()
|
||||
tool_entity.entity.description.llm = "desc"
|
||||
tool_entity.get_merged_runtime_parameters.return_value = [runtime_param]
|
||||
schema = {
|
||||
"type": "object",
|
||||
"properties": {"param1": {"type": "string", "description": "desc"}},
|
||||
"required": ["param1"],
|
||||
}
|
||||
tool_entity.get_llm_parameters_json_schema.return_value = schema
|
||||
|
||||
mocker.patch.object(module.ToolManager, "get_agent_tool_runtime", return_value=tool_entity)
|
||||
mocker.patch.object(module, "PromptMessageTool", side_effect=lambda **kw: MagicMock(**kw))
|
||||
|
||||
prompt_tool, entity = runner._convert_tool_to_prompt_message_tool(tool)
|
||||
assert entity == tool_entity
|
||||
|
||||
def test_full_conversion_multiple_params(self, runner, mocker: MockerFixture):
|
||||
tool = mocker.MagicMock(tool_name="tool1")
|
||||
|
||||
# LLM param with input_schema override
|
||||
param1 = mocker.MagicMock()
|
||||
param1.form = module.ToolParameter.ToolParameterForm.LLM
|
||||
param1.name = "p1"
|
||||
param1.llm_description = "desc"
|
||||
param1.required = True
|
||||
param1.input_schema = {"type": "integer"}
|
||||
param1.options = None
|
||||
param1.type = mocker.MagicMock()
|
||||
|
||||
# SYSTEM_FILES param should be skipped
|
||||
param2 = mocker.MagicMock()
|
||||
param2.form = module.ToolParameter.ToolParameterForm.LLM
|
||||
param2.name = "file_param"
|
||||
param2.type = module.ToolParameter.ToolParameterType.SYSTEM_FILES
|
||||
|
||||
tool_entity = mocker.MagicMock()
|
||||
tool_entity.entity.description.llm = "desc"
|
||||
tool_entity.get_merged_runtime_parameters.return_value = [param1, param2]
|
||||
|
||||
mocker.patch.object(module.ToolManager, "get_agent_tool_runtime", return_value=tool_entity)
|
||||
mocker.patch.object(module, "PromptMessageTool", side_effect=lambda **kw: MagicMock(**kw))
|
||||
|
||||
prompt_tool, entity = runner._convert_tool_to_prompt_message_tool(tool)
|
||||
|
||||
assert entity == tool_entity
|
||||
assert prompt_tool.parameters == schema
|
||||
|
||||
|
||||
# ==========================================================
|
||||
@ -465,29 +370,6 @@ class TestInitPromptToolsExtended:
|
||||
|
||||
|
||||
class TestAdditionalCoverage:
|
||||
def test_update_prompt_with_input_schema(self, runner, mocker: MockerFixture):
|
||||
tool = mocker.MagicMock()
|
||||
|
||||
param = mocker.MagicMock()
|
||||
param.form = module.ToolParameter.ToolParameterForm.LLM
|
||||
param.name = "p1"
|
||||
param.required = False
|
||||
param.llm_description = "desc"
|
||||
param.options = None
|
||||
param.input_schema = {"type": "number"}
|
||||
|
||||
mock_type = mocker.MagicMock()
|
||||
mock_type.as_normal_type.return_value = "string"
|
||||
param.type = mock_type
|
||||
|
||||
tool.get_runtime_parameters.return_value = [param]
|
||||
|
||||
prompt_tool = mocker.MagicMock()
|
||||
prompt_tool.parameters = {"properties": {}, "required": []}
|
||||
|
||||
result = runner.update_prompt_message_tool(tool, prompt_tool)
|
||||
assert result.parameters["properties"]["p1"]["type"] == "number"
|
||||
|
||||
def test_save_agent_thought_existing_labels(self, runner, mock_db_session, mocker: MockerFixture):
|
||||
agent = mocker.MagicMock()
|
||||
agent.tool = "tool1"
|
||||
@ -571,33 +453,6 @@ class TestAdditionalCoverage:
|
||||
result = runner.organize_agent_history([])
|
||||
assert isinstance(result, list)
|
||||
|
||||
# ================= Additional Surgical Coverage =================
|
||||
|
||||
def test_convert_tool_select_enum_branch(self, runner, mocker: MockerFixture):
|
||||
tool = mocker.MagicMock(tool_name="tool1")
|
||||
|
||||
param = mocker.MagicMock()
|
||||
param.form = module.ToolParameter.ToolParameterForm.LLM
|
||||
param.name = "select_param"
|
||||
param.required = True
|
||||
param.llm_description = "desc"
|
||||
param.input_schema = None
|
||||
|
||||
option1 = mocker.MagicMock(value="A")
|
||||
option2 = mocker.MagicMock(value="B")
|
||||
param.options = [option1, option2]
|
||||
param.type = module.ToolParameter.ToolParameterType.SELECT
|
||||
|
||||
tool_entity = mocker.MagicMock()
|
||||
tool_entity.entity.description.llm = "desc"
|
||||
tool_entity.get_merged_runtime_parameters.return_value = [param]
|
||||
|
||||
mocker.patch.object(module.ToolManager, "get_agent_tool_runtime", return_value=tool_entity)
|
||||
mocker.patch.object(module, "PromptMessageTool", side_effect=lambda **kw: MagicMock(**kw))
|
||||
|
||||
prompt_tool, _ = runner._convert_tool_to_prompt_message_tool(tool)
|
||||
assert prompt_tool is not None
|
||||
|
||||
|
||||
class TestConvertDatasetRetrieverTool:
|
||||
def test_required_param_added(self, runner, mocker: MockerFixture):
|
||||
@ -663,24 +518,6 @@ class TestBaseAgentRunnerInit:
|
||||
|
||||
|
||||
class TestBaseAgentRunnerCoverage:
|
||||
def test_convert_tool_skips_non_llm_param(self, runner, mocker: MockerFixture):
|
||||
tool = mocker.MagicMock(tool_name="tool1")
|
||||
|
||||
param = mocker.MagicMock()
|
||||
param.form = "NOT_LLM"
|
||||
param.type = mocker.MagicMock()
|
||||
|
||||
tool_entity = mocker.MagicMock()
|
||||
tool_entity.entity.description.llm = "desc"
|
||||
tool_entity.get_merged_runtime_parameters.return_value = [param]
|
||||
|
||||
mocker.patch.object(module.ToolManager, "get_agent_tool_runtime", return_value=tool_entity)
|
||||
mocker.patch.object(module, "PromptMessageTool", side_effect=lambda **kw: MagicMock(**kw))
|
||||
|
||||
prompt_tool, _ = runner._convert_tool_to_prompt_message_tool(tool)
|
||||
|
||||
assert prompt_tool.parameters["properties"] == {}
|
||||
|
||||
def test_init_prompt_tools_adds_dataset_tools(self, runner, mocker: MockerFixture):
|
||||
dataset_tool = mocker.MagicMock()
|
||||
dataset_tool.entity.identity.name = "ds"
|
||||
@ -693,30 +530,6 @@ class TestBaseAgentRunnerCoverage:
|
||||
assert tools["ds"] == dataset_tool
|
||||
assert len(prompt_tools) == 1
|
||||
|
||||
def test_update_prompt_message_tool_select_enum(self, runner, mocker: MockerFixture):
|
||||
tool = mocker.MagicMock()
|
||||
|
||||
option1 = mocker.MagicMock(value="A")
|
||||
option2 = mocker.MagicMock(value="B")
|
||||
|
||||
param = mocker.MagicMock()
|
||||
param.form = module.ToolParameter.ToolParameterForm.LLM
|
||||
param.name = "select_param"
|
||||
param.required = False
|
||||
param.llm_description = "desc"
|
||||
param.input_schema = None
|
||||
param.options = [option1, option2]
|
||||
param.type = module.ToolParameter.ToolParameterType.SELECT
|
||||
|
||||
tool.get_runtime_parameters.return_value = [param]
|
||||
|
||||
prompt_tool = mocker.MagicMock()
|
||||
prompt_tool.parameters = {"properties": {}, "required": []}
|
||||
|
||||
result = runner.update_prompt_message_tool(tool, prompt_tool)
|
||||
|
||||
assert result.parameters["properties"]["select_param"]["enum"] == ["A", "B"]
|
||||
|
||||
def test_save_agent_thought_json_dumps_fallbacks(self, runner, mock_db_session, mocker: MockerFixture):
|
||||
agent = mocker.MagicMock()
|
||||
agent.tool = "tool1"
|
||||
|
||||
@ -8,7 +8,13 @@ from core.app.entities.app_invoke_entities import InvokeFrom
|
||||
from core.tools.__base.tool import Tool
|
||||
from core.tools.__base.tool_runtime import ToolRuntime
|
||||
from core.tools.entities.common_entities import I18nObject
|
||||
from core.tools.entities.tool_entities import ToolEntity, ToolIdentity, ToolInvokeMessage, ToolProviderType
|
||||
from core.tools.entities.tool_entities import (
|
||||
ToolEntity,
|
||||
ToolIdentity,
|
||||
ToolInvokeMessage,
|
||||
ToolParameter,
|
||||
ToolProviderType,
|
||||
)
|
||||
|
||||
|
||||
class DummyCastType:
|
||||
@ -25,6 +31,7 @@ class DummyParameter:
|
||||
default: Any = None
|
||||
options: list[Any] | None = None
|
||||
llm_description: str | None = None
|
||||
input_schema: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class DummyTool(Tool):
|
||||
@ -149,13 +156,27 @@ def test_fork_tool_runtime_returns_new_tool_with_copied_entity():
|
||||
|
||||
def test_get_runtime_parameters_and_merge_runtime_parameters():
|
||||
tool = _build_tool()
|
||||
original = DummyParameter(name="temperature", type=DummyCastType(), form="schema", required=True, default="0.7")
|
||||
original = DummyParameter(
|
||||
name="temperature",
|
||||
type=DummyCastType(),
|
||||
form="schema",
|
||||
required=True,
|
||||
default="0.7",
|
||||
input_schema={"type": "string"},
|
||||
)
|
||||
tool.entity.parameters = cast(Any, [original])
|
||||
|
||||
default_runtime_parameters = tool.get_runtime_parameters()
|
||||
assert default_runtime_parameters == [original]
|
||||
|
||||
override = DummyParameter(name="temperature", type=DummyCastType(), form="llm", required=False, default="0.5")
|
||||
override = DummyParameter(
|
||||
name="temperature",
|
||||
type=DummyCastType(),
|
||||
form="llm",
|
||||
required=False,
|
||||
default="0.5",
|
||||
input_schema={"type": "object"},
|
||||
)
|
||||
appended = DummyParameter(name="new_param", type=DummyCastType(), form="form", required=False, default="x")
|
||||
tool.runtime_parameter_overrides = [override, appended]
|
||||
|
||||
@ -165,7 +186,93 @@ def test_get_runtime_parameters_and_merge_runtime_parameters():
|
||||
assert merged[0].form == "llm"
|
||||
assert merged[0].required is False
|
||||
assert merged[0].default == "0.5"
|
||||
assert merged[0].input_schema == {"type": "object"}
|
||||
assert merged[1].name == "new_param"
|
||||
assert merged[0] is not original
|
||||
assert merged[1] is not appended
|
||||
assert original.form == "schema"
|
||||
assert original.required is True
|
||||
assert original.default == "0.7"
|
||||
assert original.input_schema == {"type": "string"}
|
||||
|
||||
|
||||
def test_get_llm_parameters_json_schema_uses_effective_runtime_parameters():
|
||||
tool = _build_tool()
|
||||
query_parameter = ToolParameter.get_simple_instance(
|
||||
name="query",
|
||||
llm_description="Declared query",
|
||||
typ=ToolParameter.ToolParameterType.STRING,
|
||||
required=True,
|
||||
)
|
||||
region_parameter = ToolParameter.get_simple_instance(
|
||||
name="region",
|
||||
llm_description="Search region",
|
||||
typ=ToolParameter.ToolParameterType.SELECT,
|
||||
required=False,
|
||||
options=["global", "cn"],
|
||||
)
|
||||
hidden_parameter = ToolParameter.get_simple_instance(
|
||||
name="api_key",
|
||||
llm_description="Hidden api key",
|
||||
typ=ToolParameter.ToolParameterType.STRING,
|
||||
required=True,
|
||||
)
|
||||
hidden_parameter.form = ToolParameter.ToolParameterForm.FORM
|
||||
file_parameter = ToolParameter.get_simple_instance(
|
||||
name="attachment",
|
||||
llm_description="Attachment",
|
||||
typ=ToolParameter.ToolParameterType.FILE,
|
||||
required=False,
|
||||
)
|
||||
payload_parameter = ToolParameter(
|
||||
name="payload",
|
||||
label=I18nObject(en_US="payload", zh_Hans="payload"),
|
||||
placeholder=None,
|
||||
human_description=I18nObject(en_US="payload", zh_Hans="payload"),
|
||||
type=ToolParameter.ToolParameterType.OBJECT,
|
||||
form=ToolParameter.ToolParameterForm.LLM,
|
||||
llm_description="Payload",
|
||||
required=False,
|
||||
input_schema={
|
||||
"type": "object",
|
||||
"properties": {"nested": {"type": "string"}},
|
||||
},
|
||||
)
|
||||
tool.entity.parameters = [query_parameter, region_parameter, hidden_parameter, file_parameter, payload_parameter]
|
||||
|
||||
query_override = ToolParameter.get_simple_instance(
|
||||
name="query",
|
||||
llm_description="Runtime query",
|
||||
typ=ToolParameter.ToolParameterType.STRING,
|
||||
required=True,
|
||||
)
|
||||
tool.runtime_parameter_overrides = [query_override]
|
||||
|
||||
schema = tool.get_llm_parameters_json_schema()
|
||||
|
||||
assert schema == {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {"type": "string", "description": "Runtime query"},
|
||||
"region": {
|
||||
"type": "string",
|
||||
"description": "Search region",
|
||||
"enum": ["global", "cn"],
|
||||
},
|
||||
"payload": {
|
||||
"type": "object",
|
||||
"properties": {"nested": {"type": "string"}},
|
||||
"description": "Payload",
|
||||
},
|
||||
},
|
||||
"required": ["query"],
|
||||
}
|
||||
|
||||
schema["properties"]["payload"]["properties"]["nested"]["type"] = "number"
|
||||
assert payload_parameter.input_schema == {
|
||||
"type": "object",
|
||||
"properties": {"nested": {"type": "string"}},
|
||||
}
|
||||
|
||||
|
||||
def test_message_factory_helpers():
|
||||
|
||||
@ -2,6 +2,7 @@ from dataclasses import replace
|
||||
|
||||
import pytest
|
||||
|
||||
from clients.agent_backend import DIFY_EXECUTION_CONTEXT_LAYER_ID
|
||||
from core.app.entities.app_invoke_entities import DifyRunContext, InvokeFrom, UserFrom
|
||||
from core.workflow.nodes.agent_v2.runtime_request_builder import (
|
||||
WorkflowAgentRuntimeBuildContext,
|
||||
@ -93,9 +94,10 @@ def test_builds_create_run_request_from_agent_soul_and_node_job():
|
||||
result = WorkflowAgentRuntimeRequestBuilder(credentials_provider=FakeCredentialsProvider()).build(_context())
|
||||
|
||||
dumped = result.request.model_dump(mode="json")
|
||||
assert dumped["execution_context"]["agent_id"] == "agent-1"
|
||||
assert dumped["execution_context"]["agent_config_version_id"] == "snapshot-1"
|
||||
assert dumped["execution_context"]["invoke_from"] == "single_step"
|
||||
layers = {layer["name"]: layer for layer in dumped["composition"]["layers"]}
|
||||
assert layers[DIFY_EXECUTION_CONTEXT_LAYER_ID]["config"]["agent_id"] == "agent-1"
|
||||
assert layers[DIFY_EXECUTION_CONTEXT_LAYER_ID]["config"]["agent_config_version_id"] == "snapshot-1"
|
||||
assert layers[DIFY_EXECUTION_CONTEXT_LAYER_ID]["config"]["invoke_from"] == "single_step"
|
||||
assert dumped["idempotency_key"] == "run-1:node-exec-1"
|
||||
assert dumped["composition"]["layers"][0]["config"]["prefix"] == "You are careful."
|
||||
assert dumped["composition"]["layers"][1]["config"]["prefix"] == "Use the previous output."
|
||||
@ -145,7 +147,8 @@ def test_builds_workflow_run_request_with_file_output_schema_and_reserved_metada
|
||||
result = WorkflowAgentRuntimeRequestBuilder(credentials_provider=FakeCredentialsProvider()).build(context)
|
||||
|
||||
dumped = result.request.model_dump(mode="json")
|
||||
assert dumped["execution_context"]["invoke_from"] == "workflow_run"
|
||||
layers = {layer["name"]: layer for layer in dumped["composition"]["layers"]}
|
||||
assert layers[DIFY_EXECUTION_CONTEXT_LAYER_ID]["config"]["invoke_from"] == "workflow_run"
|
||||
assert dumped["idempotency_key"] == "node-exec-1"
|
||||
output_schema = dumped["composition"]["layers"][-1]["config"]["json_schema"]
|
||||
assert output_schema["properties"]["report"]["properties"]["file_id"]["type"] == "string"
|
||||
|
||||
@ -2,5 +2,3 @@
|
||||
|
||||
- [User guide](guide/index.md) explains how to compose layers, register config-backed
|
||||
plugins, use system/user prompts, and snapshot sessions.
|
||||
- [API reference](api/index.md) lists the public Agenton classes, methods, and extension
|
||||
points.
|
||||
|
||||
@ -111,11 +111,10 @@ import sys
|
||||
|
||||
from agenton_collections.layers.plain import PLAIN_PROMPT_LAYER_TYPE_ID, PromptLayerConfig
|
||||
from dify_agent.client import Client
|
||||
from dify_agent.layers.execution_context import DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID, DifyExecutionContextLayerConfig
|
||||
from dify_agent.layers.dify_plugin import (
|
||||
DIFY_PLUGIN_LAYER_TYPE_ID,
|
||||
DIFY_PLUGIN_LLM_LAYER_TYPE_ID,
|
||||
DifyPluginLLMLayerConfig,
|
||||
DifyPluginLayerConfig,
|
||||
)
|
||||
from dify_agent.protocol import DIFY_AGENT_MODEL_LAYER_ID, CreateRunRequest, RunComposition, RunLayerSpec
|
||||
|
||||
@ -147,19 +146,20 @@ def build_request() -> CreateRunRequest:
|
||||
config=PromptLayerConfig(prefix=SYSTEM_PROMPT, user=USER_PROMPT),
|
||||
),
|
||||
RunLayerSpec(
|
||||
name="plugin",
|
||||
type=DIFY_PLUGIN_LAYER_TYPE_ID,
|
||||
config=DifyPluginLayerConfig(
|
||||
name="execution_context",
|
||||
type=DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID,
|
||||
config=DifyExecutionContextLayerConfig(
|
||||
tenant_id=TENANT_ID,
|
||||
plugin_id=PLUGIN_ID,
|
||||
user_id=USER_ID,
|
||||
invoke_from="workflow_run",
|
||||
),
|
||||
),
|
||||
RunLayerSpec(
|
||||
name=DIFY_AGENT_MODEL_LAYER_ID,
|
||||
type=DIFY_PLUGIN_LLM_LAYER_TYPE_ID,
|
||||
deps={"plugin": "plugin"},
|
||||
deps={"execution_context": "execution_context"},
|
||||
config=DifyPluginLLMLayerConfig(
|
||||
plugin_id=PLUGIN_ID,
|
||||
model_provider=MODEL_PROVIDER,
|
||||
model=MODEL_NAME,
|
||||
credentials=MODEL_CREDENTIALS,
|
||||
|
||||
@ -61,9 +61,11 @@ record TTL so active runs that keep producing events remain observable.
|
||||
|
||||
## Scheduling and shutdown semantics
|
||||
|
||||
`POST /runs` validates the composition, persists a `running` run record, and starts
|
||||
an `asyncio` task in the same process. There is no Redis job stream, consumer
|
||||
group, pending reclaim, or automatic retry layer.
|
||||
`POST /runs` persists a `running` run record and starts an `asyncio` task in the
|
||||
same process. There is no Redis job stream, consumer group, pending reclaim, or
|
||||
automatic retry layer. Request-shaped runtime failures such as bad composition,
|
||||
prompt, output, or snapshot inputs are reported later as failed runs rather than
|
||||
rejected synchronously once the request DTO itself is accepted.
|
||||
|
||||
During FastAPI shutdown the scheduler rejects new runs, waits up to
|
||||
`DIFY_AGENT_SHUTDOWN_GRACE_SECONDS` for active tasks, then cancels remaining tasks
|
||||
|
||||
@ -4,5 +4,4 @@ Dify Agent hosts Agenton-composed Pydantic AI runs behind a FastAPI API. Its
|
||||
source code stays under `src/dify_agent`, while framework-neutral Agenton code
|
||||
stays under `src/agenton` and `src/agenton_collections`.
|
||||
|
||||
See the [operations guide](guide/index.md) for local server behavior and the
|
||||
[run API](api/index.md) for request and event schemas.
|
||||
See the [operations guide](guide/index.md) for local server behavior.
|
||||
|
||||
@ -0,0 +1,67 @@
|
||||
# Execution context layer
|
||||
|
||||
The execution-context layer carries shared Dify run identifiers plus the tenant
|
||||
and optional user context needed for plugin-daemon calls. Server settings still
|
||||
provide the plugin daemon URL and API key.
|
||||
|
||||
Use it together with a [plugin LLM layer](../plugin-llm-layer/index.md) and,
|
||||
when the caller wants Dify tools exposed to the model, a
|
||||
[plugin tool layer](../plugin-tool-layer/index.md). Both business layers depend
|
||||
on this layer to reach the plugin daemon.
|
||||
|
||||
## Config fields
|
||||
|
||||
| Field | Type | Meaning |
|
||||
| --- | --- | --- |
|
||||
| `tenant_id` | `str` | Dify tenant/workspace id used when calling the plugin daemon. |
|
||||
| `user_id` | `str \| None` | Optional end-user id passed through to the plugin daemon. |
|
||||
| `invoke_from` | `Literal[...]` | Dify caller category recorded for observability and correlation. |
|
||||
| `app_id` / `workflow_id` / `workflow_run_id` / `node_id` / `node_execution_id` / `conversation_id` / `agent_id` / `agent_config_version_id` / `trace_id` | `str \| None` | Optional Dify-owned execution identifiers forwarded with the run. |
|
||||
|
||||
The execution-context layer type id is `dify.execution_context`.
|
||||
|
||||
## Basic usage
|
||||
|
||||
```python {test="skip" lint="skip"}
|
||||
from dify_agent.layers.execution_context import (
|
||||
DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID,
|
||||
DifyExecutionContextLayerConfig,
|
||||
)
|
||||
from dify_agent.protocol import RunLayerSpec
|
||||
|
||||
|
||||
execution_context_layer = RunLayerSpec(
|
||||
name="execution_context",
|
||||
type=DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID,
|
||||
config=DifyExecutionContextLayerConfig(
|
||||
tenant_id="replace-with-tenant-id",
|
||||
user_id="replace-with-user-id",
|
||||
invoke_from="workflow_run",
|
||||
),
|
||||
)
|
||||
```
|
||||
|
||||
If you do not need a user id, omit `user_id` or pass `None`. Most optional
|
||||
execution identifiers may also be omitted when they are not available.
|
||||
|
||||
## Server-side settings
|
||||
|
||||
The execution-context layer config does not include daemon transport settings.
|
||||
Configure these on the Dify Agent server instead:
|
||||
|
||||
```env
|
||||
DIFY_AGENT_PLUGIN_DAEMON_URL=http://localhost:5002
|
||||
DIFY_AGENT_PLUGIN_DAEMON_API_KEY=replace-with-plugin-daemon-server-key
|
||||
```
|
||||
|
||||
This keeps server credentials out of client-submitted layer config and out of
|
||||
session snapshots.
|
||||
|
||||
## Notes
|
||||
|
||||
- The execution-context layer does not open, cache, close, or snapshot HTTP clients.
|
||||
- Concrete `plugin_id` values belong to the business layer that invokes the
|
||||
daemon: the plugin LLM layer for model calls and each plugin tool config for
|
||||
tool calls.
|
||||
- The conventional layer name is `execution_context`. If you use another name,
|
||||
point the LLM and tool layer dependencies at that name.
|
||||
@ -1,59 +0,0 @@
|
||||
# Plugin layer
|
||||
|
||||
The plugin layer carries Dify plugin daemon identity for a run. It identifies the
|
||||
tenant, plugin, and optional user context; server settings provide the plugin
|
||||
daemon URL and API key.
|
||||
|
||||
Use it together with a [plugin LLM layer](../plugin-llm-layer/index.md). The LLM
|
||||
layer depends on this layer to reach the plugin daemon.
|
||||
|
||||
## Config fields
|
||||
|
||||
| Field | Type | Meaning |
|
||||
| --- | --- | --- |
|
||||
| `tenant_id` | `str` | Dify tenant/workspace id used when calling the plugin daemon. |
|
||||
| `plugin_id` | `str` | Plugin id, for example `langgenius/openai`. |
|
||||
| `user_id` | `str \| None` | Optional end-user id passed through to the plugin daemon. |
|
||||
|
||||
The plugin layer type id is `dify.plugin`.
|
||||
|
||||
## Basic usage
|
||||
|
||||
```python {test="skip" lint="skip"}
|
||||
from dify_agent.layers.dify_plugin import DIFY_PLUGIN_LAYER_TYPE_ID, DifyPluginLayerConfig
|
||||
from dify_agent.protocol import RunLayerSpec
|
||||
|
||||
|
||||
plugin_layer = RunLayerSpec(
|
||||
name="plugin",
|
||||
type=DIFY_PLUGIN_LAYER_TYPE_ID,
|
||||
config=DifyPluginLayerConfig(
|
||||
tenant_id="replace-with-tenant-id",
|
||||
plugin_id="langgenius/openai",
|
||||
user_id="replace-with-user-id",
|
||||
),
|
||||
)
|
||||
```
|
||||
|
||||
If you do not need a user id, omit `user_id` or pass `None`.
|
||||
|
||||
## Server-side settings
|
||||
|
||||
The plugin layer config does not include daemon transport settings. Configure
|
||||
these on the Dify Agent server instead:
|
||||
|
||||
```env
|
||||
DIFY_AGENT_PLUGIN_DAEMON_URL=http://localhost:5002
|
||||
DIFY_AGENT_PLUGIN_DAEMON_API_KEY=replace-with-plugin-daemon-server-key
|
||||
```
|
||||
|
||||
This keeps server credentials out of client-submitted layer config and out of
|
||||
session snapshots.
|
||||
|
||||
## Notes
|
||||
|
||||
- The plugin layer does not open, cache, close, or snapshot HTTP clients.
|
||||
- `plugin_id` selects the plugin package. The business model provider and model
|
||||
name belong to the plugin LLM layer, not this layer.
|
||||
- The conventional layer name is `plugin`. If you use another name, point the LLM
|
||||
layer dependency at that name.
|
||||
@ -1,17 +1,18 @@
|
||||
# Plugin LLM layer
|
||||
|
||||
The plugin LLM layer selects the model provider, model name, provider credentials,
|
||||
and optional model settings for the current run. Dify Agent reads the model from
|
||||
the reserved layer name `llm`.
|
||||
The plugin LLM layer selects the plugin package, model provider, model name,
|
||||
provider credentials, and optional model settings for the current run. Dify
|
||||
Agent reads the model from the reserved layer name `llm`.
|
||||
|
||||
It must depend on a [plugin layer](../plugin-layer/index.md), because the plugin
|
||||
layer supplies the daemon identity and transport context.
|
||||
It must depend on an [execution context layer](../execution-context-layer/index.md),
|
||||
because that layer supplies the daemon identity and transport context.
|
||||
|
||||
## Config fields
|
||||
|
||||
| Field | Type | Meaning |
|
||||
| --- | --- | --- |
|
||||
| `model_provider` | `str` | Provider name inside the selected plugin. Use the value of `DIFY_AGENT_PROVIDER` from `dify-agent/.env`. |
|
||||
| `plugin_id` | `str` | Plugin package id, for example `langgenius/openai`. |
|
||||
| `model_provider` | `str` | Provider name inside `plugin_id`. Use the value of `DIFY_AGENT_PROVIDER` from `dify-agent/.env`. |
|
||||
| `model` | `str` | Model name. Use the value of `DIFY_AGENT_MODEL_NAME` from `dify-agent/.env`. |
|
||||
| `credentials` | `dict[str, str \| int \| float \| bool \| None]` | Provider-specific credential object. |
|
||||
| `model_settings` | `ModelSettings \| None` | Optional pydantic-ai model settings. |
|
||||
@ -27,12 +28,14 @@ from dify_agent.protocol import DIFY_AGENT_MODEL_LAYER_ID, RunLayerSpec
|
||||
|
||||
MODEL_PROVIDER = "replace-with-provider-from-dify-agent-env"
|
||||
MODEL_NAME = "replace-with-model-from-dify-agent-env"
|
||||
PLUGIN_ID = "langgenius/openai"
|
||||
|
||||
llm_layer = RunLayerSpec(
|
||||
name=DIFY_AGENT_MODEL_LAYER_ID,
|
||||
type=DIFY_PLUGIN_LLM_LAYER_TYPE_ID,
|
||||
deps={"plugin": "plugin"},
|
||||
deps={"execution_context": "execution_context"},
|
||||
config=DifyPluginLLMLayerConfig(
|
||||
plugin_id=PLUGIN_ID,
|
||||
model_provider=MODEL_PROVIDER,
|
||||
model=MODEL_NAME,
|
||||
credentials={"api_key": "replace-with-provider-key"},
|
||||
@ -40,29 +43,30 @@ llm_layer = RunLayerSpec(
|
||||
)
|
||||
```
|
||||
|
||||
`deps={"plugin": "plugin"}` means: bind the LLM layer's dependency field named
|
||||
`plugin` to the composition layer named `plugin`.
|
||||
`deps={"execution_context": "execution_context"}` means: bind the LLM layer's
|
||||
dependency field named `execution_context` to the composition layer named
|
||||
`execution_context`.
|
||||
|
||||
Set `MODEL_PROVIDER` and `MODEL_NAME` to the same values as
|
||||
`DIFY_AGENT_PROVIDER` and `DIFY_AGENT_MODEL_NAME` in `dify-agent/.env`.
|
||||
|
||||
## Complete minimal model composition
|
||||
|
||||
Most runs include a prompt, plugin context, and LLM layer:
|
||||
Most runs include a prompt, execution-context layer, and LLM layer:
|
||||
|
||||
```python {test="skip" lint="skip"}
|
||||
from agenton_collections.layers.plain import PLAIN_PROMPT_LAYER_TYPE_ID, PromptLayerConfig
|
||||
from dify_agent.layers.execution_context import DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID, DifyExecutionContextLayerConfig
|
||||
from dify_agent.layers.dify_plugin import (
|
||||
DIFY_PLUGIN_LAYER_TYPE_ID,
|
||||
DIFY_PLUGIN_LLM_LAYER_TYPE_ID,
|
||||
DifyPluginLLMLayerConfig,
|
||||
DifyPluginLayerConfig,
|
||||
)
|
||||
from dify_agent.protocol import DIFY_AGENT_MODEL_LAYER_ID, RunComposition, RunLayerSpec
|
||||
|
||||
|
||||
MODEL_PROVIDER = "replace-with-provider-from-dify-agent-env"
|
||||
MODEL_NAME = "replace-with-model-from-dify-agent-env"
|
||||
PLUGIN_ID = "langgenius/openai"
|
||||
|
||||
composition = RunComposition(
|
||||
layers=[
|
||||
@ -72,18 +76,19 @@ composition = RunComposition(
|
||||
config=PromptLayerConfig(prefix="You are concise.", user="Say hello."),
|
||||
),
|
||||
RunLayerSpec(
|
||||
name="plugin",
|
||||
type=DIFY_PLUGIN_LAYER_TYPE_ID,
|
||||
config=DifyPluginLayerConfig(
|
||||
name="execution_context",
|
||||
type=DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID,
|
||||
config=DifyExecutionContextLayerConfig(
|
||||
tenant_id="replace-with-tenant-id",
|
||||
plugin_id="langgenius/openai",
|
||||
invoke_from="workflow_run",
|
||||
),
|
||||
),
|
||||
RunLayerSpec(
|
||||
name=DIFY_AGENT_MODEL_LAYER_ID,
|
||||
type=DIFY_PLUGIN_LLM_LAYER_TYPE_ID,
|
||||
deps={"plugin": "plugin"},
|
||||
deps={"execution_context": "execution_context"},
|
||||
config=DifyPluginLLMLayerConfig(
|
||||
plugin_id=PLUGIN_ID,
|
||||
model_provider=MODEL_PROVIDER,
|
||||
model=MODEL_NAME,
|
||||
credentials={"api_key": "replace-with-provider-key"},
|
||||
@ -96,6 +101,9 @@ composition = RunComposition(
|
||||
## Notes
|
||||
|
||||
- The model layer must use the reserved name `llm` (`DIFY_AGENT_MODEL_LAYER_ID`).
|
||||
- `plugin_id` belongs here because model calls are plugin-specific business
|
||||
calls. The shared execution-context layer only carries Dify run and
|
||||
tenant/user daemon context.
|
||||
- Credential shape depends on the selected plugin provider; the OpenAI-style
|
||||
`api_key` field above is only an example.
|
||||
- Client-submitted model credentials remain in the scheduled request memory and
|
||||
|
||||
@ -0,0 +1,130 @@
|
||||
# Plugin tool layer
|
||||
|
||||
The plugin tool layer exposes Dify plugin tools to the model. It is designed for
|
||||
Dify API to build after it has resolved a user's tool selections, plugin daemon
|
||||
declarations, credentials, and manual/runtime inputs.
|
||||
|
||||
Unlike the plugin LLM layer, this layer may contain tools from multiple plugin
|
||||
packages. Each tool config carries its own `plugin_id`, while the shared
|
||||
[execution context layer](../execution-context-layer/index.md) still carries
|
||||
only tenant/user daemon context.
|
||||
|
||||
## Responsibilities
|
||||
|
||||
Dify API prepares the tool config before submitting the run request:
|
||||
|
||||
- resolve the selected provider and tool name;
|
||||
- merge declared parameters with runtime parameters;
|
||||
- produce the model-visible JSON schema;
|
||||
- provide hidden/manual `runtime_parameters` and credentials;
|
||||
- choose the daemon `credential_type` for invocation.
|
||||
|
||||
Dify Agent consumes that prepared config. At run time it validates required
|
||||
hidden inputs, applies defaults, casts invocation values, calls plugin daemon,
|
||||
and turns tool responses into model observations.
|
||||
|
||||
## Config fields
|
||||
|
||||
The plugin tools layer type id is `dify.plugin.tools`.
|
||||
|
||||
`DifyPluginToolsLayerConfig` contains a list of `DifyPluginToolConfig` objects:
|
||||
|
||||
| Field | Type | Meaning |
|
||||
| --- | --- | --- |
|
||||
| `tools` | `list[DifyPluginToolConfig]` | Prepared plugin tools to expose to the model. |
|
||||
|
||||
Each tool config has these fields:
|
||||
|
||||
| Field | Type | Meaning |
|
||||
| --- | --- | --- |
|
||||
| `plugin_id` | `str` | Plugin package id for this tool, for example `langgenius/wikipedia`. |
|
||||
| `provider` | `str` | Tool provider name inside the plugin. |
|
||||
| `tool_name` | `str` | Daemon tool name to invoke. |
|
||||
| `credential_type` | `"api-key" \| "oauth2" \| "unauthorized"` | Credential mode sent to plugin daemon. |
|
||||
| `name` | `str \| None` | Optional model-visible tool name. Defaults to `tool_name`. |
|
||||
| `description` | `str \| None` | Optional model-visible description. Defaults to the tool name. |
|
||||
| `credentials` | `dict[str, str \| int \| float \| bool \| None]` | Provider-specific tool credentials. |
|
||||
| `runtime_parameters` | `dict[str, JsonValue]` | Hidden/manual values merged into daemon invocation but omitted from the model schema. |
|
||||
| `parameters` | `list[DifyPluginToolParameter]` | API-prepared effective parameter declarations used for validation, defaults, and casting. |
|
||||
| `parameters_json_schema` | `dict[str, JsonValue]` | API-prepared JSON schema shown to the model. |
|
||||
|
||||
## Example: Dify API prepared Wikipedia tool
|
||||
|
||||
```python {test="skip" lint="skip"}
|
||||
from dify_agent.layers.execution_context import DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID, DifyExecutionContextLayerConfig
|
||||
from dify_agent.layers.dify_plugin import (
|
||||
DIFY_PLUGIN_TOOLS_LAYER_TYPE_ID,
|
||||
DifyPluginToolConfig,
|
||||
DifyPluginToolParameter,
|
||||
DifyPluginToolsLayerConfig,
|
||||
)
|
||||
from dify_agent.protocol import RunComposition, RunLayerSpec
|
||||
|
||||
|
||||
# Dify API side: resolve the selected tool into the API-side Tool runtime first,
|
||||
# for example with ToolManager.get_agent_tool_runtime(...). Then adapt its
|
||||
# effective ToolParameter objects at the protocol boundary. Dify Agent accepts
|
||||
# both ToolParameter attribute objects and ToolParameter.model_dump(mode="json")
|
||||
# dictionaries, ignoring API-only fields such as label and human_description.
|
||||
tool_runtime = ToolManager.get_agent_tool_runtime(...)
|
||||
effective_parameters = tool_runtime.get_merged_runtime_parameters()
|
||||
prepared_parameters = [
|
||||
DifyPluginToolParameter.model_validate(parameter)
|
||||
# If the API serializes first, use:
|
||||
# DifyPluginToolParameter.model_validate(parameter.model_dump(mode="json"))
|
||||
for parameter in effective_parameters
|
||||
]
|
||||
parameters_json_schema = tool_runtime.get_llm_parameters_json_schema()
|
||||
|
||||
composition = RunComposition(
|
||||
layers=[
|
||||
RunLayerSpec(
|
||||
name="execution_context",
|
||||
type=DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID,
|
||||
config=DifyExecutionContextLayerConfig(
|
||||
tenant_id="replace-with-tenant-id",
|
||||
user_id="replace-with-user-id",
|
||||
invoke_from="workflow_run",
|
||||
),
|
||||
),
|
||||
RunLayerSpec(
|
||||
name="tools",
|
||||
type=DIFY_PLUGIN_TOOLS_LAYER_TYPE_ID,
|
||||
deps={"execution_context": "execution_context"},
|
||||
config=DifyPluginToolsLayerConfig(
|
||||
tools=[
|
||||
DifyPluginToolConfig(
|
||||
plugin_id="langgenius/wikipedia",
|
||||
provider="wikipedia",
|
||||
tool_name="wikipedia_search",
|
||||
credential_type="unauthorized",
|
||||
name="wikipedia_search",
|
||||
description="Search Wikipedia for relevant pages.",
|
||||
parameters=prepared_parameters,
|
||||
runtime_parameters={"language": "en"},
|
||||
parameters_json_schema=parameters_json_schema,
|
||||
)
|
||||
]
|
||||
),
|
||||
),
|
||||
]
|
||||
)
|
||||
```
|
||||
|
||||
`deps={"execution_context": "execution_context"}` means: bind the tool layer's
|
||||
dependency field named `execution_context` to the composition layer named
|
||||
`execution_context`.
|
||||
|
||||
## Notes for Dify API callers
|
||||
|
||||
- Do not ask Dify Agent to discover tool declarations. Resolve and prepare them
|
||||
in API before creating the run.
|
||||
- `parameters` should include all effective parameters, including hidden/manual
|
||||
ones needed for validation and default application.
|
||||
- `parameters_json_schema` should include only model-visible parameters. Omit
|
||||
hidden/manual parameters and file/system-file parameters unless they are truly
|
||||
intended for model input.
|
||||
- `runtime_parameters` should contain hidden/manual values selected by the user
|
||||
or derived from workflow variables.
|
||||
- Put each tool's `plugin_id` on the tool config. The shared execution-context
|
||||
layer has no package-specific identity.
|
||||
@ -17,12 +17,11 @@ import asyncio
|
||||
|
||||
from agenton_collections.layers.plain import PLAIN_PROMPT_LAYER_TYPE_ID, PromptLayerConfig
|
||||
from dify_agent.client import Client
|
||||
from dify_agent.layers.execution_context import DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID, DifyExecutionContextLayerConfig
|
||||
from dify_agent.layers.dify_plugin import (
|
||||
DIFY_PLUGIN_LAYER_TYPE_ID,
|
||||
DIFY_PLUGIN_LLM_LAYER_TYPE_ID,
|
||||
DifyPluginCredentialValue,
|
||||
DifyPluginLLMLayerConfig,
|
||||
DifyPluginLayerConfig,
|
||||
)
|
||||
from dify_agent.protocol import DIFY_AGENT_MODEL_LAYER_ID, CreateRunRequest, RunComposition, RunLayerSpec
|
||||
|
||||
@ -50,20 +49,67 @@ async def main() -> None:
|
||||
),
|
||||
),
|
||||
RunLayerSpec(
|
||||
name="plugin",
|
||||
type=DIFY_PLUGIN_LAYER_TYPE_ID,
|
||||
config=DifyPluginLayerConfig(tenant_id=TENANT_ID, plugin_id=PLUGIN_ID),
|
||||
name="execution_context",
|
||||
type=DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID,
|
||||
config=DifyExecutionContextLayerConfig(
|
||||
tenant_id=TENANT_ID,
|
||||
invoke_from="workflow_run",
|
||||
),
|
||||
),
|
||||
RunLayerSpec(
|
||||
name=DIFY_AGENT_MODEL_LAYER_ID,
|
||||
type=DIFY_PLUGIN_LLM_LAYER_TYPE_ID,
|
||||
deps={"plugin": "plugin"},
|
||||
deps={"execution_context": "execution_context"},
|
||||
config=DifyPluginLLMLayerConfig(
|
||||
plugin_id=PLUGIN_ID,
|
||||
model_provider=PLUGIN_PROVIDER,
|
||||
model=MODEL_NAME,
|
||||
credentials=MODEL_CREDENTIALS,
|
||||
),
|
||||
),
|
||||
# Minimal plugin-tools example. API callers should pass
|
||||
# prepared parameters + JSON schema instead of relying on
|
||||
# dify-agent to fetch and merge daemon declarations.
|
||||
# from dify_agent.layers.dify_plugin import (
|
||||
# DifyPluginToolConfig,
|
||||
# DifyPluginToolParameter,
|
||||
# DifyPluginToolParameterForm,
|
||||
# DifyPluginToolParameterType,
|
||||
# DifyPluginToolsLayerConfig,
|
||||
# )
|
||||
# RunLayerSpec(
|
||||
# name="tools",
|
||||
# type="dify.plugin.tools",
|
||||
# deps={"execution_context": "execution_context"},
|
||||
# config=DifyPluginToolsLayerConfig(
|
||||
# tools=[
|
||||
# DifyPluginToolConfig(
|
||||
# plugin_id="langgenius/search",
|
||||
# provider="search",
|
||||
# tool_name="web_search",
|
||||
# credential_type="api-key",
|
||||
# credentials={"api_key": "replace-with-tool-key"},
|
||||
# runtime_parameters={"site": "docs.dify.ai"},
|
||||
# parameters=[
|
||||
# DifyPluginToolParameter(
|
||||
# name="query",
|
||||
# type=DifyPluginToolParameterType.STRING,
|
||||
# form=DifyPluginToolParameterForm.LLM,
|
||||
# required=True,
|
||||
# llm_description="Search query",
|
||||
# ),
|
||||
# ],
|
||||
# parameters_json_schema={
|
||||
# "type": "object",
|
||||
# "properties": {
|
||||
# "query": {"type": "string", "description": "Search query"}
|
||||
# },
|
||||
# "required": ["query"],
|
||||
# },
|
||||
# )
|
||||
# ]
|
||||
# ),
|
||||
# ),
|
||||
],
|
||||
),
|
||||
)
|
||||
|
||||
@ -10,12 +10,11 @@ assuming the original request was not accepted.
|
||||
|
||||
from agenton_collections.layers.plain import PLAIN_PROMPT_LAYER_TYPE_ID, PromptLayerConfig
|
||||
from dify_agent.client import Client
|
||||
from dify_agent.layers.execution_context import DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID, DifyExecutionContextLayerConfig
|
||||
from dify_agent.layers.dify_plugin import (
|
||||
DIFY_PLUGIN_LAYER_TYPE_ID,
|
||||
DIFY_PLUGIN_LLM_LAYER_TYPE_ID,
|
||||
DifyPluginCredentialValue,
|
||||
DifyPluginLLMLayerConfig,
|
||||
DifyPluginLayerConfig,
|
||||
)
|
||||
from dify_agent.protocol import DIFY_AGENT_MODEL_LAYER_ID, CreateRunRequest, RunComposition, RunLayerSpec
|
||||
|
||||
@ -43,20 +42,67 @@ def main() -> None:
|
||||
),
|
||||
),
|
||||
RunLayerSpec(
|
||||
name="plugin",
|
||||
type=DIFY_PLUGIN_LAYER_TYPE_ID,
|
||||
config=DifyPluginLayerConfig(tenant_id=TENANT_ID, plugin_id=PLUGIN_ID),
|
||||
name="execution_context",
|
||||
type=DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID,
|
||||
config=DifyExecutionContextLayerConfig(
|
||||
tenant_id=TENANT_ID,
|
||||
invoke_from="workflow_run",
|
||||
),
|
||||
),
|
||||
RunLayerSpec(
|
||||
name=DIFY_AGENT_MODEL_LAYER_ID,
|
||||
type=DIFY_PLUGIN_LLM_LAYER_TYPE_ID,
|
||||
deps={"plugin": "plugin"},
|
||||
deps={"execution_context": "execution_context"},
|
||||
config=DifyPluginLLMLayerConfig(
|
||||
plugin_id=PLUGIN_ID,
|
||||
model_provider=PLUGIN_PROVIDER,
|
||||
model=MODEL_NAME,
|
||||
credentials=MODEL_CREDENTIALS,
|
||||
),
|
||||
),
|
||||
# Minimal plugin-tools example. API callers should pass
|
||||
# prepared parameters + JSON schema instead of relying on
|
||||
# dify-agent to fetch and merge daemon declarations.
|
||||
# from dify_agent.layers.dify_plugin import (
|
||||
# DifyPluginToolConfig,
|
||||
# DifyPluginToolParameter,
|
||||
# DifyPluginToolParameterForm,
|
||||
# DifyPluginToolParameterType,
|
||||
# DifyPluginToolsLayerConfig,
|
||||
# )
|
||||
# RunLayerSpec(
|
||||
# name="tools",
|
||||
# type="dify.plugin.tools",
|
||||
# deps={"execution_context": "execution_context"},
|
||||
# config=DifyPluginToolsLayerConfig(
|
||||
# tools=[
|
||||
# DifyPluginToolConfig(
|
||||
# plugin_id="langgenius/search",
|
||||
# provider="search",
|
||||
# tool_name="web_search",
|
||||
# credential_type="api-key",
|
||||
# credentials={"api_key": "replace-with-tool-key"},
|
||||
# runtime_parameters={"site": "docs.dify.ai"},
|
||||
# parameters=[
|
||||
# DifyPluginToolParameter(
|
||||
# name="query",
|
||||
# type=DifyPluginToolParameterType.STRING,
|
||||
# form=DifyPluginToolParameterForm.LLM,
|
||||
# required=True,
|
||||
# llm_description="Search query",
|
||||
# ),
|
||||
# ],
|
||||
# parameters_json_schema={
|
||||
# "type": "object",
|
||||
# "properties": {
|
||||
# "query": {"type": "string", "description": "Search query"}
|
||||
# },
|
||||
# "required": ["query"],
|
||||
# },
|
||||
# )
|
||||
# ]
|
||||
# ),
|
||||
# ),
|
||||
],
|
||||
),
|
||||
)
|
||||
|
||||
@ -11,19 +11,18 @@ nav:
|
||||
- Agenton:
|
||||
- Overview: agenton/index.md
|
||||
- Guide: agenton/guide/index.md
|
||||
- API Reference: agenton/api/index.md
|
||||
- Examples: agenton/examples/index.md
|
||||
- Dify Agent:
|
||||
- Overview: dify-agent/index.md
|
||||
- User Manual:
|
||||
- Get Started: dify-agent/get-started/index.md
|
||||
- Prompt Layer: dify-agent/user-manual/prompt-layer/index.md
|
||||
- Plugin Layer: dify-agent/user-manual/plugin-layer/index.md
|
||||
- Execution Context Layer: dify-agent/user-manual/execution-context-layer/index.md
|
||||
- Plugin LLM Layer: dify-agent/user-manual/plugin-llm-layer/index.md
|
||||
- Plugin Tool Layer: dify-agent/user-manual/plugin-tool-layer/index.md
|
||||
- History Layer: dify-agent/user-manual/history-layer/index.md
|
||||
- Structured Output Layer: dify-agent/user-manual/structured-output-layer/index.md
|
||||
- Operations Guide: dify-agent/guide/index.md
|
||||
- Run API: dify-agent/api/index.md
|
||||
- Examples: dify-agent/examples/index.md
|
||||
|
||||
theme:
|
||||
|
||||
@ -8,7 +8,6 @@ this provider.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections.abc import AsyncIterator, Callable, Mapping
|
||||
from dataclasses import dataclass, field
|
||||
from typing import NoReturn
|
||||
@ -22,6 +21,12 @@ from typing_extensions import override
|
||||
from pydantic_ai.exceptions import ModelAPIError, ModelHTTPError, UnexpectedModelBehavior, UserError
|
||||
from pydantic_ai.providers import Provider
|
||||
|
||||
from dify_agent.plugin_daemon_transport import (
|
||||
decode_plugin_daemon_error_payload,
|
||||
to_plugin_daemon_jsonable,
|
||||
unwrap_plugin_daemon_error,
|
||||
)
|
||||
|
||||
_DEFAULT_DAEMON_TIMEOUT: float | httpx.Timeout | None = 600.0
|
||||
|
||||
|
||||
@ -83,7 +88,7 @@ class DifyPluginDaemonLLMClient:
|
||||
request_data: Mapping[str, object],
|
||||
response_model: type[T],
|
||||
) -> AsyncIterator[T]:
|
||||
payload: dict[str, object] = {"data": _to_jsonable(request_data)}
|
||||
payload: dict[str, object] = {"data": to_plugin_daemon_jsonable(request_data)}
|
||||
if self.user_id is not None:
|
||||
payload["user_id"] = self.user_id
|
||||
|
||||
@ -97,14 +102,18 @@ class DifyPluginDaemonLLMClient:
|
||||
async with self.http_client.stream("POST", url, headers=headers, json=payload) as response:
|
||||
if response.is_error:
|
||||
body = (await response.aread()).decode("utf-8", errors="replace")
|
||||
error = _decode_plugin_daemon_error_payload(body)
|
||||
error = decode_plugin_daemon_error_payload(body)
|
||||
if error is not None:
|
||||
_raise_plugin_daemon_error(
|
||||
model_name=model_name,
|
||||
resolved_error = unwrap_plugin_daemon_error(
|
||||
error_type=error["error_type"],
|
||||
message=error["message"],
|
||||
)
|
||||
_raise_plugin_daemon_error(
|
||||
model_name=model_name,
|
||||
error_type=resolved_error["error_type"],
|
||||
message=resolved_error["message"],
|
||||
status_code=response.status_code,
|
||||
body=error,
|
||||
body=resolved_error,
|
||||
)
|
||||
raise ModelHTTPError(response.status_code, model_name, body or None)
|
||||
|
||||
@ -117,13 +126,17 @@ class DifyPluginDaemonLLMClient:
|
||||
|
||||
wrapped = PluginDaemonBasicResponse.model_validate_json(line)
|
||||
if wrapped.code != 0:
|
||||
error = _decode_plugin_daemon_error_payload(wrapped.message)
|
||||
error = decode_plugin_daemon_error_payload(wrapped.message)
|
||||
if error is not None:
|
||||
_raise_plugin_daemon_error(
|
||||
model_name=model_name,
|
||||
resolved_error = unwrap_plugin_daemon_error(
|
||||
error_type=error["error_type"],
|
||||
message=error["message"],
|
||||
body=error,
|
||||
)
|
||||
_raise_plugin_daemon_error(
|
||||
model_name=model_name,
|
||||
error_type=resolved_error["error_type"],
|
||||
message=resolved_error["message"],
|
||||
body=resolved_error,
|
||||
)
|
||||
raise ModelAPIError(
|
||||
model_name,
|
||||
@ -199,32 +212,6 @@ class DifyPluginDaemonProvider(Provider[DifyPluginDaemonLLMClient]):
|
||||
return self._client
|
||||
|
||||
|
||||
def _to_jsonable(value: object) -> object:
|
||||
if isinstance(value, BaseModel):
|
||||
return value.model_dump(mode="json")
|
||||
if isinstance(value, dict):
|
||||
return {key: _to_jsonable(item) for key, item in value.items()}
|
||||
if isinstance(value, list | tuple):
|
||||
return [_to_jsonable(item) for item in value]
|
||||
return value
|
||||
|
||||
|
||||
def _decode_plugin_daemon_error_payload(raw_message: str) -> dict[str, str] | None:
|
||||
try:
|
||||
parsed = json.loads(raw_message)
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
|
||||
if not isinstance(parsed, dict):
|
||||
return None
|
||||
|
||||
error_type = parsed.get("error_type")
|
||||
message = parsed.get("message")
|
||||
if not isinstance(error_type, str) or not isinstance(message, str):
|
||||
return None
|
||||
return {"error_type": error_type, "message": message}
|
||||
|
||||
|
||||
def _raise_plugin_daemon_error(
|
||||
*,
|
||||
model_name: str,
|
||||
@ -236,17 +223,6 @@ def _raise_plugin_daemon_error(
|
||||
http_error_body = body or {"error_type": error_type, "message": message}
|
||||
|
||||
match error_type:
|
||||
case "PluginInvokeError":
|
||||
nested_error = _decode_plugin_daemon_error_payload(message)
|
||||
if nested_error is not None:
|
||||
_raise_plugin_daemon_error(
|
||||
model_name=model_name,
|
||||
error_type=nested_error["error_type"],
|
||||
message=nested_error["message"],
|
||||
status_code=status_code,
|
||||
body=nested_error,
|
||||
)
|
||||
raise ModelAPIError(model_name, message)
|
||||
case "PluginDaemonUnauthorizedError" | "InvokeAuthorizationError":
|
||||
raise ModelHTTPError(status_code or 401, model_name, http_error_body)
|
||||
case "PluginPermissionDeniedError":
|
||||
|
||||
@ -1,21 +1,35 @@
|
||||
"""Client-safe exports for Dify plugin DTOs and public layer type ids.
|
||||
"""Client-safe exports for Dify plugin business-layer DTOs and type ids.
|
||||
|
||||
Implementation layers live in sibling modules and require server-side runtime
|
||||
dependencies. Keep this package root import-safe for client-only installs.
|
||||
"""
|
||||
|
||||
from dify_agent.layers.dify_plugin.configs import (
|
||||
DIFY_PLUGIN_LAYER_TYPE_ID,
|
||||
DIFY_PLUGIN_LLM_LAYER_TYPE_ID,
|
||||
DIFY_PLUGIN_TOOLS_LAYER_TYPE_ID,
|
||||
DifyPluginCredentialValue,
|
||||
DifyPluginLLMLayerConfig,
|
||||
DifyPluginLayerConfig,
|
||||
DifyPluginToolCredentialType,
|
||||
DifyPluginToolConfig,
|
||||
DifyPluginToolOption,
|
||||
DifyPluginToolParameter,
|
||||
DifyPluginToolParameterForm,
|
||||
DifyPluginToolParameterType,
|
||||
DifyPluginToolsLayerConfig,
|
||||
DifyPluginToolValue,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"DIFY_PLUGIN_LAYER_TYPE_ID",
|
||||
"DIFY_PLUGIN_LLM_LAYER_TYPE_ID",
|
||||
"DIFY_PLUGIN_TOOLS_LAYER_TYPE_ID",
|
||||
"DifyPluginCredentialValue",
|
||||
"DifyPluginLLMLayerConfig",
|
||||
"DifyPluginLayerConfig",
|
||||
"DifyPluginToolCredentialType",
|
||||
"DifyPluginToolConfig",
|
||||
"DifyPluginToolOption",
|
||||
"DifyPluginToolParameter",
|
||||
"DifyPluginToolParameterForm",
|
||||
"DifyPluginToolParameterType",
|
||||
"DifyPluginToolsLayerConfig",
|
||||
"DifyPluginToolValue",
|
||||
]
|
||||
|
||||
@ -1,38 +1,111 @@
|
||||
"""Client-safe DTOs for Dify plugin-backed Agenton layers.
|
||||
"""Client-safe DTOs for Dify plugin-backed Agenton business layers.
|
||||
|
||||
This module intentionally contains only public config schemas and scalar type
|
||||
aliases plus stable layer type identifiers. Runtime objects such as HTTP
|
||||
clients, server settings, and adapter implementations live in sibling
|
||||
implementation modules so clients can build run requests without importing
|
||||
server-only dependencies.
|
||||
aliases plus stable plugin business-layer type identifiers. Runtime objects
|
||||
such as HTTP clients, server settings, and adapter implementations live in
|
||||
sibling implementation modules so clients can build run requests without
|
||||
importing server-only dependencies.
|
||||
|
||||
Shared tenant/user/run context now lives in the sibling
|
||||
``dify_agent.layers.execution_context`` package. This module only covers the
|
||||
plugin-backed LLM and tools layers that invoke daemon features with concrete
|
||||
``plugin_id`` values. Tool configs also carry the API-side prepared parameter
|
||||
declarations and model-visible JSON schema so the agent runtime does not have to
|
||||
re-fetch and re-merge tool declarations at execution time.
|
||||
"""
|
||||
|
||||
from typing import ClassVar, Final, TypeAlias
|
||||
from enum import StrEnum
|
||||
from typing import ClassVar, Final, Literal, TypeAlias
|
||||
|
||||
from pydantic import ConfigDict, Field
|
||||
from pydantic import BaseModel, ConfigDict, Field, JsonValue, field_validator
|
||||
from pydantic_ai.settings import ModelSettings
|
||||
|
||||
from agenton.layers import LayerConfig
|
||||
|
||||
|
||||
DifyPluginCredentialValue: TypeAlias = str | int | float | bool | None
|
||||
DIFY_PLUGIN_LAYER_TYPE_ID: Final[str] = "dify.plugin"
|
||||
DifyPluginToolCredentialType: TypeAlias = Literal["api-key", "oauth2", "unauthorized"]
|
||||
DifyPluginToolValue: TypeAlias = JsonValue
|
||||
DIFY_PLUGIN_LLM_LAYER_TYPE_ID: Final[str] = "dify.plugin.llm"
|
||||
DIFY_PLUGIN_TOOLS_LAYER_TYPE_ID: Final[str] = "dify.plugin.tools"
|
||||
|
||||
|
||||
class DifyPluginLayerConfig(LayerConfig):
|
||||
"""Public config for the plugin daemon tenant/plugin context layer."""
|
||||
class DifyPluginToolOption(BaseModel):
|
||||
"""Selectable tool option value exposed to the model.
|
||||
|
||||
tenant_id: str
|
||||
plugin_id: str
|
||||
user_id: str | None = None
|
||||
The DTO also accepts API-side option dumps and attribute objects. Fields
|
||||
such as ``label`` or ``icon`` are intentionally ignored because Dify Agent
|
||||
only preserves the normalized option ``value`` for tool invocation and
|
||||
model-visible schema generation.
|
||||
"""
|
||||
|
||||
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid", arbitrary_types_allowed=True)
|
||||
value: str
|
||||
|
||||
model_config: ClassVar[ConfigDict] = ConfigDict(extra="ignore", from_attributes=True)
|
||||
|
||||
@field_validator("value", mode="before")
|
||||
@classmethod
|
||||
def stringify_value(cls, value: object) -> str:
|
||||
return value if isinstance(value, str) else str(value)
|
||||
|
||||
|
||||
class DifyPluginToolParameterType(StrEnum):
|
||||
STRING = "string"
|
||||
NUMBER = "number"
|
||||
BOOLEAN = "boolean"
|
||||
SELECT = "select"
|
||||
SECRET_INPUT = "secret-input"
|
||||
FILE = "file"
|
||||
FILES = "files"
|
||||
APP_SELECTOR = "app-selector"
|
||||
MODEL_SELECTOR = "model-selector"
|
||||
ANY = "any"
|
||||
DYNAMIC_SELECT = "dynamic-select"
|
||||
CHECKBOX = "checkbox"
|
||||
SYSTEM_FILES = "system-files"
|
||||
ARRAY = "array"
|
||||
OBJECT = "object"
|
||||
|
||||
def as_normal_type(self) -> str:
|
||||
if self in {
|
||||
DifyPluginToolParameterType.SECRET_INPUT,
|
||||
DifyPluginToolParameterType.SELECT,
|
||||
DifyPluginToolParameterType.CHECKBOX,
|
||||
}:
|
||||
return "string"
|
||||
return self.value
|
||||
|
||||
|
||||
class DifyPluginToolParameterForm(StrEnum):
|
||||
SCHEMA = "schema"
|
||||
FORM = "form"
|
||||
LLM = "llm"
|
||||
|
||||
|
||||
class DifyPluginToolParameter(BaseModel):
|
||||
"""Prepared tool parameter declaration supplied by the API side.
|
||||
|
||||
The DTO intentionally accepts both API-side ``ToolParameter`` dumps and
|
||||
attribute objects so callers can adapt existing tool runtime declarations
|
||||
without coupling Dify Agent to API-internal model classes.
|
||||
"""
|
||||
|
||||
name: str
|
||||
type: DifyPluginToolParameterType
|
||||
form: DifyPluginToolParameterForm
|
||||
required: bool = False
|
||||
default: DifyPluginToolValue = None
|
||||
llm_description: str | None = None
|
||||
input_schema: dict[str, JsonValue] | None = None
|
||||
options: list[DifyPluginToolOption] = Field(default_factory=list)
|
||||
|
||||
model_config: ClassVar[ConfigDict] = ConfigDict(extra="ignore", from_attributes=True)
|
||||
|
||||
|
||||
class DifyPluginLLMLayerConfig(LayerConfig):
|
||||
"""Public config for selecting a business provider/model from a plugin."""
|
||||
"""Public config for selecting a plugin-backed business provider/model."""
|
||||
|
||||
plugin_id: str
|
||||
model_provider: str
|
||||
model: str
|
||||
credentials: dict[str, DifyPluginCredentialValue] = Field(default_factory=dict)
|
||||
@ -41,10 +114,64 @@ class DifyPluginLLMLayerConfig(LayerConfig):
|
||||
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid", arbitrary_types_allowed=True)
|
||||
|
||||
|
||||
class DifyPluginToolConfig(LayerConfig):
|
||||
"""Public config for exposing one plugin tool to the agent model.
|
||||
|
||||
``credential_type`` is an explicit caller-supplied daemon transport choice,
|
||||
not an auto-discovered property. It must match the actual credential mode of
|
||||
``credentials`` for the configured plugin tool, for example ``"api-key"``
|
||||
versus ``"oauth2"``. A wrong value can make invocation fail at runtime even
|
||||
when the config itself validates successfully.
|
||||
|
||||
``runtime_parameters`` mirrors Dify's agent-node hidden/manual tool inputs:
|
||||
those values are merged into the actual daemon invocation but omitted from
|
||||
the tool schema shown to the model.
|
||||
|
||||
``parameters`` and ``parameters_json_schema`` are API-side prepared tool
|
||||
declaration artifacts. They let the agent runtime validate hidden/default
|
||||
inputs and expose the correct LLM-facing schema without re-fetching or
|
||||
re-merging daemon declarations at run time.
|
||||
"""
|
||||
|
||||
plugin_id: str
|
||||
provider: str
|
||||
tool_name: str
|
||||
credential_type: DifyPluginToolCredentialType
|
||||
name: str | None = None
|
||||
description: str | None = None
|
||||
credentials: dict[str, DifyPluginCredentialValue] = Field(default_factory=dict)
|
||||
runtime_parameters: dict[str, DifyPluginToolValue] = Field(default_factory=dict)
|
||||
parameters: list[DifyPluginToolParameter] = Field(default_factory=list)
|
||||
parameters_json_schema: dict[str, JsonValue] = Field(
|
||||
default_factory=lambda: {"type": "object", "properties": {}, "required": []}
|
||||
)
|
||||
|
||||
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid", arbitrary_types_allowed=True)
|
||||
|
||||
|
||||
class DifyPluginToolsLayerConfig(LayerConfig):
|
||||
"""Public config for the Dify plugin tools layer.
|
||||
|
||||
Callers configure the tools layer with this wrapper object and supply one
|
||||
or more prepared ``DifyPluginToolConfig`` entries in ``tools``.
|
||||
"""
|
||||
|
||||
tools: list[DifyPluginToolConfig] = Field(default_factory=list)
|
||||
|
||||
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid", arbitrary_types_allowed=True)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"DIFY_PLUGIN_LAYER_TYPE_ID",
|
||||
"DIFY_PLUGIN_LLM_LAYER_TYPE_ID",
|
||||
"DIFY_PLUGIN_TOOLS_LAYER_TYPE_ID",
|
||||
"DifyPluginCredentialValue",
|
||||
"DifyPluginLLMLayerConfig",
|
||||
"DifyPluginLayerConfig",
|
||||
"DifyPluginToolCredentialType",
|
||||
"DifyPluginToolConfig",
|
||||
"DifyPluginToolOption",
|
||||
"DifyPluginToolParameter",
|
||||
"DifyPluginToolParameterForm",
|
||||
"DifyPluginToolParameterType",
|
||||
"DifyPluginToolsLayerConfig",
|
||||
"DifyPluginToolValue",
|
||||
]
|
||||
|
||||
@ -1,15 +1,17 @@
|
||||
"""Dify plugin LLM model layer.
|
||||
|
||||
This layer owns model capability resolution for Dify plugin-backed LLMs. It
|
||||
depends on ``DifyPluginLayer`` for daemon identity through Agenton's direct
|
||||
dependency binding and returns a Pydantic AI model adapter configured from the
|
||||
public LLM layer DTO. Runtime code supplies the FastAPI lifespan-owned shared
|
||||
HTTP client to ``get_model``; the layer does not own or discover live resources.
|
||||
The daemon provider carries plugin transport identity, while the DTO's
|
||||
``model_provider`` is passed to the adapter as request-level model identity.
|
||||
depends on ``DifyExecutionContextLayer`` for shared daemon settings through
|
||||
Agenton's direct dependency binding and returns a Pydantic AI model adapter
|
||||
configured from the public LLM layer DTO. Runtime code supplies the FastAPI
|
||||
lifespan-owned shared HTTP client to ``get_model``; the layer does not own or
|
||||
discover live resources. The daemon provider carries plugin transport identity,
|
||||
while the DTO's ``model_provider`` is passed to the adapter as request-level
|
||||
model identity.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import ClassVar
|
||||
|
||||
import httpx
|
||||
from typing_extensions import Self, override
|
||||
@ -17,20 +19,20 @@ from typing_extensions import Self, override
|
||||
from agenton.layers import LayerDeps, PlainLayer
|
||||
from dify_agent.adapters.llm import DifyLLMAdapterModel
|
||||
from dify_agent.layers.dify_plugin.configs import DIFY_PLUGIN_LLM_LAYER_TYPE_ID, DifyPluginLLMLayerConfig
|
||||
from dify_agent.layers.dify_plugin.plugin_layer import DifyPluginLayer
|
||||
from dify_agent.layers.execution_context.layer import DifyExecutionContextLayer
|
||||
|
||||
|
||||
class DifyPluginLLMDeps(LayerDeps):
|
||||
"""Dependencies required by ``DifyPluginLLMLayer``."""
|
||||
|
||||
plugin: DifyPluginLayer # pyright: ignore[reportUninitializedInstanceVariable]
|
||||
execution_context: DifyExecutionContextLayer # pyright: ignore[reportUninitializedInstanceVariable]
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class DifyPluginLLMLayer(PlainLayer[DifyPluginLLMDeps, DifyPluginLLMLayerConfig]):
|
||||
"""Layer that creates the Dify plugin-daemon Pydantic AI model."""
|
||||
|
||||
type_id = DIFY_PLUGIN_LLM_LAYER_TYPE_ID
|
||||
type_id: ClassVar[str] = DIFY_PLUGIN_LLM_LAYER_TYPE_ID
|
||||
|
||||
config: DifyPluginLLMLayerConfig
|
||||
|
||||
@ -41,8 +43,11 @@ class DifyPluginLLMLayer(PlainLayer[DifyPluginLLMDeps, DifyPluginLLMLayerConfig]
|
||||
return cls(config=config)
|
||||
|
||||
def get_model(self, *, http_client: httpx.AsyncClient) -> DifyLLMAdapterModel:
|
||||
"""Return the configured model using the directly bound plugin dependency."""
|
||||
provider = self.deps.plugin.create_daemon_provider(http_client=http_client)
|
||||
"""Return the configured model using the directly bound execution context."""
|
||||
provider = self.deps.execution_context.create_daemon_provider(
|
||||
plugin_id=self.config.plugin_id,
|
||||
http_client=http_client,
|
||||
)
|
||||
return DifyLLMAdapterModel(
|
||||
model=self.config.model,
|
||||
daemon_provider=provider,
|
||||
|
||||
@ -1,69 +0,0 @@
|
||||
"""Runtime Dify plugin context layer.
|
||||
|
||||
The public config identifies tenant/plugin/user context only. Plugin daemon URL
|
||||
and API key are server-side settings injected by the provider factory. The layer
|
||||
is intentionally config/settings-only under Agenton's state-only core: it does
|
||||
not open, cache, close, or snapshot HTTP clients, and its lifecycle hooks remain
|
||||
the inherited no-op hooks. Runtime code passes the FastAPI lifespan-owned shared
|
||||
``httpx.AsyncClient`` into ``create_daemon_provider`` for each model adapter.
|
||||
Business model-provider names belong to the LLM layer/model request, not this
|
||||
daemon context layer.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
import httpx
|
||||
from typing_extensions import Self, override
|
||||
|
||||
from agenton.layers import EmptyRuntimeState, NoLayerDeps, PlainLayer
|
||||
from dify_agent.adapters.llm import DifyPluginDaemonProvider
|
||||
from dify_agent.layers.dify_plugin.configs import DIFY_PLUGIN_LAYER_TYPE_ID, DifyPluginLayerConfig
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class DifyPluginLayer(PlainLayer[NoLayerDeps, DifyPluginLayerConfig, EmptyRuntimeState]):
|
||||
"""Layer that carries plugin daemon identity without owning live resources."""
|
||||
|
||||
type_id = DIFY_PLUGIN_LAYER_TYPE_ID
|
||||
|
||||
config: DifyPluginLayerConfig
|
||||
daemon_url: str
|
||||
daemon_api_key: str
|
||||
|
||||
@classmethod
|
||||
@override
|
||||
def from_config(cls, config: DifyPluginLayerConfig) -> Self:
|
||||
"""Reject construction without server-injected daemon settings."""
|
||||
del config
|
||||
raise TypeError("DifyPluginLayer requires server-side daemon settings and must use a provider factory.")
|
||||
|
||||
@classmethod
|
||||
def from_config_with_settings(
|
||||
cls,
|
||||
config: DifyPluginLayerConfig,
|
||||
*,
|
||||
daemon_url: str,
|
||||
daemon_api_key: str,
|
||||
) -> Self:
|
||||
"""Create a plugin layer from public config plus server-only daemon settings."""
|
||||
return cls(config=config, daemon_url=daemon_url, daemon_api_key=daemon_api_key)
|
||||
|
||||
def create_daemon_provider(self, *, http_client: httpx.AsyncClient) -> DifyPluginDaemonProvider:
|
||||
"""Return a daemon provider backed by the shared plugin daemon client.
|
||||
|
||||
Raises:
|
||||
RuntimeError: if ``http_client`` has already been closed.
|
||||
"""
|
||||
if http_client.is_closed:
|
||||
raise RuntimeError("DifyPluginLayer.create_daemon_provider() requires an open shared HTTP client.")
|
||||
return DifyPluginDaemonProvider(
|
||||
tenant_id=self.config.tenant_id,
|
||||
plugin_id=self.config.plugin_id,
|
||||
plugin_daemon_url=self.daemon_url,
|
||||
plugin_daemon_api_key=self.daemon_api_key,
|
||||
user_id=self.config.user_id,
|
||||
http_client=http_client,
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["DifyPluginLayer"]
|
||||
333
dify-agent/src/dify_agent/layers/dify_plugin/tool_client.py
Normal file
333
dify-agent/src/dify_agent/layers/dify_plugin/tool_client.py
Normal file
@ -0,0 +1,333 @@
|
||||
"""Async plugin-daemon client for Dify plugin tool invocation.
|
||||
|
||||
The agent runtime talks to the plugin daemon rather than importing provider SDKs
|
||||
directly. The tools layer now consumes API-prepared declarations from config, so
|
||||
this module only keeps the invoke-time boundary:
|
||||
|
||||
- POST ``/plugin/{tenant_id}/dispatch/tool/invoke``
|
||||
- request headers ``X-Api-Key``, ``X-Plugin-ID``, and ``Content-Type``
|
||||
- top-level ``user_id`` forwarding when shared execution context includes one
|
||||
- stream decoding and blob-chunk merging for agent observations
|
||||
|
||||
The shared execution-context layer still owns tenant/user daemon context, while
|
||||
each tool's own ``plugin_id`` determines the transport identity placed in
|
||||
``X-Plugin-ID``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
from collections.abc import AsyncIterator, Mapping
|
||||
from dataclasses import dataclass, field
|
||||
from enum import StrEnum
|
||||
|
||||
import httpx
|
||||
from pydantic import BaseModel, Field, ValidationInfo, field_validator, model_validator
|
||||
|
||||
from dify_agent.layers.dify_plugin.configs import DifyPluginToolCredentialType
|
||||
from dify_agent.plugin_daemon_transport import (
|
||||
decode_plugin_daemon_error_payload,
|
||||
to_plugin_daemon_jsonable,
|
||||
unwrap_plugin_daemon_error,
|
||||
)
|
||||
|
||||
|
||||
class PluginDaemonBasicResponse(BaseModel):
|
||||
"""Common plugin-daemon stream and JSON wrapper."""
|
||||
|
||||
code: int
|
||||
message: str
|
||||
data: object | None = None
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class FileChunk:
|
||||
"""Buffer for accumulating streamed blob chunks."""
|
||||
|
||||
total_length: int
|
||||
bytes_written: int = field(default=0, init=False)
|
||||
data: bytearray = field(init=False)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
self.data = bytearray(self.total_length)
|
||||
|
||||
|
||||
class DifyPluginToolInvokeMessage(BaseModel):
|
||||
"""Subset of Dify tool stream messages needed for agent observations."""
|
||||
|
||||
class TextMessage(BaseModel):
|
||||
text: str
|
||||
|
||||
class JsonMessage(BaseModel):
|
||||
json_object: dict[str, object] | list[object]
|
||||
suppress_output: bool = False
|
||||
|
||||
class BlobMessage(BaseModel):
|
||||
blob: bytes
|
||||
|
||||
class BlobChunkMessage(BaseModel):
|
||||
id: str
|
||||
sequence: int
|
||||
total_length: int
|
||||
blob: bytes
|
||||
end: bool
|
||||
|
||||
class FileMessage(BaseModel):
|
||||
file_marker: str = "file_marker"
|
||||
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
def validate_file_marker(cls, values: object) -> object:
|
||||
if isinstance(values, dict) and "file_marker" not in values:
|
||||
raise ValueError("Invalid FileMessage: missing file_marker")
|
||||
return values
|
||||
|
||||
class VariableMessage(BaseModel):
|
||||
variable_name: str
|
||||
variable_value: object
|
||||
stream: bool = False
|
||||
|
||||
class LogMessage(BaseModel):
|
||||
id: str
|
||||
label: str
|
||||
parent_id: str | None = None
|
||||
error: str | None = None
|
||||
status: str
|
||||
data: Mapping[str, object] = Field(default_factory=dict)
|
||||
metadata: Mapping[str, object] = Field(default_factory=dict)
|
||||
|
||||
class MessageType(StrEnum):
|
||||
TEXT = "text"
|
||||
IMAGE = "image"
|
||||
LINK = "link"
|
||||
BLOB = "blob"
|
||||
JSON = "json"
|
||||
IMAGE_LINK = "image_link"
|
||||
BINARY_LINK = "binary_link"
|
||||
VARIABLE = "variable"
|
||||
FILE = "file"
|
||||
LOG = "log"
|
||||
BLOB_CHUNK = "blob_chunk"
|
||||
|
||||
type: MessageType = MessageType.TEXT
|
||||
message: (
|
||||
TextMessage | JsonMessage | BlobChunkMessage | BlobMessage | LogMessage | FileMessage | VariableMessage | None
|
||||
)
|
||||
meta: dict[str, object] | None = None
|
||||
|
||||
@field_validator("message", mode="before")
|
||||
@classmethod
|
||||
def decode_message(cls, value: object, info: ValidationInfo) -> object:
|
||||
if isinstance(value, dict) and "blob" in value:
|
||||
try:
|
||||
value = {**value, "blob": base64.b64decode(value["blob"])}
|
||||
except Exception:
|
||||
return value
|
||||
|
||||
msg_type = info.data.get("type") if isinstance(info.data, dict) else None
|
||||
if msg_type == cls.MessageType.JSON and isinstance(value, dict) and "json_object" not in value:
|
||||
return {"json_object": value}
|
||||
if msg_type == cls.MessageType.FILE and isinstance(value, dict):
|
||||
return {"file_marker": value.get("file_marker", "file_marker")}
|
||||
return value
|
||||
|
||||
|
||||
class DifyPluginToolClientError(Exception):
|
||||
"""Raised when the plugin daemon rejects a tool-layer request."""
|
||||
|
||||
error_type: str | None
|
||||
status_code: int | None
|
||||
|
||||
def __init__(self, message: str, *, error_type: str | None = None, status_code: int | None = None) -> None:
|
||||
super().__init__(message)
|
||||
self.error_type = error_type
|
||||
self.status_code = status_code
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class DifyPluginDaemonToolClient:
|
||||
"""HTTP wrapper for the invoke-only plugin-daemon tool boundary.
|
||||
|
||||
Callers provide business-level provider/tool/credential data per invocation,
|
||||
while this client supplies daemon transport identity from shared runtime
|
||||
context: tenant path segment, daemon API key, plugin-specific ``X-Plugin-ID``
|
||||
header, and optional top-level ``user_id``.
|
||||
"""
|
||||
|
||||
plugin_daemon_url: str
|
||||
plugin_daemon_api_key: str
|
||||
tenant_id: str
|
||||
plugin_id: str
|
||||
user_id: str | None
|
||||
http_client: httpx.AsyncClient = field(repr=False)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
self.plugin_daemon_url = self.plugin_daemon_url.rstrip("/")
|
||||
|
||||
async def invoke(
|
||||
self,
|
||||
*,
|
||||
provider: str,
|
||||
tool_name: str,
|
||||
credential_type: DifyPluginToolCredentialType,
|
||||
credentials: dict[str, object],
|
||||
tool_parameters: Mapping[str, object],
|
||||
) -> list[DifyPluginToolInvokeMessage]:
|
||||
"""Invoke a plugin tool and collect its observation stream."""
|
||||
raw_messages = [
|
||||
item
|
||||
async for item in self._iter_stream_response(
|
||||
path=f"plugin/{self.tenant_id}/dispatch/tool/invoke",
|
||||
request_data={
|
||||
"provider": provider,
|
||||
"tool": tool_name,
|
||||
"credentials": credentials,
|
||||
"credential_type": credential_type,
|
||||
"tool_parameters": dict(tool_parameters),
|
||||
},
|
||||
response_model=DifyPluginToolInvokeMessage,
|
||||
)
|
||||
]
|
||||
return merge_blob_chunks(raw_messages)
|
||||
|
||||
async def _iter_stream_response[T: BaseModel](
|
||||
self,
|
||||
*,
|
||||
path: str,
|
||||
request_data: Mapping[str, object],
|
||||
response_model: type[T],
|
||||
) -> AsyncIterator[T]:
|
||||
"""Send one daemon stream request and yield typed items.
|
||||
|
||||
The daemon expects the actual invoke payload nested under ``data``. When
|
||||
the shared plugin context included ``user_id``, it is forwarded as a
|
||||
top-level peer to ``data`` so daemon-side auditing and credential logic
|
||||
can attribute the request to the end user.
|
||||
"""
|
||||
payload: dict[str, object] = {"data": to_plugin_daemon_jsonable(dict(request_data))}
|
||||
if self.user_id is not None:
|
||||
payload["user_id"] = self.user_id
|
||||
|
||||
url = f"{self.plugin_daemon_url}/{path}"
|
||||
async with self.http_client.stream("POST", url, headers=self._headers(), json=payload) as response:
|
||||
if response.is_error:
|
||||
body = (await response.aread()).decode("utf-8", errors="replace")
|
||||
error = decode_plugin_daemon_error_payload(body)
|
||||
if error is not None:
|
||||
resolved_error = unwrap_plugin_daemon_error(
|
||||
error_type=error["error_type"],
|
||||
message=error["message"],
|
||||
)
|
||||
_raise_tool_daemon_error(
|
||||
error_type=resolved_error["error_type"],
|
||||
message=resolved_error["message"],
|
||||
status_code=response.status_code,
|
||||
)
|
||||
raise DifyPluginToolClientError(
|
||||
body or "Plugin daemon stream request failed.", status_code=response.status_code
|
||||
)
|
||||
|
||||
async for raw_line in response.aiter_lines():
|
||||
line = raw_line.strip()
|
||||
if not line:
|
||||
continue
|
||||
if line.startswith("data:"):
|
||||
line = line[5:].strip()
|
||||
|
||||
wrapped = PluginDaemonBasicResponse.model_validate_json(line)
|
||||
if wrapped.code != 0:
|
||||
error = decode_plugin_daemon_error_payload(wrapped.message)
|
||||
if error is not None:
|
||||
resolved_error = unwrap_plugin_daemon_error(
|
||||
error_type=error["error_type"],
|
||||
message=error["message"],
|
||||
)
|
||||
_raise_tool_daemon_error(
|
||||
error_type=resolved_error["error_type"],
|
||||
message=resolved_error["message"],
|
||||
)
|
||||
raise DifyPluginToolClientError(wrapped.message or "Plugin daemon returned an error stream item.")
|
||||
if wrapped.data is None:
|
||||
raise DifyPluginToolClientError("Plugin daemon returned an empty stream item.")
|
||||
yield response_model.model_validate(wrapped.data)
|
||||
|
||||
def _headers(self) -> dict[str, str]:
|
||||
"""Build required plugin-daemon transport headers for tool invocation."""
|
||||
return {
|
||||
"X-Api-Key": self.plugin_daemon_api_key,
|
||||
"X-Plugin-ID": self.plugin_id,
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
|
||||
def merge_blob_chunks(
|
||||
response: list[DifyPluginToolInvokeMessage],
|
||||
*,
|
||||
max_file_size: int = 30 * 1024 * 1024,
|
||||
max_chunk_size: int = 8192,
|
||||
) -> list[DifyPluginToolInvokeMessage]:
|
||||
"""Merge streamed blob chunks into complete blob messages.
|
||||
|
||||
This mirrors Dify API's plugin-daemon chunk-merging behavior before the
|
||||
higher-level observation conversion logic sees tool stream messages.
|
||||
"""
|
||||
files: dict[str, FileChunk] = {}
|
||||
merged_messages: list[DifyPluginToolInvokeMessage] = []
|
||||
|
||||
for resp in response:
|
||||
if resp.type is DifyPluginToolInvokeMessage.MessageType.BLOB_CHUNK:
|
||||
if not isinstance(resp.message, DifyPluginToolInvokeMessage.BlobChunkMessage):
|
||||
raise TypeError("Blob chunk responses must carry BlobChunkMessage payloads.")
|
||||
|
||||
chunk_id = resp.message.id
|
||||
total_length = resp.message.total_length
|
||||
blob_data = resp.message.blob
|
||||
is_end = resp.message.end
|
||||
|
||||
if chunk_id not in files:
|
||||
files[chunk_id] = FileChunk(total_length)
|
||||
|
||||
if files[chunk_id].bytes_written + len(blob_data) > max_file_size:
|
||||
del files[chunk_id]
|
||||
raise ValueError(f"File is too large which reached the limit of {max_file_size / 1024 / 1024}MB")
|
||||
if len(blob_data) > max_chunk_size:
|
||||
raise ValueError(f"File chunk is too large which reached the limit of {max_chunk_size / 1024}KB")
|
||||
|
||||
files[chunk_id].data[files[chunk_id].bytes_written : files[chunk_id].bytes_written + len(blob_data)] = (
|
||||
blob_data
|
||||
)
|
||||
files[chunk_id].bytes_written += len(blob_data)
|
||||
|
||||
if is_end:
|
||||
merged_messages.append(
|
||||
DifyPluginToolInvokeMessage(
|
||||
type=DifyPluginToolInvokeMessage.MessageType.BLOB,
|
||||
message=DifyPluginToolInvokeMessage.BlobMessage(
|
||||
blob=bytes(files[chunk_id].data[: files[chunk_id].bytes_written])
|
||||
),
|
||||
meta=resp.meta,
|
||||
)
|
||||
)
|
||||
del files[chunk_id]
|
||||
else:
|
||||
merged_messages.append(resp)
|
||||
|
||||
return merged_messages
|
||||
|
||||
|
||||
def _raise_tool_daemon_error(
|
||||
*,
|
||||
error_type: str,
|
||||
message: str,
|
||||
status_code: int | None = None,
|
||||
) -> None:
|
||||
raise DifyPluginToolClientError(message, error_type=error_type, status_code=status_code)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"DifyPluginDaemonToolClient",
|
||||
"DifyPluginToolClientError",
|
||||
"DifyPluginToolCredentialType",
|
||||
"DifyPluginToolInvokeMessage",
|
||||
"merge_blob_chunks",
|
||||
]
|
||||
341
dify-agent/src/dify_agent/layers/dify_plugin/tools_layer.py
Normal file
341
dify-agent/src/dify_agent/layers/dify_plugin/tools_layer.py
Normal file
@ -0,0 +1,341 @@
|
||||
"""Dify plugin tools layer for agent-accessible plugin tools.
|
||||
|
||||
This layer consumes API-prepared plugin tool declarations. The API side is
|
||||
responsible for resolving daemon declarations, applying runtime-parameter
|
||||
overrides, and producing the clean LLM-facing JSON schema. At run time the layer
|
||||
only validates hidden/manual inputs, prepares invocation arguments, and maps
|
||||
daemon responses into agent-friendly observations.
|
||||
|
||||
Like the LLM layer, this layer never owns live HTTP clients. The runtime passes
|
||||
the FastAPI lifespan-owned shared client into ``get_tools`` so the layer can
|
||||
build Pydantic AI tool adapters on demand.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from copy import deepcopy
|
||||
import json
|
||||
from collections.abc import Mapping, Sequence
|
||||
from dataclasses import dataclass
|
||||
from typing import ClassVar
|
||||
|
||||
import httpx
|
||||
from pydantic_ai import RunContext, Tool
|
||||
from pydantic_ai.tools import ToolDefinition
|
||||
from typing_extensions import Self, override
|
||||
|
||||
from agenton.layers import LayerDeps, PlainLayer
|
||||
from dify_agent.layers.dify_plugin.configs import (
|
||||
DIFY_PLUGIN_TOOLS_LAYER_TYPE_ID,
|
||||
DifyPluginToolConfig,
|
||||
DifyPluginToolParameter,
|
||||
DifyPluginToolParameterForm,
|
||||
DifyPluginToolParameterType,
|
||||
DifyPluginToolsLayerConfig,
|
||||
)
|
||||
from dify_agent.layers.dify_plugin.tool_client import (
|
||||
DifyPluginDaemonToolClient,
|
||||
DifyPluginToolClientError,
|
||||
DifyPluginToolInvokeMessage,
|
||||
)
|
||||
from dify_agent.layers.execution_context.layer import DifyExecutionContextLayer
|
||||
|
||||
|
||||
# Plugin tools intentionally do not expose a per-tool strictness override in the
|
||||
# public config. The API supplies already-prepared schemas, but Dify Agent always
|
||||
# registers those tools in loose mode so daemon tool invocation stays tolerant of
|
||||
# plugin schema differences and older API-prepared payloads.
|
||||
PLUGIN_TOOL_STRICT = False
|
||||
|
||||
|
||||
class DifyPluginToolsDeps(LayerDeps):
|
||||
"""Dependencies required by ``DifyPluginToolsLayer``."""
|
||||
|
||||
execution_context: DifyExecutionContextLayer # pyright: ignore[reportUninitializedInstanceVariable]
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class DifyPluginToolsLayer(PlainLayer[DifyPluginToolsDeps, DifyPluginToolsLayerConfig]):
|
||||
"""Layer that resolves Dify plugin tools into Pydantic AI tools."""
|
||||
|
||||
type_id: ClassVar[str] = DIFY_PLUGIN_TOOLS_LAYER_TYPE_ID
|
||||
|
||||
config: DifyPluginToolsLayerConfig
|
||||
|
||||
@classmethod
|
||||
@override
|
||||
def from_config(cls, config: DifyPluginToolsLayerConfig) -> Self:
|
||||
"""Create the tools layer from validated public config."""
|
||||
return cls(config=DifyPluginToolsLayerConfig.model_validate(config))
|
||||
|
||||
async def get_tools(self, *, http_client: httpx.AsyncClient) -> list[Tool[object]]:
|
||||
"""Build Pydantic AI tool adapters from prepared plugin tool config."""
|
||||
tool_clients: dict[str, DifyPluginDaemonToolClient] = {}
|
||||
tools: list[Tool[object]] = []
|
||||
|
||||
for tool_config in self.config.tools:
|
||||
client = tool_clients.get(tool_config.plugin_id)
|
||||
if client is None:
|
||||
client = self.deps.execution_context.create_tool_client(
|
||||
plugin_id=tool_config.plugin_id,
|
||||
http_client=http_client,
|
||||
)
|
||||
tool_clients[tool_config.plugin_id] = client
|
||||
effective_parameters = [parameter.model_copy(deep=True) for parameter in tool_config.parameters]
|
||||
_validate_required_hidden_parameters(tool_config, effective_parameters)
|
||||
|
||||
tools.append(
|
||||
_build_pydantic_ai_tool(
|
||||
client=client,
|
||||
tool_config=tool_config,
|
||||
effective_parameters=effective_parameters,
|
||||
)
|
||||
)
|
||||
|
||||
return tools
|
||||
|
||||
|
||||
def _validate_required_hidden_parameters(
|
||||
tool_config: DifyPluginToolConfig,
|
||||
effective_parameters: Sequence[DifyPluginToolParameter],
|
||||
) -> None:
|
||||
missing_names = [
|
||||
parameter.name
|
||||
for parameter in effective_parameters
|
||||
if parameter.form is not DifyPluginToolParameterForm.LLM
|
||||
and parameter.required
|
||||
and parameter.default is None
|
||||
and parameter.name not in tool_config.runtime_parameters
|
||||
]
|
||||
if missing_names:
|
||||
names = ", ".join(sorted(missing_names))
|
||||
raise ValueError(f"Tool '{tool_config.tool_name}' requires non-LLM runtime_parameters for: {names}.")
|
||||
|
||||
|
||||
def _build_pydantic_ai_tool(
|
||||
*,
|
||||
client: DifyPluginDaemonToolClient,
|
||||
tool_config: DifyPluginToolConfig,
|
||||
effective_parameters: Sequence[DifyPluginToolParameter],
|
||||
) -> Tool[object]:
|
||||
tool_name = tool_config.name or tool_config.tool_name
|
||||
tool_description = tool_config.description or tool_name
|
||||
tool_schema = deepcopy(tool_config.parameters_json_schema)
|
||||
|
||||
async def invoke_tool(_ctx: RunContext[object], **tool_arguments: object) -> str:
|
||||
try:
|
||||
merged_arguments = _prepare_tool_arguments(effective_parameters, tool_config, tool_arguments)
|
||||
messages = await client.invoke(
|
||||
provider=tool_config.provider,
|
||||
tool_name=tool_config.tool_name,
|
||||
credential_type=tool_config.credential_type,
|
||||
credentials=dict(tool_config.credentials),
|
||||
tool_parameters=merged_arguments,
|
||||
)
|
||||
return _convert_tool_response_to_text(messages)
|
||||
except DifyPluginToolClientError as exc:
|
||||
return _tool_error_text(tool_name=tool_name, error=exc)
|
||||
except ValueError as exc:
|
||||
return f"tool parameters validation error: {exc}, please check your tool parameters"
|
||||
|
||||
async def prepare_tool_definition(_ctx: RunContext[object], tool_def: ToolDefinition) -> ToolDefinition:
|
||||
return ToolDefinition(
|
||||
name=tool_def.name,
|
||||
description=tool_def.description,
|
||||
parameters_json_schema=tool_schema,
|
||||
strict=PLUGIN_TOOL_STRICT,
|
||||
sequential=tool_def.sequential,
|
||||
metadata=tool_def.metadata,
|
||||
timeout=tool_def.timeout,
|
||||
defer_loading=tool_def.defer_loading,
|
||||
kind=tool_def.kind,
|
||||
return_schema=tool_def.return_schema,
|
||||
include_return_schema=tool_def.include_return_schema,
|
||||
)
|
||||
|
||||
return Tool(
|
||||
invoke_tool,
|
||||
takes_ctx=True,
|
||||
name=tool_name,
|
||||
description=tool_description,
|
||||
prepare=prepare_tool_definition,
|
||||
)
|
||||
|
||||
|
||||
def _prepare_tool_arguments(
|
||||
effective_parameters: Sequence[DifyPluginToolParameter],
|
||||
tool_config: DifyPluginToolConfig,
|
||||
tool_arguments: Mapping[str, object],
|
||||
) -> dict[str, object]:
|
||||
"""Build the daemon invocation payload from prepared config + model args.
|
||||
|
||||
Argument precedence intentionally mirrors the old Dify tool runtime contract:
|
||||
|
||||
1. start from config-supplied ``runtime_parameters`` for hidden/manual inputs;
|
||||
2. let model-supplied tool arguments override same-named entries;
|
||||
3. if neither provided a value, fall back to the prepared parameter default;
|
||||
4. if a required parameter still has no value, raise validation error.
|
||||
|
||||
Only parameters declared in ``effective_parameters`` are type-cast here;
|
||||
extra merged keys are passed through unchanged for forward compatibility with
|
||||
prepared config that may contain additional daemon inputs.
|
||||
"""
|
||||
merged_arguments: dict[str, object] = dict(tool_config.runtime_parameters)
|
||||
merged_arguments.update(tool_arguments)
|
||||
prepared_arguments: dict[str, object] = {}
|
||||
|
||||
for parameter in effective_parameters:
|
||||
if parameter.name in merged_arguments:
|
||||
value = merged_arguments[parameter.name]
|
||||
elif parameter.default is not None:
|
||||
value = parameter.default
|
||||
elif parameter.required:
|
||||
raise ValueError(f"tool parameter {parameter.name} not found in tool config")
|
||||
else:
|
||||
continue
|
||||
prepared_arguments[parameter.name] = _cast_tool_parameter_value(parameter.type, value)
|
||||
|
||||
for key, value in merged_arguments.items():
|
||||
prepared_arguments.setdefault(key, value)
|
||||
return prepared_arguments
|
||||
|
||||
|
||||
def _cast_tool_parameter_value(parameter_type: DifyPluginToolParameterType, value: object) -> object:
|
||||
"""Cast prepared tool argument values into daemon-facing wire shapes.
|
||||
|
||||
The API side prepares declaration metadata, but the actual invocation payload
|
||||
still needs to match Dify plugin-daemon expectations. This helper keeps the
|
||||
runtime-side coercion rules for common scalar, collection, file, and selector
|
||||
parameter types so model-supplied JSON values and config-supplied hidden
|
||||
inputs are normalized before transport.
|
||||
"""
|
||||
match parameter_type:
|
||||
case (
|
||||
DifyPluginToolParameterType.STRING
|
||||
| DifyPluginToolParameterType.SECRET_INPUT
|
||||
| DifyPluginToolParameterType.SELECT
|
||||
| DifyPluginToolParameterType.CHECKBOX
|
||||
| DifyPluginToolParameterType.DYNAMIC_SELECT
|
||||
):
|
||||
return "" if value is None else value if isinstance(value, str) else str(value)
|
||||
case DifyPluginToolParameterType.BOOLEAN:
|
||||
if value is None:
|
||||
return False
|
||||
if isinstance(value, str):
|
||||
lowered = value.lower()
|
||||
if lowered in {"true", "yes", "y", "1"}:
|
||||
return True
|
||||
if lowered in {"false", "no", "n", "0"}:
|
||||
return False
|
||||
return value if isinstance(value, bool) else bool(value)
|
||||
case DifyPluginToolParameterType.NUMBER:
|
||||
if isinstance(value, int | float):
|
||||
return value
|
||||
if isinstance(value, str) and value:
|
||||
return float(value) if "." in value else int(value)
|
||||
return value
|
||||
case DifyPluginToolParameterType.SYSTEM_FILES | DifyPluginToolParameterType.FILES:
|
||||
return value if isinstance(value, list) else [value]
|
||||
case DifyPluginToolParameterType.FILE:
|
||||
if isinstance(value, list):
|
||||
if len(value) != 1:
|
||||
raise ValueError("This parameter only accepts one file but got multiple files while invoking.")
|
||||
return value[0]
|
||||
return value
|
||||
case DifyPluginToolParameterType.MODEL_SELECTOR | DifyPluginToolParameterType.APP_SELECTOR:
|
||||
if not isinstance(value, dict):
|
||||
raise ValueError("The selector must be a dictionary.")
|
||||
return value
|
||||
case DifyPluginToolParameterType.ANY:
|
||||
if value is not None and not isinstance(value, dict | list | str | int | float | bool):
|
||||
raise ValueError("The var selector must be a string, dictionary, list or number.")
|
||||
return value
|
||||
case DifyPluginToolParameterType.ARRAY:
|
||||
if isinstance(value, list):
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
parsed_value = json.loads(value)
|
||||
except json.JSONDecodeError:
|
||||
return [value]
|
||||
if isinstance(parsed_value, list):
|
||||
return parsed_value
|
||||
return [value]
|
||||
case DifyPluginToolParameterType.OBJECT:
|
||||
if isinstance(value, dict):
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
parsed_value = json.loads(value)
|
||||
except json.JSONDecodeError:
|
||||
return {}
|
||||
if isinstance(parsed_value, dict):
|
||||
return parsed_value
|
||||
return {}
|
||||
|
||||
raise AssertionError(f"Unsupported tool parameter type: {parameter_type}")
|
||||
|
||||
|
||||
def _tool_error_text(*, tool_name: str, error: DifyPluginToolClientError) -> str:
|
||||
"""Map expected daemon/tool failures into agent-visible observation text.
|
||||
|
||||
Only known plugin-daemon rejection categories should be softened into tool
|
||||
observations. Unexpected local bugs are intentionally not handled here and
|
||||
should propagate so tests and callers notice the regression.
|
||||
"""
|
||||
error_type = error.error_type or ""
|
||||
if any(token in error_type for token in ("Credential", "Authorization", "Unauthorized")):
|
||||
return "Please check your tool provider credentials"
|
||||
if any(token in error_type for token in ("ToolNotFound", "ProviderNotFound")):
|
||||
return f"there is not a tool named {tool_name}"
|
||||
if error.status_code == 400 or any(token in error_type for token in ("BadRequest", "Validate", "Validation")):
|
||||
return f"tool parameters validation error: {error}, please check your tool parameters"
|
||||
return f"tool invoke error: {error}"
|
||||
|
||||
|
||||
def _convert_tool_response_to_text(tool_response: Sequence[DifyPluginToolInvokeMessage]) -> str:
|
||||
"""Convert daemon stream messages into the plain-text tool observation.
|
||||
|
||||
This preserves the user-facing semantics Dify's agent tool runtime relies on:
|
||||
text is appended directly, links/images become user-check instructions, JSON
|
||||
output is included unless explicitly suppressed, variable messages stay
|
||||
internal, and everything else falls back to ``str(message)``. JSON fragments
|
||||
are deduplicated against existing text so mixed text/JSON streams do not
|
||||
repeat the same content unnecessarily.
|
||||
"""
|
||||
parts: list[str] = []
|
||||
json_parts: list[str] = []
|
||||
|
||||
for response in tool_response:
|
||||
if response.type is DifyPluginToolInvokeMessage.MessageType.TEXT:
|
||||
text_message = response.message
|
||||
if isinstance(text_message, DifyPluginToolInvokeMessage.TextMessage):
|
||||
parts.append(text_message.text)
|
||||
elif response.type is DifyPluginToolInvokeMessage.MessageType.LINK:
|
||||
link_message = response.message
|
||||
if isinstance(link_message, DifyPluginToolInvokeMessage.TextMessage):
|
||||
parts.append(f"result link: {link_message.text}. please tell user to check it.")
|
||||
elif response.type in {
|
||||
DifyPluginToolInvokeMessage.MessageType.IMAGE_LINK,
|
||||
DifyPluginToolInvokeMessage.MessageType.IMAGE,
|
||||
}:
|
||||
parts.append(
|
||||
"image has been created and sent to user already, "
|
||||
"you do not need to create it, just tell the user to check it now."
|
||||
)
|
||||
elif response.type is DifyPluginToolInvokeMessage.MessageType.JSON:
|
||||
json_message = response.message
|
||||
if isinstance(json_message, DifyPluginToolInvokeMessage.JsonMessage) and not json_message.suppress_output:
|
||||
json_parts.append(json.dumps(json_message.json_object, ensure_ascii=False, default=str))
|
||||
elif response.type is DifyPluginToolInvokeMessage.MessageType.VARIABLE:
|
||||
continue
|
||||
else:
|
||||
parts.append(str(response.message))
|
||||
|
||||
if json_parts:
|
||||
existing_parts = set(parts)
|
||||
parts.extend(part for part in json_parts if part not in existing_parts)
|
||||
return "".join(parts)
|
||||
|
||||
|
||||
__all__ = ["DifyPluginToolsDeps", "DifyPluginToolsLayer"]
|
||||
@ -0,0 +1,18 @@
|
||||
"""Client-safe exports for the Dify execution-context layer DTOs.
|
||||
|
||||
Implementation layers live in sibling modules and require server-side runtime
|
||||
dependencies. Keep this package root import-safe for client code that only
|
||||
needs to build run requests.
|
||||
"""
|
||||
|
||||
from dify_agent.layers.execution_context.configs import (
|
||||
DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID,
|
||||
DifyExecutionContextInvokeFrom,
|
||||
DifyExecutionContextLayerConfig,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID",
|
||||
"DifyExecutionContextInvokeFrom",
|
||||
"DifyExecutionContextLayerConfig",
|
||||
]
|
||||
@ -0,0 +1,50 @@
|
||||
"""Client-safe DTOs for the Dify execution-context Agenton layer.
|
||||
|
||||
This layer carries Dify-owned execution identifiers plus the tenant/user daemon
|
||||
transport context shared by plugin-backed business layers. The identifiers are
|
||||
for observability and product correlation only; callers must not treat them as
|
||||
authorization proof. Server-only plugin-daemon settings are injected by the
|
||||
runtime provider factory and therefore do not appear in this public schema.
|
||||
"""
|
||||
|
||||
from typing import ClassVar, Final, Literal, TypeAlias
|
||||
|
||||
from pydantic import ConfigDict
|
||||
|
||||
from agenton.layers import LayerConfig
|
||||
|
||||
|
||||
DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID: Final[str] = "dify.execution_context"
|
||||
DifyExecutionContextInvokeFrom: TypeAlias = Literal[
|
||||
"workflow_run",
|
||||
"single_step",
|
||||
"agent_app",
|
||||
"babysit",
|
||||
"fasten",
|
||||
]
|
||||
|
||||
|
||||
class DifyExecutionContextLayerConfig(LayerConfig):
|
||||
"""Public config for Dify execution identity and daemon transport context."""
|
||||
|
||||
tenant_id: str
|
||||
user_id: str | None = None
|
||||
app_id: str | None = None
|
||||
workflow_id: str | None = None
|
||||
workflow_run_id: str | None = None
|
||||
node_id: str | None = None
|
||||
node_execution_id: str | None = None
|
||||
conversation_id: str | None = None
|
||||
agent_id: str | None = None
|
||||
agent_config_version_id: str | None = None
|
||||
invoke_from: DifyExecutionContextInvokeFrom
|
||||
trace_id: str | None = None
|
||||
|
||||
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid", arbitrary_types_allowed=True)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID",
|
||||
"DifyExecutionContextInvokeFrom",
|
||||
"DifyExecutionContextLayerConfig",
|
||||
]
|
||||
95
dify-agent/src/dify_agent/layers/execution_context/layer.py
Normal file
95
dify-agent/src/dify_agent/layers/execution_context/layer.py
Normal file
@ -0,0 +1,95 @@
|
||||
"""Runtime Dify execution-context layer.
|
||||
|
||||
The public config carries Dify-owned execution identifiers plus the tenant/user
|
||||
daemon context needed by plugin-backed business layers. Server-only daemon URL
|
||||
and API key are injected by the provider factory. The layer is intentionally
|
||||
config/settings-only under Agenton's state-only core: it does not open, cache,
|
||||
close, or snapshot HTTP clients, and its lifecycle hooks remain the inherited
|
||||
no-op hooks. Runtime code passes the FastAPI lifespan-owned shared
|
||||
``httpx.AsyncClient`` into ``create_daemon_provider`` or ``create_tool_client``
|
||||
for each invocation.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import ClassVar
|
||||
|
||||
import httpx
|
||||
from typing_extensions import Self, override
|
||||
|
||||
from agenton.layers import EmptyRuntimeState, NoLayerDeps, PlainLayer
|
||||
from dify_agent.adapters.llm import DifyPluginDaemonProvider
|
||||
from dify_agent.layers.dify_plugin.tool_client import DifyPluginDaemonToolClient
|
||||
from dify_agent.layers.execution_context.configs import (
|
||||
DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID,
|
||||
DifyExecutionContextLayerConfig,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class DifyExecutionContextLayer(PlainLayer[NoLayerDeps, DifyExecutionContextLayerConfig, EmptyRuntimeState]):
|
||||
"""Layer that carries Dify execution context without owning live resources."""
|
||||
|
||||
type_id: ClassVar[str] = DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID
|
||||
|
||||
config: DifyExecutionContextLayerConfig
|
||||
daemon_url: str
|
||||
daemon_api_key: str
|
||||
|
||||
@classmethod
|
||||
@override
|
||||
def from_config(cls, config: DifyExecutionContextLayerConfig) -> Self:
|
||||
"""Reject construction without server-injected daemon settings."""
|
||||
del config
|
||||
raise TypeError(
|
||||
"DifyExecutionContextLayer requires server-side daemon settings and must use a provider factory."
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_config_with_settings(
|
||||
cls,
|
||||
config: DifyExecutionContextLayerConfig,
|
||||
*,
|
||||
daemon_url: str,
|
||||
daemon_api_key: str,
|
||||
) -> Self:
|
||||
"""Create the layer from public config plus server-only daemon settings."""
|
||||
return cls(config=config, daemon_url=daemon_url, daemon_api_key=daemon_api_key)
|
||||
|
||||
def create_daemon_provider(self, *, plugin_id: str, http_client: httpx.AsyncClient) -> DifyPluginDaemonProvider:
|
||||
"""Return a daemon provider backed by the shared plugin daemon client.
|
||||
|
||||
Raises:
|
||||
RuntimeError: if ``http_client`` has already been closed.
|
||||
"""
|
||||
if http_client.is_closed:
|
||||
raise RuntimeError(
|
||||
"DifyExecutionContextLayer.create_daemon_provider() requires an open shared HTTP client."
|
||||
)
|
||||
return DifyPluginDaemonProvider(
|
||||
tenant_id=self.config.tenant_id,
|
||||
plugin_id=plugin_id,
|
||||
plugin_daemon_url=self.daemon_url,
|
||||
plugin_daemon_api_key=self.daemon_api_key,
|
||||
user_id=self.config.user_id,
|
||||
http_client=http_client,
|
||||
)
|
||||
|
||||
def create_tool_client(self, *, plugin_id: str, http_client: httpx.AsyncClient) -> DifyPluginDaemonToolClient:
|
||||
"""Return a plugin-daemon tool client backed by the shared HTTP client.
|
||||
|
||||
Raises:
|
||||
RuntimeError: if ``http_client`` has already been closed.
|
||||
"""
|
||||
if http_client.is_closed:
|
||||
raise RuntimeError("DifyExecutionContextLayer.create_tool_client() requires an open shared HTTP client.")
|
||||
return DifyPluginDaemonToolClient(
|
||||
tenant_id=self.config.tenant_id,
|
||||
plugin_id=plugin_id,
|
||||
plugin_daemon_url=self.daemon_url,
|
||||
plugin_daemon_api_key=self.daemon_api_key,
|
||||
user_id=self.config.user_id,
|
||||
http_client=http_client,
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["DifyExecutionContextLayer"]
|
||||
72
dify-agent/src/dify_agent/plugin_daemon_transport.py
Normal file
72
dify-agent/src/dify_agent/plugin_daemon_transport.py
Normal file
@ -0,0 +1,72 @@
|
||||
"""Shared plugin-daemon transport helpers.
|
||||
|
||||
These helpers define the common request-payload and nested-error semantics used
|
||||
by Dify Agent's LLM and tools daemon clients so the two transport adapters do
|
||||
not drift when the daemon protocol evolves.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import TypedDict
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class PluginDaemonErrorPayload(TypedDict):
|
||||
"""Decoded plugin-daemon error payload."""
|
||||
|
||||
error_type: str
|
||||
message: str
|
||||
|
||||
|
||||
def to_plugin_daemon_jsonable(value: object) -> object:
|
||||
"""Convert nested request data into JSON-safe daemon payload values."""
|
||||
if isinstance(value, BaseModel):
|
||||
return value.model_dump(mode="json")
|
||||
if isinstance(value, dict):
|
||||
return {key: to_plugin_daemon_jsonable(item) for key, item in value.items()}
|
||||
if isinstance(value, list | tuple):
|
||||
return [to_plugin_daemon_jsonable(item) for item in value]
|
||||
return value
|
||||
|
||||
|
||||
def decode_plugin_daemon_error_payload(raw_message: str) -> PluginDaemonErrorPayload | None:
|
||||
"""Decode one plugin-daemon JSON error payload if present."""
|
||||
try:
|
||||
parsed = json.loads(raw_message)
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
|
||||
if not isinstance(parsed, dict):
|
||||
return None
|
||||
|
||||
error_type = parsed.get("error_type")
|
||||
message = parsed.get("message")
|
||||
if not isinstance(error_type, str) or not isinstance(message, str):
|
||||
return None
|
||||
return {"error_type": error_type, "message": message}
|
||||
|
||||
|
||||
def unwrap_plugin_daemon_error(
|
||||
*,
|
||||
error_type: str,
|
||||
message: str,
|
||||
) -> PluginDaemonErrorPayload:
|
||||
"""Unwrap nested ``PluginInvokeError`` payloads to their effective error."""
|
||||
if error_type == "PluginInvokeError":
|
||||
nested_error = decode_plugin_daemon_error_payload(message)
|
||||
if nested_error is not None:
|
||||
return unwrap_plugin_daemon_error(
|
||||
error_type=nested_error["error_type"],
|
||||
message=nested_error["message"],
|
||||
)
|
||||
return {"error_type": error_type, "message": message}
|
||||
|
||||
|
||||
__all__ = [
|
||||
"PluginDaemonErrorPayload",
|
||||
"decode_plugin_daemon_error_payload",
|
||||
"to_plugin_daemon_jsonable",
|
||||
"unwrap_plugin_daemon_error",
|
||||
]
|
||||
@ -11,8 +11,6 @@ from .schemas import (
|
||||
CreateRunRequest,
|
||||
CreateRunResponse,
|
||||
EmptyRunEventData,
|
||||
ExecutionContext,
|
||||
InvokeFrom,
|
||||
LayerExitSignals,
|
||||
PydanticAIStreamRunEvent,
|
||||
RunCancelledEvent,
|
||||
@ -46,8 +44,6 @@ __all__ = [
|
||||
"DIFY_AGENT_MODEL_LAYER_ID",
|
||||
"DIFY_AGENT_OUTPUT_LAYER_ID",
|
||||
"EmptyRunEventData",
|
||||
"ExecutionContext",
|
||||
"InvokeFrom",
|
||||
"LayerExitSignals",
|
||||
"PydanticAIStreamRunEvent",
|
||||
"RUN_EVENT_ADAPTER",
|
||||
|
||||
@ -47,7 +47,6 @@ DIFY_AGENT_HISTORY_LAYER_ID: Final[str] = "history"
|
||||
DIFY_AGENT_OUTPUT_LAYER_ID: Final[str] = "output"
|
||||
RunStatus = Literal["running", "paused", "succeeded", "failed", "cancelled"]
|
||||
RunPurpose = Literal["workflow_node", "single_step", "agent_app", "babysit", "fasten_preview"]
|
||||
InvokeFrom = Literal["workflow_run", "single_step", "agent_app", "babysit", "fasten"]
|
||||
RunEventType = Literal[
|
||||
"run_started",
|
||||
"pydantic_ai_event",
|
||||
@ -106,29 +105,6 @@ class RunComposition(BaseModel):
|
||||
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class ExecutionContext(BaseModel):
|
||||
"""Dify-owned execution identifiers attached to one Agent backend run.
|
||||
|
||||
The Agent backend stores and replays this context for observability and
|
||||
product correlation only. It must not use these identifiers as authorization
|
||||
proof; API backend remains responsible for tenant and user access checks.
|
||||
"""
|
||||
|
||||
tenant_id: str
|
||||
app_id: str | None = None
|
||||
workflow_id: str | None = None
|
||||
workflow_run_id: str | None = None
|
||||
node_id: str | None = None
|
||||
node_execution_id: str | None = None
|
||||
conversation_id: str | None = None
|
||||
agent_id: str | None = None
|
||||
agent_config_version_id: str | None = None
|
||||
invoke_from: InvokeFrom
|
||||
trace_id: str | None = None
|
||||
|
||||
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class CreateRunRequest(BaseModel):
|
||||
"""Request body for creating one async agent run.
|
||||
|
||||
@ -142,11 +118,13 @@ class CreateRunRequest(BaseModel):
|
||||
explicitly request delete for one or more layers. Session snapshots do not
|
||||
preserve output-layer config, so resume requests that rely on structured
|
||||
output must include the same ``output`` layer in ``composition.layers[]`` to
|
||||
keep snapshot compatibility and rebuild the output schema.
|
||||
keep snapshot compatibility and rebuild the output schema. Dify tenant,
|
||||
user, and run-correlation identifiers must be submitted through a
|
||||
``dify.execution_context`` entry in ``composition.layers[]``; there is no
|
||||
parallel top-level ``execution_context`` request field.
|
||||
"""
|
||||
|
||||
composition: RunComposition
|
||||
execution_context: ExecutionContext | None = None
|
||||
purpose: RunPurpose = "workflow_node"
|
||||
idempotency_key: str | None = None
|
||||
metadata: dict[str, JsonValue] = Field(default_factory=dict)
|
||||
@ -356,8 +334,6 @@ __all__ = [
|
||||
"DIFY_AGENT_MODEL_LAYER_ID",
|
||||
"DIFY_AGENT_OUTPUT_LAYER_ID",
|
||||
"EmptyRunEventData",
|
||||
"ExecutionContext",
|
||||
"InvokeFrom",
|
||||
"LayerExitSignals",
|
||||
"PydanticAIStreamRunEvent",
|
||||
"RUN_EVENT_ADAPTER",
|
||||
|
||||
@ -2,12 +2,18 @@
|
||||
|
||||
Only explicitly allowed provider type ids are constructible here. The default
|
||||
provider set contains prompt layers, the optional pydantic-ai history layer, the
|
||||
state-free Dify structured output layer, plus Dify plugin LLM layers. Public
|
||||
DTOs provide tenant/plugin/model data, while server-only plugin daemon settings
|
||||
are injected through the provider factory for ``DifyPluginLayer``. The resulting
|
||||
``Compositor`` remains Agenton state-only: live resources such as the plugin
|
||||
daemon HTTP client are supplied later by the runtime and never enter providers,
|
||||
layers, or session snapshots.
|
||||
state-free Dify structured output layer, the Dify execution-context layer, and
|
||||
the Dify plugin business-layer family:
|
||||
|
||||
- ``dify.execution_context`` for shared tenant/user/run daemon context,
|
||||
- ``dify.plugin.llm`` for plugin-backed model selection, and
|
||||
- ``dify.plugin.tools`` for prepared plugin tool exposure.
|
||||
|
||||
Public DTOs provide Dify context plus plugin/model/tool data, while server-only
|
||||
plugin daemon settings are injected through the provider factory for
|
||||
``DifyExecutionContextLayer``. The resulting ``Compositor`` remains Agenton
|
||||
state-only: live resources such as the plugin daemon HTTP client are supplied
|
||||
later by the runtime and never enter providers, layers, or session snapshots.
|
||||
"""
|
||||
|
||||
from collections.abc import Mapping, Sequence
|
||||
@ -20,9 +26,10 @@ from agenton.layers.types import AllPromptTypes, AllToolTypes, AllUserPromptType
|
||||
from agenton_collections.layers.pydantic_ai import PydanticAIHistoryLayer
|
||||
from agenton_collections.layers.plain.basic import PromptLayer
|
||||
from agenton_collections.transformers.pydantic_ai import PYDANTIC_AI_TRANSFORMERS
|
||||
from dify_agent.layers.dify_plugin.configs import DifyPluginLayerConfig
|
||||
from dify_agent.layers.dify_plugin.llm_layer import DifyPluginLLMLayer
|
||||
from dify_agent.layers.dify_plugin.plugin_layer import DifyPluginLayer
|
||||
from dify_agent.layers.dify_plugin.tools_layer import DifyPluginToolsLayer
|
||||
from dify_agent.layers.execution_context.configs import DifyExecutionContextLayerConfig
|
||||
from dify_agent.layers.execution_context.layer import DifyExecutionContextLayer
|
||||
from dify_agent.layers.output.output_layer import DifyOutputLayer
|
||||
|
||||
|
||||
@ -40,14 +47,15 @@ def create_default_layer_providers(
|
||||
LayerProvider.from_layer_type(PydanticAIHistoryLayer),
|
||||
LayerProvider.from_layer_type(DifyOutputLayer),
|
||||
LayerProvider.from_factory(
|
||||
layer_type=DifyPluginLayer,
|
||||
create=lambda config: DifyPluginLayer.from_config_with_settings(
|
||||
DifyPluginLayerConfig.model_validate(config),
|
||||
layer_type=DifyExecutionContextLayer,
|
||||
create=lambda config: DifyExecutionContextLayer.from_config_with_settings(
|
||||
DifyExecutionContextLayerConfig.model_validate(config),
|
||||
daemon_url=plugin_daemon_url,
|
||||
daemon_api_key=plugin_daemon_api_key,
|
||||
),
|
||||
),
|
||||
LayerProvider.from_layer_type(DifyPluginLLMLayer),
|
||||
LayerProvider.from_layer_type(DifyPluginToolsLayer),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@ -5,12 +5,11 @@ The scheduler is intentionally process-local: it persists a run record, starts a
|
||||
task registry. Redis remains the durable source for status and event streams, but
|
||||
there is no Redis job queue or cross-process handoff. If the process crashes,
|
||||
currently active runs are lost until an external operator marks or retries them.
|
||||
Create-run validation enters a lightweight Agenton run before persistence so the
|
||||
same transformed user prompts, temporary system-prompt history assembly,
|
||||
optional structured output contract, and top-level ``on_exit`` policy used by
|
||||
execution are checked without relying on removed session/control APIs; Dify's
|
||||
default layers keep lifecycle hooks side-effect free so this validation does not
|
||||
open plugin daemon clients.
|
||||
Create-run requests are accepted once the scheduler is not stopping and storage
|
||||
can persist the run record. Request-shaped execution failures are left to
|
||||
``AgentRunRunner`` so bad compositions, ``on_exit`` policies, prompts,
|
||||
structured-output schemas, or session snapshots become asynchronous
|
||||
``run_failed`` outcomes instead of synchronous HTTP rejections.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
@ -21,15 +20,10 @@ from typing import Protocol
|
||||
import httpx
|
||||
|
||||
from agenton.compositor import LayerProviderInput
|
||||
from dify_agent.protocol.schemas import CreateRunRequest, normalize_composition
|
||||
from dify_agent.runtime.agenton_validation import is_agenton_enter_validation_runtime_error
|
||||
from dify_agent.runtime.compositor_factory import build_pydantic_ai_compositor, create_default_layer_providers
|
||||
from dify_agent.protocol.schemas import CreateRunRequest
|
||||
from dify_agent.runtime.compositor_factory import create_default_layer_providers
|
||||
from dify_agent.runtime.event_sink import RunEventSink, emit_run_failed
|
||||
from dify_agent.runtime.history import build_run_message_history, get_history_layer, validate_history_layer_composition
|
||||
from dify_agent.runtime.layer_exit_signals import apply_layer_exit_signals, validate_layer_exit_signals
|
||||
from dify_agent.runtime.output_type import resolve_run_output_contract, validate_output_layer_composition
|
||||
from dify_agent.runtime.runner import AgentRunRunner
|
||||
from dify_agent.runtime.user_prompt_validation import EMPTY_USER_PROMPTS_ERROR, has_non_blank_user_prompt
|
||||
from dify_agent.server.schemas import RunRecord
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@ -39,10 +33,6 @@ class SchedulerStoppingError(RuntimeError):
|
||||
"""Raised when a create-run request arrives after shutdown has started."""
|
||||
|
||||
|
||||
class RunRequestValidationError(ValueError):
|
||||
"""Raised when a create-run request cannot produce an executable Agenton run."""
|
||||
|
||||
|
||||
class RunStore(RunEventSink, Protocol):
|
||||
"""Persistence boundary needed by the scheduler."""
|
||||
|
||||
@ -68,9 +58,8 @@ class RunScheduler:
|
||||
``active_tasks`` is mutated only on the event loop that calls ``create_run``
|
||||
and ``shutdown``. The task registry is not durable; it exists so the lifespan
|
||||
hook can wait for in-flight work and mark cancelled runs failed before Redis is
|
||||
closed. A lock guards the stopping flag, lightweight request validation, run
|
||||
persistence, and task registration so shutdown cannot begin after a request is
|
||||
admitted and no validation runs once stopping has been set.
|
||||
closed. A lock guards the stopping flag, run persistence, and task
|
||||
registration so shutdown cannot begin after a request is admitted.
|
||||
"""
|
||||
|
||||
store: RunStore
|
||||
@ -101,15 +90,16 @@ class RunScheduler:
|
||||
self._lifecycle_lock = asyncio.Lock()
|
||||
|
||||
async def create_run(self, request: CreateRunRequest) -> RunRecord:
|
||||
"""Validate, persist, and schedule one run in the current process.
|
||||
"""Persist and schedule one run in the current process.
|
||||
|
||||
The returned record is already ``running``. The background task is removed
|
||||
from ``active_tasks`` when it finishes, regardless of success or failure.
|
||||
Request-shaped runtime failures are intentionally deferred to the runner so
|
||||
callers can observe them through the normal event/status stream.
|
||||
"""
|
||||
async with self._lifecycle_lock:
|
||||
if self.stopping:
|
||||
raise SchedulerStoppingError("run scheduler is shutting down")
|
||||
await validate_run_request(request, layer_providers=self.layer_providers)
|
||||
record = await self.store.create_run()
|
||||
task = asyncio.create_task(self._run_record(record, request), name=f"dify-agent-run-{record.run_id}")
|
||||
self.active_tasks[record.run_id] = task
|
||||
@ -164,52 +154,4 @@ class RunScheduler:
|
||||
logger.exception("failed to mark cancelled run failed", extra={"run_id": run_id})
|
||||
|
||||
|
||||
async def validate_run_request(
|
||||
request: CreateRunRequest,
|
||||
*,
|
||||
layer_providers: tuple[LayerProviderInput, ...] | None = None,
|
||||
) -> None:
|
||||
"""Validate create-run semantics that require an entered Agenton run.
|
||||
|
||||
This boundary rejects unsupported output/history-layer graph shapes, unknown
|
||||
``on_exit`` layer ids, effectively empty transformed user prompts, and known
|
||||
enter-time snapshot lifecycle errors before the scheduler persists a run
|
||||
record. It also exercises provider config validation, temporary
|
||||
system-prompt history assembly, structured output contract construction, and
|
||||
snapshot hydration without touching external services because Dify plugin
|
||||
daemon clients are owned by the FastAPI lifespan, not Agenton lifecycle
|
||||
hooks.
|
||||
"""
|
||||
resolved_layer_providers = layer_providers if layer_providers is not None else create_default_layer_providers()
|
||||
entered_run = False
|
||||
try:
|
||||
validate_output_layer_composition(request.composition)
|
||||
validate_history_layer_composition(request.composition)
|
||||
graph_config, layer_configs = normalize_composition(request.composition)
|
||||
compositor = build_pydantic_ai_compositor(
|
||||
graph_config,
|
||||
providers=resolved_layer_providers,
|
||||
)
|
||||
validate_layer_exit_signals(compositor, request.on_exit)
|
||||
async with compositor.enter(configs=layer_configs, session_snapshot=request.session_snapshot) as run:
|
||||
entered_run = True
|
||||
apply_layer_exit_signals(run, request.on_exit)
|
||||
history_layer = get_history_layer(run)
|
||||
_ = await build_run_message_history(
|
||||
system_prompts=run.prompts,
|
||||
stored_history=history_layer.message_history if history_layer is not None else (),
|
||||
)
|
||||
if not has_non_blank_user_prompt(run.user_prompts):
|
||||
raise RunRequestValidationError(EMPTY_USER_PROMPTS_ERROR)
|
||||
_ = resolve_run_output_contract(run)
|
||||
except RunRequestValidationError:
|
||||
raise
|
||||
except RuntimeError as exc:
|
||||
if not entered_run and is_agenton_enter_validation_runtime_error(exc):
|
||||
raise RunRequestValidationError(str(exc)) from exc
|
||||
raise
|
||||
except (KeyError, TypeError, ValueError) as exc:
|
||||
raise RunRequestValidationError(str(exc)) from exc
|
||||
|
||||
|
||||
__all__ = ["RunRequestValidationError", "RunScheduler", "SchedulerStoppingError", "validate_run_request"]
|
||||
__all__ = ["RunScheduler", "SchedulerStoppingError"]
|
||||
|
||||
@ -21,14 +21,17 @@ snapshot; there are no separate output or snapshot events to correlate.
|
||||
"""
|
||||
|
||||
from collections.abc import AsyncIterable
|
||||
from typing import cast
|
||||
from collections import Counter
|
||||
from typing import Any, cast
|
||||
|
||||
import httpx
|
||||
from pydantic import JsonValue, TypeAdapter
|
||||
from pydantic_ai.messages import AgentStreamEvent
|
||||
|
||||
from agenton.compositor import CompositorSessionSnapshot, LayerProviderInput
|
||||
from agenton.layers.types import PydanticAITool
|
||||
from dify_agent.layers.dify_plugin.llm_layer import DifyPluginLLMLayer
|
||||
from dify_agent.layers.dify_plugin.tools_layer import DifyPluginToolsLayer
|
||||
from dify_agent.protocol.schemas import DIFY_AGENT_MODEL_LAYER_ID, CreateRunRequest, normalize_composition
|
||||
from dify_agent.runtime.agent_factory import create_agent, normalize_user_input
|
||||
from dify_agent.runtime.agenton_validation import is_agenton_enter_validation_runtime_error
|
||||
@ -149,12 +152,13 @@ class AgentRunRunner:
|
||||
)
|
||||
llm_layer = run.get_layer(DIFY_AGENT_MODEL_LAYER_ID, DifyPluginLLMLayer)
|
||||
model = llm_layer.get_model(http_client=self.plugin_daemon_http_client)
|
||||
tools = await _resolve_run_tools(run, http_client=self.plugin_daemon_http_client)
|
||||
except (KeyError, TypeError, RuntimeError, ValueError) as exc:
|
||||
raise AgentRunValidationError(str(exc)) from exc
|
||||
|
||||
agent = create_agent(
|
||||
model,
|
||||
tools=run.tools,
|
||||
tools=tools,
|
||||
output_type=output_contract.output_type,
|
||||
)
|
||||
result = await agent.run(
|
||||
@ -180,4 +184,27 @@ def _serialize_agent_output(output: object) -> JsonValue:
|
||||
return cast(JsonValue, _AGENT_OUTPUT_ADAPTER.dump_python(output, mode="json"))
|
||||
|
||||
|
||||
async def _resolve_run_tools(
|
||||
run: Any,
|
||||
*,
|
||||
http_client: httpx.AsyncClient,
|
||||
) -> list[PydanticAITool[object]]:
|
||||
"""Return the static compositor tools plus any Dify plugin runtime tools."""
|
||||
resolved_tools = list(cast(list[PydanticAITool[object]], run.tools))
|
||||
for slot in run.slots.values():
|
||||
layer = slot.layer
|
||||
if isinstance(layer, DifyPluginToolsLayer):
|
||||
resolved_tools.extend(await layer.get_tools(http_client=http_client))
|
||||
_validate_unique_tool_names(resolved_tools)
|
||||
return resolved_tools
|
||||
|
||||
|
||||
def _validate_unique_tool_names(tools: list[PydanticAITool[object]]) -> None:
|
||||
"""Reject duplicate tool names across static and dynamic tool sources."""
|
||||
duplicate_names = sorted(name for name, count in Counter(tool.name for tool in tools).items() if count > 1)
|
||||
if duplicate_names:
|
||||
names = ", ".join(duplicate_names)
|
||||
raise ValueError(f"Agent run requires unique tool names across all layers, got duplicates: {names}.")
|
||||
|
||||
|
||||
__all__ = ["AgentRunRunner", "AgentRunValidationError"]
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
"""Validation for effective user prompts produced by Agenton runs.
|
||||
|
||||
Validation happens after safe compositor construction and run entry so scheduler
|
||||
and runner paths use the same transformed prompts as the actual pydantic-ai
|
||||
input. Blank string fragments do not count as meaningful input; non-string
|
||||
Validation happens after safe compositor construction and run entry so runtime
|
||||
execution uses the same transformed prompts as the actual pydantic-ai input.
|
||||
Blank string fragments do not count as meaningful input; non-string
|
||||
``UserContent`` is treated as intentional content because rich media/message
|
||||
parts do not have a universal whitespace representation.
|
||||
"""
|
||||
|
||||
@ -1,10 +1,13 @@
|
||||
"""FastAPI routes for asynchronous agent runs.
|
||||
|
||||
Controllers translate known validation and shutdown errors into HTTP status codes.
|
||||
Unexpected scheduler or storage failures are intentionally left for FastAPI's
|
||||
server-error handling so infrastructure problems are not reported as client input
|
||||
errors. Created runs are scheduled in the current process and observed through
|
||||
status polling or SSE replay backed by Redis event streams.
|
||||
Controllers translate shutdown errors into HTTP status codes. Runtime request
|
||||
failures are intentionally not pre-mapped here: once a request passes DTO
|
||||
validation it is accepted for background execution, and bad compositions or
|
||||
snapshots fail later through normal run events/status. Unexpected scheduler or
|
||||
storage failures are intentionally left for FastAPI's server-error handling so
|
||||
infrastructure problems are not reported as client input errors. Created runs
|
||||
are scheduled in the current process and observed through status polling or SSE
|
||||
replay backed by Redis event streams.
|
||||
"""
|
||||
|
||||
from collections.abc import Callable
|
||||
@ -21,7 +24,7 @@ from dify_agent.protocol.schemas import (
|
||||
RunEventsResponse,
|
||||
RunStatusResponse,
|
||||
)
|
||||
from dify_agent.runtime.run_scheduler import RunRequestValidationError, RunScheduler, SchedulerStoppingError
|
||||
from dify_agent.runtime.run_scheduler import RunScheduler, SchedulerStoppingError
|
||||
from dify_agent.server.sse import sse_event_stream
|
||||
from dify_agent.storage.redis_run_store import RedisRunStore, RunNotFoundError
|
||||
|
||||
@ -46,8 +49,6 @@ def create_runs_router(
|
||||
) -> CreateRunResponse:
|
||||
try:
|
||||
record = await scheduler.create_run(request)
|
||||
except RunRequestValidationError as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
except SchedulerStoppingError as exc:
|
||||
raise HTTPException(status_code=503, detail="run scheduler is shutting down") from exc
|
||||
return CreateRunResponse(run_id=record.run_id, status=record.status)
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
@ -5,55 +7,54 @@ import dify_agent.layers.dify_plugin as dify_plugin_exports
|
||||
from dify_agent.layers.dify_plugin import (
|
||||
DifyPluginCredentialValue,
|
||||
DifyPluginLLMLayerConfig,
|
||||
DifyPluginLayerConfig,
|
||||
DifyPluginToolCredentialType,
|
||||
DifyPluginToolConfig,
|
||||
DifyPluginToolParameter,
|
||||
DifyPluginToolParameterForm,
|
||||
DifyPluginToolParameterType,
|
||||
DifyPluginToolsLayerConfig,
|
||||
DifyPluginToolValue,
|
||||
)
|
||||
|
||||
|
||||
def test_dify_plugin_package_exports_client_safe_config_symbols_only() -> None:
|
||||
assert dify_plugin_exports.__all__ == [
|
||||
"DIFY_PLUGIN_LAYER_TYPE_ID",
|
||||
"DIFY_PLUGIN_LLM_LAYER_TYPE_ID",
|
||||
"DIFY_PLUGIN_TOOLS_LAYER_TYPE_ID",
|
||||
"DifyPluginCredentialValue",
|
||||
"DifyPluginLLMLayerConfig",
|
||||
"DifyPluginLayerConfig",
|
||||
"DifyPluginToolCredentialType",
|
||||
"DifyPluginToolConfig",
|
||||
"DifyPluginToolOption",
|
||||
"DifyPluginToolParameter",
|
||||
"DifyPluginToolParameterForm",
|
||||
"DifyPluginToolParameterType",
|
||||
"DifyPluginToolsLayerConfig",
|
||||
"DifyPluginToolValue",
|
||||
]
|
||||
assert dify_plugin_exports.DIFY_PLUGIN_LAYER_TYPE_ID == "dify.plugin"
|
||||
assert dify_plugin_exports.DIFY_PLUGIN_LLM_LAYER_TYPE_ID == "dify.plugin.llm"
|
||||
assert not hasattr(dify_plugin_exports, "DifyPluginLayer")
|
||||
assert dify_plugin_exports.DIFY_PLUGIN_TOOLS_LAYER_TYPE_ID == "dify.plugin.tools"
|
||||
assert not hasattr(dify_plugin_exports, "DifyPluginLLMLayer")
|
||||
|
||||
|
||||
def test_dify_plugin_layer_config_forbids_runtime_settings() -> None:
|
||||
config = DifyPluginLayerConfig(tenant_id="tenant-1", plugin_id="plugin-1", user_id="user-1")
|
||||
|
||||
assert config.tenant_id == "tenant-1"
|
||||
assert config.plugin_id == "plugin-1"
|
||||
assert config.user_id == "user-1"
|
||||
with pytest.raises(ValidationError):
|
||||
_ = DifyPluginLayerConfig.model_validate(
|
||||
{
|
||||
"tenant_id": "tenant-1",
|
||||
"plugin_id": "plugin-1",
|
||||
"daemon_url": "http://daemon",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_dify_plugin_llm_config_accepts_scalar_credentials_and_model_settings() -> None:
|
||||
credential: DifyPluginCredentialValue = "secret"
|
||||
config = DifyPluginLLMLayerConfig(
|
||||
plugin_id="langgenius/openai",
|
||||
model_provider="openai",
|
||||
model="gpt-4o-mini",
|
||||
credentials={"api_key": credential, "enabled": True, "retries": 2, "ratio": 0.5, "empty": None},
|
||||
model_settings={"temperature": 0.2, "max_tokens": 64},
|
||||
)
|
||||
|
||||
assert config.plugin_id == "langgenius/openai"
|
||||
assert config.model_provider == "openai"
|
||||
assert config.credentials == {"api_key": "secret", "enabled": True, "retries": 2, "ratio": 0.5, "empty": None}
|
||||
assert config.model_settings == {"temperature": 0.2, "max_tokens": 64}
|
||||
with pytest.raises(ValidationError):
|
||||
_ = DifyPluginLLMLayerConfig.model_validate(
|
||||
{
|
||||
"plugin_id": "langgenius/openai",
|
||||
"model_provider": "openai",
|
||||
"model": "gpt-4o-mini",
|
||||
"credentials": {"nested": {"not": "allowed"}},
|
||||
@ -66,6 +67,154 @@ def test_dify_plugin_llm_config_rejects_old_provider_field() -> None:
|
||||
_ = DifyPluginLLMLayerConfig.model_validate(
|
||||
{
|
||||
"provider": "openai",
|
||||
"plugin_id": "langgenius/openai",
|
||||
"model": "gpt-4o-mini",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_dify_plugin_tools_layer_config_accepts_prepared_parameters_and_schema() -> None:
|
||||
runtime_value: DifyPluginToolValue = {"locale": "en-US", "max_results": 5}
|
||||
credential_type: DifyPluginToolCredentialType = "api-key"
|
||||
config = DifyPluginToolsLayerConfig(
|
||||
tools=[
|
||||
DifyPluginToolConfig(
|
||||
plugin_id="langgenius/tools",
|
||||
provider="search",
|
||||
tool_name="web_search",
|
||||
credential_type=credential_type,
|
||||
name="search_web",
|
||||
description="Search the web.",
|
||||
credentials={"api_key": "secret"},
|
||||
runtime_parameters={"settings": runtime_value},
|
||||
parameters=[
|
||||
DifyPluginToolParameter(
|
||||
name="query",
|
||||
type=DifyPluginToolParameterType.STRING,
|
||||
form=DifyPluginToolParameterForm.LLM,
|
||||
required=True,
|
||||
llm_description="Search query",
|
||||
)
|
||||
],
|
||||
parameters_json_schema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {"type": "string", "description": "Search query"},
|
||||
},
|
||||
"required": ["query"],
|
||||
},
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
assert config.tools[0].plugin_id == "langgenius/tools"
|
||||
assert config.tools[0].provider == "search"
|
||||
assert config.tools[0].tool_name == "web_search"
|
||||
assert config.tools[0].credential_type == "api-key"
|
||||
assert config.tools[0].name == "search_web"
|
||||
assert config.tools[0].runtime_parameters == {"settings": {"locale": "en-US", "max_results": 5}}
|
||||
assert config.tools[0].parameters[0].name == "query"
|
||||
assert config.tools[0].parameters_json_schema["required"] == ["query"]
|
||||
|
||||
|
||||
def test_dify_plugin_tool_parameter_accepts_api_tool_parameter_dump_shape() -> None:
|
||||
parameter = DifyPluginToolParameter.model_validate(
|
||||
{
|
||||
"name": "query",
|
||||
"label": {"en_US": "Query"},
|
||||
"placeholder": None,
|
||||
"human_description": {"en_US": "Visible in UI"},
|
||||
"type": "select",
|
||||
"form": "llm",
|
||||
"required": True,
|
||||
"default": "dify",
|
||||
"llm_description": "Search query",
|
||||
"input_schema": {"type": "string"},
|
||||
"options": [
|
||||
{
|
||||
"value": "dify",
|
||||
"label": {"en_US": "Dify"},
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
assert parameter.name == "query"
|
||||
assert parameter.type is DifyPluginToolParameterType.SELECT
|
||||
assert parameter.form is DifyPluginToolParameterForm.LLM
|
||||
assert parameter.required is True
|
||||
assert parameter.default == "dify"
|
||||
assert parameter.input_schema == {"type": "string"}
|
||||
assert [option.value for option in parameter.options] == ["dify"]
|
||||
|
||||
|
||||
def test_dify_plugin_tool_parameter_accepts_api_tool_parameter_attributes() -> None:
|
||||
parameter = DifyPluginToolParameter.model_validate(
|
||||
SimpleNamespace(
|
||||
name="language",
|
||||
label=SimpleNamespace(en_US="Language"),
|
||||
type="string",
|
||||
form="form",
|
||||
required=False,
|
||||
default="en",
|
||||
llm_description=None,
|
||||
input_schema=None,
|
||||
options=[SimpleNamespace(value="en", label=SimpleNamespace(en_US="English"))],
|
||||
)
|
||||
)
|
||||
|
||||
assert parameter.name == "language"
|
||||
assert parameter.type is DifyPluginToolParameterType.STRING
|
||||
assert parameter.form is DifyPluginToolParameterForm.FORM
|
||||
assert parameter.default == "en"
|
||||
assert [option.value for option in parameter.options] == ["en"]
|
||||
|
||||
|
||||
def test_dify_plugin_tool_config_rejects_non_json_runtime_parameters() -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
_ = DifyPluginToolConfig.model_validate(
|
||||
{
|
||||
"plugin_id": "langgenius/tools",
|
||||
"provider": "search",
|
||||
"tool_name": "web_search",
|
||||
"credential_type": "api-key",
|
||||
"runtime_parameters": {"bad": object()},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_dify_plugin_tool_config_rejects_non_json_schema_values() -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
_ = DifyPluginToolConfig.model_validate(
|
||||
{
|
||||
"plugin_id": "langgenius/tools",
|
||||
"provider": "search",
|
||||
"tool_name": "web_search",
|
||||
"credential_type": "api-key",
|
||||
"parameters_json_schema": {"type": object()},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_dify_plugin_tool_config_rejects_strict_flag() -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
_ = DifyPluginToolConfig.model_validate(
|
||||
{
|
||||
"plugin_id": "langgenius/tools",
|
||||
"provider": "search",
|
||||
"tool_name": "web_search",
|
||||
"credential_type": "api-key",
|
||||
"strict": True,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_dify_plugin_tool_config_requires_explicit_credential_type() -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
_ = DifyPluginToolConfig.model_validate(
|
||||
{
|
||||
"plugin_id": "langgenius/tools",
|
||||
"provider": "search",
|
||||
"tool_name": "web_search",
|
||||
}
|
||||
)
|
||||
|
||||
@ -1,26 +1,36 @@
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from pydantic import JsonValue
|
||||
|
||||
from agenton.compositor import Compositor, LayerNode, LayerProvider
|
||||
from dify_agent.adapters.llm import DifyLLMAdapterModel
|
||||
from dify_agent.layers.dify_plugin.configs import (
|
||||
DIFY_PLUGIN_LAYER_TYPE_ID,
|
||||
DIFY_PLUGIN_LLM_LAYER_TYPE_ID,
|
||||
DIFY_PLUGIN_TOOLS_LAYER_TYPE_ID,
|
||||
DifyPluginLLMLayerConfig,
|
||||
DifyPluginLayerConfig,
|
||||
DifyPluginToolConfig,
|
||||
DifyPluginToolOption,
|
||||
DifyPluginToolParameter,
|
||||
DifyPluginToolParameterForm,
|
||||
DifyPluginToolParameterType,
|
||||
DifyPluginToolsLayerConfig,
|
||||
)
|
||||
from dify_agent.layers.dify_plugin.llm_layer import DifyPluginLLMLayer
|
||||
from dify_agent.layers.dify_plugin.plugin_layer import DifyPluginLayer
|
||||
from dify_agent.layers.dify_plugin.tools_layer import DifyPluginToolsLayer
|
||||
from dify_agent.layers.execution_context import DifyExecutionContextLayerConfig
|
||||
from dify_agent.layers.execution_context.layer import DifyExecutionContextLayer
|
||||
|
||||
|
||||
def _plugin_config() -> DifyPluginLayerConfig:
|
||||
return DifyPluginLayerConfig(tenant_id="tenant-1", plugin_id="langgenius/openai", user_id="user-1")
|
||||
def _execution_context_config() -> DifyExecutionContextLayerConfig:
|
||||
return DifyExecutionContextLayerConfig(tenant_id="tenant-1", user_id="user-1", invoke_from="workflow_run")
|
||||
|
||||
|
||||
def _llm_config() -> DifyPluginLLMLayerConfig:
|
||||
return DifyPluginLLMLayerConfig(
|
||||
plugin_id="langgenius/openai",
|
||||
model_provider="openai",
|
||||
model="demo-model",
|
||||
credentials={"api_key": "secret"},
|
||||
@ -28,82 +38,192 @@ def _llm_config() -> DifyPluginLLMLayerConfig:
|
||||
)
|
||||
|
||||
|
||||
def _plugin_layer() -> DifyPluginLayer:
|
||||
return DifyPluginLayer.from_config_with_settings(
|
||||
_plugin_config(),
|
||||
daemon_url="http://plugin-daemon",
|
||||
daemon_api_key="daemon-secret",
|
||||
def _tools_config() -> DifyPluginToolsLayerConfig:
|
||||
return DifyPluginToolsLayerConfig(
|
||||
tools=[
|
||||
DifyPluginToolConfig(
|
||||
plugin_id="langgenius/tools",
|
||||
provider="search",
|
||||
tool_name="web_search",
|
||||
credential_type="api-key",
|
||||
description="Search the web.",
|
||||
credentials={"api_key": "secret"},
|
||||
runtime_parameters={"api_version": "2026-01", "auth_scope": "workspace"},
|
||||
parameters=_prepared_tool_parameters(),
|
||||
parameters_json_schema=_prepared_tool_schema(),
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def _plugin_provider() -> LayerProvider[DifyPluginLayer]:
|
||||
def _missing_hidden_parameter_tools_config() -> DifyPluginToolsLayerConfig:
|
||||
return DifyPluginToolsLayerConfig(
|
||||
tools=[
|
||||
DifyPluginToolConfig(
|
||||
plugin_id="langgenius/tools",
|
||||
provider="search",
|
||||
tool_name="web_search",
|
||||
credential_type="api-key",
|
||||
description="Search the web.",
|
||||
credentials={"api_key": "secret"},
|
||||
runtime_parameters={"api_version": "2026-01"},
|
||||
parameters=_prepared_tool_parameters(),
|
||||
parameters_json_schema=_prepared_tool_schema(),
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def _execution_context_provider() -> LayerProvider[DifyExecutionContextLayer]:
|
||||
return LayerProvider.from_factory(
|
||||
layer_type=DifyPluginLayer,
|
||||
create=lambda config: DifyPluginLayer.from_config_with_settings(
|
||||
DifyPluginLayerConfig.model_validate(config),
|
||||
layer_type=DifyExecutionContextLayer,
|
||||
create=lambda config: DifyExecutionContextLayer.from_config_with_settings(
|
||||
DifyExecutionContextLayerConfig.model_validate(config),
|
||||
daemon_url="http://plugin-daemon",
|
||||
daemon_api_key="daemon-secret",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _prepared_tool_parameters() -> list[DifyPluginToolParameter]:
|
||||
return [
|
||||
DifyPluginToolParameter(
|
||||
name="query",
|
||||
type=DifyPluginToolParameterType.STRING,
|
||||
form=DifyPluginToolParameterForm.LLM,
|
||||
required=True,
|
||||
llm_description="Search query",
|
||||
),
|
||||
DifyPluginToolParameter(
|
||||
name="region",
|
||||
type=DifyPluginToolParameterType.SELECT,
|
||||
form=DifyPluginToolParameterForm.LLM,
|
||||
required=False,
|
||||
llm_description="Search region",
|
||||
options=[DifyPluginToolOption(value="global"), DifyPluginToolOption(value="cn")],
|
||||
),
|
||||
DifyPluginToolParameter(
|
||||
name="api_version",
|
||||
type=DifyPluginToolParameterType.STRING,
|
||||
form=DifyPluginToolParameterForm.FORM,
|
||||
required=True,
|
||||
llm_description="Hidden API version",
|
||||
),
|
||||
DifyPluginToolParameter(
|
||||
name="auth_scope",
|
||||
type=DifyPluginToolParameterType.STRING,
|
||||
form=DifyPluginToolParameterForm.FORM,
|
||||
required=True,
|
||||
llm_description="Hidden auth scope",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def _prepared_tool_schema() -> dict[str, JsonValue]:
|
||||
return {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {"type": "string", "description": "Search query"},
|
||||
"region": {
|
||||
"type": "string",
|
||||
"description": "Search region",
|
||||
"enum": ["global", "cn"],
|
||||
},
|
||||
},
|
||||
"required": ["query"],
|
||||
}
|
||||
|
||||
|
||||
def _llm_only_parameter(*, name: str, description: str, default: JsonValue = None) -> DifyPluginToolParameter:
|
||||
return DifyPluginToolParameter(
|
||||
name=name,
|
||||
type=DifyPluginToolParameterType.STRING,
|
||||
form=DifyPluginToolParameterForm.LLM,
|
||||
required=default is None,
|
||||
default=default,
|
||||
llm_description=description,
|
||||
)
|
||||
|
||||
|
||||
def _invoke_stream_response(
|
||||
*,
|
||||
error_payload: dict[str, object] | None = None,
|
||||
chunked_blob: bool = False,
|
||||
) -> httpx.Response:
|
||||
if error_payload is not None:
|
||||
return httpx.Response(400, json=error_payload)
|
||||
|
||||
if chunked_blob:
|
||||
stream_payload = "\n".join(
|
||||
[
|
||||
f"data: {json.dumps({'code': 0, 'message': 'ok', 'data': {'type': 'blob_chunk', 'message': {'id': 'blob-1', 'sequence': 0, 'total_length': 11, 'blob': 'aGVsbG8g', 'end': False}}})}",
|
||||
f"data: {json.dumps({'code': 0, 'message': 'ok', 'data': {'type': 'blob_chunk', 'message': {'id': 'blob-1', 'sequence': 1, 'total_length': 11, 'blob': 'd29ybGQ=', 'end': True}}})}",
|
||||
"",
|
||||
]
|
||||
)
|
||||
return httpx.Response(200, text=stream_payload)
|
||||
|
||||
stream_payload = "\n".join(
|
||||
[
|
||||
f"data: {json.dumps({'code': 0, 'message': 'ok', 'data': {'type': 'text', 'message': {'text': 'found '}}})}",
|
||||
f"data: {json.dumps({'code': 0, 'message': 'ok', 'data': {'type': 'json', 'message': {'json_object': {'count': 1}}}})}",
|
||||
"",
|
||||
]
|
||||
)
|
||||
return httpx.Response(200, text=stream_payload)
|
||||
|
||||
|
||||
def _tool_transport(
|
||||
*,
|
||||
invoke_error_payload: dict[str, object] | None = None,
|
||||
chunked_blob: bool = False,
|
||||
) -> httpx.MockTransport:
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
if request.url.path.endswith("/dispatch/tool/invoke"):
|
||||
payload = json.loads(request.content.decode("utf-8"))
|
||||
assert payload["user_id"] == "user-1"
|
||||
assert payload["data"]["provider"] == "search"
|
||||
assert payload["data"]["tool"] == "web_search"
|
||||
assert payload["data"]["credential_type"] == "api-key"
|
||||
assert payload["data"]["tool_parameters"] == {
|
||||
"query": "dify",
|
||||
"region": "global",
|
||||
"api_version": "2026-01",
|
||||
"auth_scope": "workspace",
|
||||
}
|
||||
return _invoke_stream_response(error_payload=invoke_error_payload, chunked_blob=chunked_blob)
|
||||
|
||||
raise AssertionError(f"Unexpected request path: {request.url.path}")
|
||||
|
||||
return httpx.MockTransport(handler)
|
||||
|
||||
|
||||
def test_dify_plugin_type_id_constants_match_implementation_classes() -> None:
|
||||
assert DIFY_PLUGIN_LAYER_TYPE_ID == DifyPluginLayer.type_id
|
||||
assert DIFY_PLUGIN_LLM_LAYER_TYPE_ID == DifyPluginLLMLayer.type_id
|
||||
|
||||
|
||||
def test_dify_plugin_layer_creates_daemon_provider_from_shared_http_client() -> None:
|
||||
async def scenario() -> None:
|
||||
plugin = _plugin_layer()
|
||||
async with httpx.AsyncClient(transport=httpx.MockTransport(lambda _request: httpx.Response(200))) as client:
|
||||
provider = plugin.create_daemon_provider(http_client=client)
|
||||
|
||||
assert provider.name == "DifyPlugin/langgenius/openai"
|
||||
assert provider.client.http_client is client
|
||||
assert provider.client.tenant_id == "tenant-1"
|
||||
assert provider.client.plugin_id == "langgenius/openai"
|
||||
assert provider.client.user_id == "user-1"
|
||||
|
||||
async with provider:
|
||||
pass
|
||||
assert client.is_closed is False
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_dify_plugin_layer_rejects_closed_shared_http_client() -> None:
|
||||
async def scenario() -> None:
|
||||
plugin = _plugin_layer()
|
||||
client = httpx.AsyncClient()
|
||||
await client.aclose()
|
||||
|
||||
with pytest.raises(RuntimeError, match="open shared HTTP client"):
|
||||
_ = plugin.create_daemon_provider(http_client=client)
|
||||
|
||||
asyncio.run(scenario())
|
||||
assert DIFY_PLUGIN_TOOLS_LAYER_TYPE_ID == DifyPluginToolsLayer.type_id
|
||||
|
||||
|
||||
def test_dify_plugin_llm_layer_builds_adapter_model_from_direct_dependency() -> None:
|
||||
async def scenario() -> None:
|
||||
compositor = Compositor(
|
||||
[
|
||||
LayerNode("renamed-plugin", _plugin_provider()),
|
||||
LayerNode("llm", DifyPluginLLMLayer, deps={"plugin": "renamed-plugin"}),
|
||||
LayerNode("renamed-execution-context", _execution_context_provider()),
|
||||
LayerNode("llm", DifyPluginLLMLayer, deps={"execution_context": "renamed-execution-context"}),
|
||||
]
|
||||
)
|
||||
async with httpx.AsyncClient(transport=httpx.MockTransport(lambda _request: httpx.Response(200))) as client:
|
||||
async with compositor.enter(
|
||||
configs={
|
||||
"renamed-plugin": _plugin_config(),
|
||||
"renamed-execution-context": _execution_context_config(),
|
||||
"llm": _llm_config(),
|
||||
}
|
||||
) as run:
|
||||
plugin = run.get_layer("renamed-plugin", DifyPluginLayer)
|
||||
execution_context = run.get_layer("renamed-execution-context", DifyExecutionContextLayer)
|
||||
llm = run.get_layer("llm", DifyPluginLLMLayer)
|
||||
|
||||
model = llm.get_model(http_client=client)
|
||||
|
||||
assert llm.deps.plugin is plugin
|
||||
assert llm.deps.execution_context is execution_context
|
||||
assert isinstance(model, DifyLLMAdapterModel)
|
||||
assert model.model_name == "demo-model"
|
||||
assert model.model_provider == "openai"
|
||||
@ -114,17 +234,436 @@ def test_dify_plugin_llm_layer_builds_adapter_model_from_direct_dependency() ->
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_dify_plugin_layer_lifecycle_does_not_manage_http_client() -> None:
|
||||
def test_dify_plugin_tools_layer_uses_prepared_tool_definition_and_invokes_daemon() -> None:
|
||||
async def scenario() -> None:
|
||||
compositor = Compositor([LayerNode("plugin", _plugin_provider())])
|
||||
async with httpx.AsyncClient(transport=httpx.MockTransport(lambda _request: httpx.Response(200))) as client:
|
||||
async with compositor.enter(configs={"plugin": _plugin_config()}) as run:
|
||||
plugin = run.get_layer("plugin", DifyPluginLayer)
|
||||
provider = plugin.create_daemon_provider(http_client=client)
|
||||
run.suspend_layer_on_exit("plugin")
|
||||
compositor = Compositor(
|
||||
[
|
||||
LayerNode("execution_context", _execution_context_provider()),
|
||||
LayerNode("tools", DifyPluginToolsLayer, deps={"execution_context": "execution_context"}),
|
||||
]
|
||||
)
|
||||
async with httpx.AsyncClient(transport=_tool_transport()) as client:
|
||||
async with compositor.enter(
|
||||
configs={"execution_context": _execution_context_config(), "tools": _tools_config()}
|
||||
) as run:
|
||||
tools_layer = run.get_layer("tools", DifyPluginToolsLayer)
|
||||
tool = (await tools_layer.get_tools(http_client=client))[0]
|
||||
|
||||
assert run.session_snapshot is not None
|
||||
assert provider.client.http_client is client
|
||||
assert client.is_closed is False
|
||||
tool_def = await tool.prepare_tool_def(None) # pyright: ignore[reportArgumentType]
|
||||
result = await tool.function_schema.call(
|
||||
{"query": "dify", "region": "global"},
|
||||
None, # pyright: ignore[reportArgumentType]
|
||||
)
|
||||
|
||||
assert tool.name == "web_search"
|
||||
assert tool.description == "Search the web."
|
||||
assert tool_def is not None
|
||||
assert tool_def.parameters_json_schema == _prepared_tool_schema()
|
||||
assert tool_def.strict is False
|
||||
assert result == 'found {"count": 1}'
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_dify_plugin_tools_layer_uses_each_tool_plugin_id_for_transport() -> None:
|
||||
async def scenario() -> None:
|
||||
seen_requests: list[tuple[str, str, str, str]] = []
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
if request.url.path.endswith("/dispatch/tool/invoke"):
|
||||
payload = json.loads(request.content.decode("utf-8"))
|
||||
seen_requests.append(
|
||||
(
|
||||
request.headers["X-Plugin-ID"],
|
||||
payload["user_id"],
|
||||
payload["data"]["provider"],
|
||||
payload["data"]["tool"],
|
||||
)
|
||||
)
|
||||
return _invoke_stream_response()
|
||||
|
||||
raise AssertionError(f"Unexpected request path: {request.url.path}")
|
||||
|
||||
tools_config = DifyPluginToolsLayerConfig(
|
||||
tools=[
|
||||
DifyPluginToolConfig(
|
||||
plugin_id="langgenius/tools-a",
|
||||
provider="search-a",
|
||||
tool_name="web_search_a",
|
||||
credential_type="api-key",
|
||||
parameters=[_llm_only_parameter(name="query", description="Search query A")],
|
||||
parameters_json_schema={
|
||||
"type": "object",
|
||||
"properties": {"query": {"type": "string", "description": "Search query A"}},
|
||||
"required": ["query"],
|
||||
},
|
||||
),
|
||||
DifyPluginToolConfig(
|
||||
plugin_id="langgenius/tools-b",
|
||||
provider="search-b",
|
||||
tool_name="web_search_b",
|
||||
credential_type="api-key",
|
||||
parameters=[_llm_only_parameter(name="query", description="Search query B")],
|
||||
parameters_json_schema={
|
||||
"type": "object",
|
||||
"properties": {"query": {"type": "string", "description": "Search query B"}},
|
||||
"required": ["query"],
|
||||
},
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
compositor = Compositor(
|
||||
[
|
||||
LayerNode("execution_context", _execution_context_provider()),
|
||||
LayerNode("tools", DifyPluginToolsLayer, deps={"execution_context": "execution_context"}),
|
||||
]
|
||||
)
|
||||
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
|
||||
async with compositor.enter(
|
||||
configs={"execution_context": _execution_context_config(), "tools": tools_config}
|
||||
) as run:
|
||||
tools = await run.get_layer("tools", DifyPluginToolsLayer).get_tools(http_client=client)
|
||||
|
||||
await tools[0].function_schema.call({"query": "first"}, None) # pyright: ignore[reportArgumentType]
|
||||
await tools[1].function_schema.call({"query": "second"}, None) # pyright: ignore[reportArgumentType]
|
||||
|
||||
assert seen_requests == [
|
||||
("langgenius/tools-a", "user-1", "search-a", "web_search_a"),
|
||||
("langgenius/tools-b", "user-1", "search-b", "web_search_b"),
|
||||
]
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_dify_plugin_tools_layer_casts_prepared_parameter_values_before_invocation() -> None:
|
||||
async def scenario() -> None:
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
if request.url.path.endswith("/dispatch/tool/invoke"):
|
||||
payload = json.loads(request.content.decode("utf-8"))
|
||||
assert payload["user_id"] == "user-1"
|
||||
assert payload["data"]["tool_parameters"] == {
|
||||
"enabled": True,
|
||||
"count": 7,
|
||||
"tags": ["a", "b"],
|
||||
"metadata": {"source": "docs"},
|
||||
"model": {"provider": "openai", "model": "gpt-4o-mini"},
|
||||
}
|
||||
return _invoke_stream_response()
|
||||
|
||||
raise AssertionError(f"Unexpected request path: {request.url.path}")
|
||||
|
||||
tools_config = DifyPluginToolsLayerConfig(
|
||||
tools=[
|
||||
DifyPluginToolConfig(
|
||||
plugin_id="langgenius/tools",
|
||||
provider="search",
|
||||
tool_name="web_search",
|
||||
credential_type="api-key",
|
||||
parameters=[
|
||||
DifyPluginToolParameter(
|
||||
name="enabled",
|
||||
type=DifyPluginToolParameterType.BOOLEAN,
|
||||
form=DifyPluginToolParameterForm.LLM,
|
||||
required=True,
|
||||
llm_description="Enable search",
|
||||
),
|
||||
DifyPluginToolParameter(
|
||||
name="count",
|
||||
type=DifyPluginToolParameterType.NUMBER,
|
||||
form=DifyPluginToolParameterForm.LLM,
|
||||
required=True,
|
||||
llm_description="Result count",
|
||||
),
|
||||
DifyPluginToolParameter(
|
||||
name="tags",
|
||||
type=DifyPluginToolParameterType.ARRAY,
|
||||
form=DifyPluginToolParameterForm.LLM,
|
||||
required=True,
|
||||
llm_description="Tags",
|
||||
input_schema={"type": "array", "items": {"type": "string"}},
|
||||
),
|
||||
DifyPluginToolParameter(
|
||||
name="metadata",
|
||||
type=DifyPluginToolParameterType.OBJECT,
|
||||
form=DifyPluginToolParameterForm.LLM,
|
||||
required=True,
|
||||
llm_description="Metadata",
|
||||
input_schema={"type": "object", "additionalProperties": True},
|
||||
),
|
||||
DifyPluginToolParameter(
|
||||
name="model",
|
||||
type=DifyPluginToolParameterType.MODEL_SELECTOR,
|
||||
form=DifyPluginToolParameterForm.LLM,
|
||||
required=True,
|
||||
llm_description="Model selector",
|
||||
input_schema={"type": "object", "additionalProperties": True},
|
||||
),
|
||||
],
|
||||
parameters_json_schema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"enabled": {"type": "boolean", "description": "Enable search"},
|
||||
"count": {"type": "number", "description": "Result count"},
|
||||
"tags": {"type": "array", "items": {"type": "string"}, "description": "Tags"},
|
||||
"metadata": {
|
||||
"type": "object",
|
||||
"additionalProperties": True,
|
||||
"description": "Metadata",
|
||||
},
|
||||
"model": {
|
||||
"type": "object",
|
||||
"additionalProperties": True,
|
||||
"description": "Model selector",
|
||||
},
|
||||
},
|
||||
"required": ["enabled", "count", "tags", "metadata", "model"],
|
||||
},
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
compositor = Compositor(
|
||||
[
|
||||
LayerNode("execution_context", _execution_context_provider()),
|
||||
LayerNode("tools", DifyPluginToolsLayer, deps={"execution_context": "execution_context"}),
|
||||
]
|
||||
)
|
||||
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
|
||||
async with compositor.enter(
|
||||
configs={"execution_context": _execution_context_config(), "tools": tools_config}
|
||||
) as run:
|
||||
tool = (await run.get_layer("tools", DifyPluginToolsLayer).get_tools(http_client=client))[0]
|
||||
|
||||
result = await tool.function_schema.call(
|
||||
{
|
||||
"enabled": "yes",
|
||||
"count": "7",
|
||||
"tags": '["a", "b"]',
|
||||
"metadata": '{"source": "docs"}',
|
||||
"model": {"provider": "openai", "model": "gpt-4o-mini"},
|
||||
},
|
||||
None, # pyright: ignore[reportArgumentType]
|
||||
)
|
||||
|
||||
assert result == 'found {"count": 1}'
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_dify_plugin_tools_layer_sends_prepared_parameter_defaults_to_daemon() -> None:
|
||||
async def scenario() -> None:
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
if request.url.path.endswith("/dispatch/tool/invoke"):
|
||||
payload = json.loads(request.content.decode("utf-8"))
|
||||
assert payload["data"]["tool_parameters"] == {
|
||||
"query": "dify",
|
||||
"region": "global",
|
||||
}
|
||||
return _invoke_stream_response()
|
||||
|
||||
raise AssertionError(f"Unexpected request path: {request.url.path}")
|
||||
|
||||
tools_config = DifyPluginToolsLayerConfig(
|
||||
tools=[
|
||||
DifyPluginToolConfig(
|
||||
plugin_id="langgenius/tools",
|
||||
provider="search",
|
||||
tool_name="web_search",
|
||||
credential_type="api-key",
|
||||
parameters=[
|
||||
_llm_only_parameter(name="query", description="Search query"),
|
||||
_llm_only_parameter(name="region", description="Search region", default="global"),
|
||||
],
|
||||
parameters_json_schema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {"type": "string", "description": "Search query"},
|
||||
"region": {"type": "string", "description": "Search region"},
|
||||
},
|
||||
"required": ["query"],
|
||||
},
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
compositor = Compositor(
|
||||
[
|
||||
LayerNode("execution_context", _execution_context_provider()),
|
||||
LayerNode("tools", DifyPluginToolsLayer, deps={"execution_context": "execution_context"}),
|
||||
]
|
||||
)
|
||||
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
|
||||
async with compositor.enter(
|
||||
configs={"execution_context": _execution_context_config(), "tools": tools_config}
|
||||
) as run:
|
||||
tool = (await run.get_layer("tools", DifyPluginToolsLayer).get_tools(http_client=client))[0]
|
||||
|
||||
result = await tool.function_schema.call(
|
||||
{"query": "dify"},
|
||||
None, # pyright: ignore[reportArgumentType]
|
||||
)
|
||||
|
||||
assert result == 'found {"count": 1}'
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_dify_plugin_tools_layer_requires_hidden_runtime_parameters_in_prepared_config() -> None:
|
||||
async def scenario() -> None:
|
||||
compositor = Compositor(
|
||||
[
|
||||
LayerNode("execution_context", _execution_context_provider()),
|
||||
LayerNode("tools", DifyPluginToolsLayer, deps={"execution_context": "execution_context"}),
|
||||
]
|
||||
)
|
||||
async with httpx.AsyncClient(transport=_tool_transport()) as client:
|
||||
async with compositor.enter(
|
||||
configs={
|
||||
"execution_context": _execution_context_config(),
|
||||
"tools": _missing_hidden_parameter_tools_config(),
|
||||
}
|
||||
) as run:
|
||||
with pytest.raises(ValueError, match="requires non-LLM runtime_parameters for: auth_scope"):
|
||||
await run.get_layer("tools", DifyPluginToolsLayer).get_tools(http_client=client)
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_dify_plugin_tools_layer_returns_agent_friendly_error_text() -> None:
|
||||
async def scenario() -> None:
|
||||
compositor = Compositor(
|
||||
[
|
||||
LayerNode("execution_context", _execution_context_provider()),
|
||||
LayerNode("tools", DifyPluginToolsLayer, deps={"execution_context": "execution_context"}),
|
||||
]
|
||||
)
|
||||
async with httpx.AsyncClient(
|
||||
transport=_tool_transport(
|
||||
invoke_error_payload={
|
||||
"error_type": "PluginDaemonBadRequestError",
|
||||
"message": "missing query",
|
||||
}
|
||||
)
|
||||
) as client:
|
||||
async with compositor.enter(
|
||||
configs={"execution_context": _execution_context_config(), "tools": _tools_config()}
|
||||
) as run:
|
||||
tool = (await run.get_layer("tools", DifyPluginToolsLayer).get_tools(http_client=client))[0]
|
||||
result = await tool.function_schema.call(
|
||||
{"query": "dify", "region": "global"},
|
||||
None, # pyright: ignore[reportArgumentType]
|
||||
)
|
||||
|
||||
assert result == "tool parameters validation error: missing query, please check your tool parameters"
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_dify_plugin_tools_layer_propagates_unexpected_transport_errors() -> None:
|
||||
async def scenario() -> None:
|
||||
compositor = Compositor(
|
||||
[
|
||||
LayerNode("execution_context", _execution_context_provider()),
|
||||
LayerNode("tools", DifyPluginToolsLayer, deps={"execution_context": "execution_context"}),
|
||||
]
|
||||
)
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
if request.url.path.endswith("/dispatch/tool/invoke"):
|
||||
raise RuntimeError("unexpected transport failure")
|
||||
|
||||
raise AssertionError(f"Unexpected request path: {request.url.path}")
|
||||
|
||||
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
|
||||
async with compositor.enter(
|
||||
configs={"execution_context": _execution_context_config(), "tools": _tools_config()}
|
||||
) as run:
|
||||
tool = (await run.get_layer("tools", DifyPluginToolsLayer).get_tools(http_client=client))[0]
|
||||
|
||||
with pytest.raises(RuntimeError, match="unexpected transport failure"):
|
||||
await tool.function_schema.call(
|
||||
{"query": "dify", "region": "global"},
|
||||
None, # pyright: ignore[reportArgumentType]
|
||||
)
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("invoke_error_payload", "expected_text"),
|
||||
[
|
||||
(
|
||||
{
|
||||
"error_type": "PluginInvokeError",
|
||||
"message": json.dumps(
|
||||
{
|
||||
"error_type": "PluginDaemonUnauthorizedError",
|
||||
"message": "invalid api key",
|
||||
}
|
||||
),
|
||||
},
|
||||
"Please check your tool provider credentials",
|
||||
),
|
||||
(
|
||||
{
|
||||
"error_type": "PluginInvokeError",
|
||||
"message": json.dumps(
|
||||
{
|
||||
"error_type": "ToolNotFoundError",
|
||||
"message": "missing plugin tool",
|
||||
}
|
||||
),
|
||||
},
|
||||
"there is not a tool named web_search",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_dify_plugin_tools_layer_maps_nested_plugin_invoke_errors_to_agent_text(
|
||||
invoke_error_payload: dict[str, object],
|
||||
expected_text: str,
|
||||
) -> None:
|
||||
async def scenario() -> None:
|
||||
compositor = Compositor(
|
||||
[
|
||||
LayerNode("execution_context", _execution_context_provider()),
|
||||
LayerNode("tools", DifyPluginToolsLayer, deps={"execution_context": "execution_context"}),
|
||||
]
|
||||
)
|
||||
async with httpx.AsyncClient(transport=_tool_transport(invoke_error_payload=invoke_error_payload)) as client:
|
||||
async with compositor.enter(
|
||||
configs={"execution_context": _execution_context_config(), "tools": _tools_config()}
|
||||
) as run:
|
||||
tool = (await run.get_layer("tools", DifyPluginToolsLayer).get_tools(http_client=client))[0]
|
||||
result = await tool.function_schema.call(
|
||||
{"query": "dify", "region": "global"},
|
||||
None, # pyright: ignore[reportArgumentType]
|
||||
)
|
||||
|
||||
assert result == expected_text
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_dify_plugin_tools_layer_merges_blob_chunks_before_observation_conversion() -> None:
|
||||
async def scenario() -> None:
|
||||
compositor = Compositor(
|
||||
[
|
||||
LayerNode("execution_context", _execution_context_provider()),
|
||||
LayerNode("tools", DifyPluginToolsLayer, deps={"execution_context": "execution_context"}),
|
||||
]
|
||||
)
|
||||
async with httpx.AsyncClient(transport=_tool_transport(chunked_blob=True)) as client:
|
||||
async with compositor.enter(
|
||||
configs={"execution_context": _execution_context_config(), "tools": _tools_config()}
|
||||
) as run:
|
||||
tool = (await run.get_layer("tools", DifyPluginToolsLayer).get_tools(http_client=client))[0]
|
||||
result = await tool.function_schema.call(
|
||||
{"query": "dify", "region": "global"},
|
||||
None, # pyright: ignore[reportArgumentType]
|
||||
)
|
||||
|
||||
assert "hello world" in result
|
||||
assert "sequence=0" not in result
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
@ -0,0 +1,47 @@
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
import dify_agent.layers.execution_context as execution_context_exports
|
||||
from dify_agent.layers.execution_context import DifyExecutionContextLayerConfig
|
||||
|
||||
|
||||
def test_execution_context_package_exports_client_safe_config_symbols_only() -> None:
|
||||
assert execution_context_exports.__all__ == [
|
||||
"DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID",
|
||||
"DifyExecutionContextInvokeFrom",
|
||||
"DifyExecutionContextLayerConfig",
|
||||
]
|
||||
assert execution_context_exports.DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID == "dify.execution_context"
|
||||
assert not hasattr(execution_context_exports, "DifyExecutionContextLayer")
|
||||
|
||||
|
||||
def test_execution_context_layer_config_forbids_runtime_settings_and_unknown_fields() -> None:
|
||||
config = DifyExecutionContextLayerConfig(
|
||||
tenant_id="tenant-1",
|
||||
user_id="user-1",
|
||||
workflow_id="workflow-1",
|
||||
invoke_from="workflow_run",
|
||||
)
|
||||
|
||||
assert config.tenant_id == "tenant-1"
|
||||
assert config.user_id == "user-1"
|
||||
assert config.workflow_id == "workflow-1"
|
||||
assert config.invoke_from == "workflow_run"
|
||||
|
||||
with pytest.raises(ValidationError):
|
||||
_ = DifyExecutionContextLayerConfig.model_validate(
|
||||
{
|
||||
"tenant_id": "tenant-1",
|
||||
"invoke_from": "workflow_run",
|
||||
"daemon_url": "http://daemon",
|
||||
}
|
||||
)
|
||||
|
||||
with pytest.raises(ValidationError):
|
||||
_ = DifyExecutionContextLayerConfig.model_validate(
|
||||
{
|
||||
"tenant_id": "tenant-1",
|
||||
"invoke_from": "workflow_run",
|
||||
"unknown": "value",
|
||||
}
|
||||
)
|
||||
@ -0,0 +1,107 @@
|
||||
import asyncio
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from dify_agent.layers.execution_context import DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID, DifyExecutionContextLayerConfig
|
||||
from dify_agent.layers.execution_context.layer import DifyExecutionContextLayer
|
||||
|
||||
|
||||
def _execution_context_layer() -> DifyExecutionContextLayer:
|
||||
return DifyExecutionContextLayer.from_config_with_settings(
|
||||
DifyExecutionContextLayerConfig(tenant_id="tenant-1", user_id="user-1", invoke_from="workflow_run"),
|
||||
daemon_url="http://plugin-daemon",
|
||||
daemon_api_key="daemon-secret",
|
||||
)
|
||||
|
||||
|
||||
def test_execution_context_type_id_constant_matches_implementation_class() -> None:
|
||||
assert DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID == DifyExecutionContextLayer.type_id
|
||||
|
||||
|
||||
def test_execution_context_layer_creates_daemon_provider_from_shared_http_client() -> None:
|
||||
async def scenario() -> None:
|
||||
execution_context = _execution_context_layer()
|
||||
async with httpx.AsyncClient(transport=httpx.MockTransport(lambda _request: httpx.Response(200))) as client:
|
||||
provider = execution_context.create_daemon_provider(plugin_id="langgenius/openai", http_client=client)
|
||||
|
||||
assert provider.name == "DifyPlugin/langgenius/openai"
|
||||
assert provider.client.http_client is client
|
||||
assert provider.client.tenant_id == "tenant-1"
|
||||
assert provider.client.plugin_id == "langgenius/openai"
|
||||
assert provider.client.user_id == "user-1"
|
||||
|
||||
async with provider:
|
||||
pass
|
||||
assert client.is_closed is False
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_execution_context_layer_creates_tool_client_from_shared_http_client() -> None:
|
||||
async def scenario() -> None:
|
||||
execution_context = _execution_context_layer()
|
||||
async with httpx.AsyncClient(transport=httpx.MockTransport(lambda _request: httpx.Response(200))) as client:
|
||||
tool_client = execution_context.create_tool_client(plugin_id="langgenius/tools", http_client=client)
|
||||
|
||||
assert tool_client.http_client is client
|
||||
assert tool_client.tenant_id == "tenant-1"
|
||||
assert tool_client.user_id == "user-1"
|
||||
assert tool_client.plugin_id == "langgenius/tools"
|
||||
assert tool_client.plugin_daemon_url == "http://plugin-daemon"
|
||||
assert tool_client.plugin_daemon_api_key == "daemon-secret"
|
||||
assert client.is_closed is False
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_execution_context_layer_rejects_closed_shared_http_client() -> None:
|
||||
async def scenario() -> None:
|
||||
execution_context = _execution_context_layer()
|
||||
client = httpx.AsyncClient()
|
||||
await client.aclose()
|
||||
|
||||
with pytest.raises(RuntimeError, match="open shared HTTP client"):
|
||||
_ = execution_context.create_daemon_provider(plugin_id="langgenius/openai", http_client=client)
|
||||
with pytest.raises(RuntimeError, match="open shared HTTP client"):
|
||||
_ = execution_context.create_tool_client(plugin_id="langgenius/tools", http_client=client)
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_execution_context_layer_lifecycle_does_not_manage_http_client() -> None:
|
||||
from agenton.compositor import Compositor, LayerNode, LayerProvider
|
||||
|
||||
provider = LayerProvider.from_factory(
|
||||
layer_type=DifyExecutionContextLayer,
|
||||
create=lambda config: DifyExecutionContextLayer.from_config_with_settings(
|
||||
DifyExecutionContextLayerConfig.model_validate(config),
|
||||
daemon_url="http://plugin-daemon",
|
||||
daemon_api_key="daemon-secret",
|
||||
),
|
||||
)
|
||||
|
||||
async def scenario() -> None:
|
||||
compositor = Compositor([LayerNode("execution_context", provider)])
|
||||
async with httpx.AsyncClient(transport=httpx.MockTransport(lambda _request: httpx.Response(200))) as client:
|
||||
async with compositor.enter(
|
||||
configs={
|
||||
"execution_context": DifyExecutionContextLayerConfig(
|
||||
tenant_id="tenant-1",
|
||||
user_id="user-1",
|
||||
invoke_from="workflow_run",
|
||||
)
|
||||
}
|
||||
) as run:
|
||||
execution_context = run.get_layer("execution_context", DifyExecutionContextLayer)
|
||||
daemon_provider = execution_context.create_daemon_provider(
|
||||
plugin_id="langgenius/openai",
|
||||
http_client=client,
|
||||
)
|
||||
run.suspend_layer_on_exit("execution_context")
|
||||
|
||||
assert run.session_snapshot is not None
|
||||
assert daemon_provider.client.http_client is client
|
||||
assert client.is_closed is False
|
||||
|
||||
asyncio.run(scenario())
|
||||
@ -6,13 +6,13 @@ from agenton.compositor import CompositorSessionSnapshot
|
||||
from agenton.layers import ExitIntent
|
||||
from agenton_collections.layers.plain import PLAIN_PROMPT_LAYER_TYPE_ID, PromptLayerConfig
|
||||
import dify_agent.protocol as protocol_exports
|
||||
from dify_agent.layers.dify_plugin import DIFY_PLUGIN_LAYER_TYPE_ID, DIFY_PLUGIN_LLM_LAYER_TYPE_ID
|
||||
from dify_agent.layers.execution_context import DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID, DifyExecutionContextLayerConfig
|
||||
from dify_agent.layers.dify_plugin import DIFY_PLUGIN_LLM_LAYER_TYPE_ID, DIFY_PLUGIN_TOOLS_LAYER_TYPE_ID
|
||||
from dify_agent.layers.output import DIFY_OUTPUT_LAYER_TYPE_ID, DifyOutputLayerConfig
|
||||
from dify_agent.protocol import DIFY_AGENT_HISTORY_LAYER_ID, DIFY_AGENT_MODEL_LAYER_ID, DIFY_AGENT_OUTPUT_LAYER_ID
|
||||
from dify_agent.protocol.schemas import (
|
||||
RUN_EVENT_ADAPTER,
|
||||
CreateRunRequest,
|
||||
ExecutionContext,
|
||||
LayerExitSignals,
|
||||
PydanticAIStreamRunEvent,
|
||||
RunCancelledEvent,
|
||||
@ -28,7 +28,14 @@ from dify_agent.protocol.schemas import (
|
||||
RunSucceededEventData,
|
||||
normalize_composition,
|
||||
)
|
||||
from dify_agent.layers.dify_plugin.configs import DifyPluginLLMLayerConfig, DifyPluginLayerConfig
|
||||
from dify_agent.layers.dify_plugin.configs import (
|
||||
DifyPluginLLMLayerConfig,
|
||||
DifyPluginToolConfig,
|
||||
DifyPluginToolParameter,
|
||||
DifyPluginToolParameterForm,
|
||||
DifyPluginToolParameterType,
|
||||
DifyPluginToolsLayerConfig,
|
||||
)
|
||||
|
||||
|
||||
def test_run_event_adapter_round_trips_typed_variants() -> None:
|
||||
@ -87,10 +94,23 @@ def test_create_run_request_rejects_old_compositor_payload_and_model_layer_id_is
|
||||
)
|
||||
|
||||
|
||||
def test_protocol_package_no_longer_exports_execution_context_dto() -> None:
|
||||
assert not hasattr(protocol_exports, "ExecutionContext")
|
||||
|
||||
|
||||
def test_create_run_request_accepts_dto_first_public_composition_and_normalizes_graph_config() -> None:
|
||||
prompt_config = PromptLayerConfig(prefix="system", user="hello")
|
||||
plugin_config = DifyPluginLayerConfig(tenant_id="tenant-1", plugin_id="langgenius/openai")
|
||||
execution_context_config = DifyExecutionContextLayerConfig(
|
||||
tenant_id="tenant-1",
|
||||
workflow_id="workflow-1",
|
||||
workflow_run_id="workflow-run-1",
|
||||
node_id="node-1",
|
||||
node_execution_id="node-execution-1",
|
||||
invoke_from="workflow_run",
|
||||
trace_id="trace-1",
|
||||
)
|
||||
llm_config = DifyPluginLLMLayerConfig(
|
||||
plugin_id="langgenius/openai",
|
||||
model_provider="openai",
|
||||
model="demo-model",
|
||||
credentials={"api_key": "secret"},
|
||||
@ -104,26 +124,21 @@ def test_create_run_request_accepts_dto_first_public_composition_and_normalizes_
|
||||
}
|
||||
)
|
||||
request = CreateRunRequest(
|
||||
execution_context=ExecutionContext(
|
||||
tenant_id="tenant-1",
|
||||
workflow_id="workflow-1",
|
||||
workflow_run_id="workflow-run-1",
|
||||
node_id="node-1",
|
||||
node_execution_id="node-execution-1",
|
||||
invoke_from="workflow_run",
|
||||
trace_id="trace-1",
|
||||
),
|
||||
purpose="workflow_node",
|
||||
idempotency_key="workflow-run-1:node-execution-1",
|
||||
metadata={"source": "unit_test"},
|
||||
composition=RunComposition(
|
||||
layers=[
|
||||
RunLayerSpec(name="prompt", type=PLAIN_PROMPT_LAYER_TYPE_ID, config=prompt_config),
|
||||
RunLayerSpec(name="plugin", type=DIFY_PLUGIN_LAYER_TYPE_ID, config=plugin_config),
|
||||
RunLayerSpec(
|
||||
name="execution_context",
|
||||
type=DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID,
|
||||
config=execution_context_config,
|
||||
),
|
||||
RunLayerSpec(
|
||||
name=DIFY_AGENT_MODEL_LAYER_ID,
|
||||
type=DIFY_PLUGIN_LLM_LAYER_TYPE_ID,
|
||||
deps={"plugin": "plugin"},
|
||||
deps={"execution_context": "execution_context"},
|
||||
config=llm_config,
|
||||
),
|
||||
RunLayerSpec(
|
||||
@ -138,8 +153,9 @@ def test_create_run_request_accepts_dto_first_public_composition_and_normalizes_
|
||||
graph_config, layer_configs = normalize_composition(request.composition)
|
||||
payload = request.model_dump(mode="json")
|
||||
|
||||
assert payload["execution_context"] == {
|
||||
assert payload["composition"]["layers"][1]["config"] == {
|
||||
"tenant_id": "tenant-1",
|
||||
"user_id": None,
|
||||
"app_id": None,
|
||||
"workflow_id": "workflow-1",
|
||||
"workflow_run_id": "workflow-run-1",
|
||||
@ -157,11 +173,16 @@ def test_create_run_request_accepts_dto_first_public_composition_and_normalizes_
|
||||
assert payload["composition"]["layers"][0]["config"] == {"prefix": "system", "user": "hello", "suffix": []}
|
||||
assert [layer.model_dump(mode="json") for layer in graph_config.layers] == [
|
||||
{"name": "prompt", "type": PLAIN_PROMPT_LAYER_TYPE_ID, "deps": {}, "metadata": {}},
|
||||
{"name": "plugin", "type": DIFY_PLUGIN_LAYER_TYPE_ID, "deps": {}, "metadata": {}},
|
||||
{
|
||||
"name": "execution_context",
|
||||
"type": DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID,
|
||||
"deps": {},
|
||||
"metadata": {},
|
||||
},
|
||||
{
|
||||
"name": DIFY_AGENT_MODEL_LAYER_ID,
|
||||
"type": DIFY_PLUGIN_LLM_LAYER_TYPE_ID,
|
||||
"deps": {"plugin": "plugin"},
|
||||
"deps": {"execution_context": "execution_context"},
|
||||
"metadata": {},
|
||||
},
|
||||
{
|
||||
@ -173,12 +194,118 @@ def test_create_run_request_accepts_dto_first_public_composition_and_normalizes_
|
||||
]
|
||||
assert layer_configs == {
|
||||
"prompt": prompt_config,
|
||||
"plugin": plugin_config,
|
||||
"execution_context": execution_context_config,
|
||||
DIFY_AGENT_MODEL_LAYER_ID: llm_config,
|
||||
DIFY_AGENT_OUTPUT_LAYER_ID: output_config,
|
||||
}
|
||||
|
||||
|
||||
def test_create_run_request_accepts_plugin_tools_layer_with_prepared_parameters_and_schema() -> None:
|
||||
request = CreateRunRequest.model_validate(
|
||||
{
|
||||
"composition": {
|
||||
"layers": [
|
||||
{"name": "prompt", "type": PLAIN_PROMPT_LAYER_TYPE_ID, "config": {"user": "hello"}},
|
||||
{
|
||||
"name": "execution_context",
|
||||
"type": DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID,
|
||||
"config": {"tenant_id": "tenant-1", "invoke_from": "workflow_run"},
|
||||
},
|
||||
{
|
||||
"name": DIFY_AGENT_MODEL_LAYER_ID,
|
||||
"type": DIFY_PLUGIN_LLM_LAYER_TYPE_ID,
|
||||
"deps": {"execution_context": "execution_context"},
|
||||
"config": {
|
||||
"plugin_id": "langgenius/openai",
|
||||
"model_provider": "openai",
|
||||
"model": "demo-model",
|
||||
"credentials": {"api_key": "secret"},
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "tools",
|
||||
"type": DIFY_PLUGIN_TOOLS_LAYER_TYPE_ID,
|
||||
"deps": {"execution_context": "execution_context"},
|
||||
"config": {
|
||||
"tools": [
|
||||
{
|
||||
"plugin_id": "langgenius/search",
|
||||
"provider": "search",
|
||||
"tool_name": "web_search",
|
||||
"credential_type": "api-key",
|
||||
"runtime_parameters": {"site": "docs.dify.ai"},
|
||||
"parameters": [
|
||||
{
|
||||
"name": "query",
|
||||
"type": "string",
|
||||
"form": "llm",
|
||||
"required": True,
|
||||
"llm_description": "Search query",
|
||||
},
|
||||
{
|
||||
"name": "site",
|
||||
"type": "string",
|
||||
"form": "form",
|
||||
"required": True,
|
||||
"llm_description": "Hidden site",
|
||||
},
|
||||
],
|
||||
"parameters_json_schema": {
|
||||
"type": "object",
|
||||
"properties": {"query": {"type": "string", "description": "Search query"}},
|
||||
"required": ["query"],
|
||||
},
|
||||
}
|
||||
]
|
||||
},
|
||||
},
|
||||
]
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
graph_config, layer_configs = normalize_composition(request.composition)
|
||||
|
||||
assert [layer.type for layer in graph_config.layers] == [
|
||||
PLAIN_PROMPT_LAYER_TYPE_ID,
|
||||
DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID,
|
||||
DIFY_PLUGIN_LLM_LAYER_TYPE_ID,
|
||||
DIFY_PLUGIN_TOOLS_LAYER_TYPE_ID,
|
||||
]
|
||||
assert DifyPluginToolsLayerConfig.model_validate(layer_configs["tools"]) == DifyPluginToolsLayerConfig(
|
||||
tools=[
|
||||
DifyPluginToolConfig(
|
||||
plugin_id="langgenius/search",
|
||||
provider="search",
|
||||
tool_name="web_search",
|
||||
credential_type="api-key",
|
||||
runtime_parameters={"site": "docs.dify.ai"},
|
||||
parameters=[
|
||||
DifyPluginToolParameter(
|
||||
name="query",
|
||||
type=DifyPluginToolParameterType.STRING,
|
||||
form=DifyPluginToolParameterForm.LLM,
|
||||
required=True,
|
||||
llm_description="Search query",
|
||||
),
|
||||
DifyPluginToolParameter(
|
||||
name="site",
|
||||
type=DifyPluginToolParameterType.STRING,
|
||||
form=DifyPluginToolParameterForm.FORM,
|
||||
required=True,
|
||||
llm_description="Hidden site",
|
||||
),
|
||||
],
|
||||
parameters_json_schema={
|
||||
"type": "object",
|
||||
"properties": {"query": {"type": "string", "description": "Search query"}},
|
||||
"required": ["query"],
|
||||
},
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def test_on_exit_default_to_suspend_and_are_public() -> None:
|
||||
assert protocol_exports.LayerExitSignals is LayerExitSignals
|
||||
assert protocol_exports.RunComposition is RunComposition
|
||||
@ -206,13 +333,12 @@ def test_on_exit_accept_layer_overrides() -> None:
|
||||
assert request.on_exit.layers == {"prompt": ExitIntent.SUSPEND, "llm": ExitIntent.DELETE}
|
||||
|
||||
|
||||
def test_execution_context_rejects_unknown_fields() -> None:
|
||||
def test_create_run_request_rejects_removed_top_level_execution_context() -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
_ = ExecutionContext.model_validate(
|
||||
_ = CreateRunRequest.model_validate(
|
||||
{
|
||||
"tenant_id": "tenant-1",
|
||||
"invoke_from": "workflow_run",
|
||||
"unknown": "value",
|
||||
"composition": {"layers": []},
|
||||
"execution_context": {"tenant_id": "tenant-1", "invoke_from": "workflow_run"},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@ -6,25 +6,18 @@ import httpx
|
||||
import pytest
|
||||
|
||||
from agenton.compositor import CompositorSessionSnapshot, LayerSessionSnapshot
|
||||
from agenton.layers import ExitIntent, LifecycleState
|
||||
from agenton_collections.layers.pydantic_ai import PYDANTIC_AI_HISTORY_LAYER_TYPE_ID
|
||||
from agenton.layers import LifecycleState
|
||||
from agenton_collections.layers.plain import PromptLayerConfig
|
||||
from dify_agent.layers.output import DIFY_OUTPUT_LAYER_TYPE_ID, DifyOutputLayerConfig
|
||||
from dify_agent.protocol import DIFY_AGENT_HISTORY_LAYER_ID, DIFY_AGENT_OUTPUT_LAYER_ID
|
||||
from dify_agent.protocol import DIFY_AGENT_OUTPUT_LAYER_ID
|
||||
from dify_agent.protocol.schemas import (
|
||||
CreateRunRequest,
|
||||
LayerExitSignals,
|
||||
RunComposition,
|
||||
RunEvent,
|
||||
RunLayerSpec,
|
||||
RunStatus,
|
||||
)
|
||||
from dify_agent.runtime.run_scheduler import (
|
||||
RunRequestValidationError,
|
||||
RunScheduler,
|
||||
SchedulerStoppingError,
|
||||
validate_run_request,
|
||||
)
|
||||
from dify_agent.runtime.run_scheduler import RunScheduler, SchedulerStoppingError
|
||||
from dify_agent.server.schemas import RunRecord
|
||||
|
||||
|
||||
@ -168,390 +161,64 @@ def test_shutdown_marks_unfinished_runs_failed_and_appends_event() -> None:
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_create_run_rejects_blank_prompt_before_persisting() -> None:
|
||||
def test_create_run_accepts_blank_prompt_and_runner_fails_asynchronously() -> None:
|
||||
async def scenario() -> None:
|
||||
store = FakeStore()
|
||||
async with httpx.AsyncClient() as client:
|
||||
scheduler = RunScheduler(store=store, plugin_daemon_http_client=client)
|
||||
|
||||
with pytest.raises(ValueError, match="run.user_prompts must not be empty"):
|
||||
await scheduler.create_run(_request(["", " "]))
|
||||
record = await scheduler.create_run(_request(["", " "]))
|
||||
await asyncio.wait_for(scheduler.active_tasks[record.run_id], timeout=1)
|
||||
|
||||
assert store.records == {}
|
||||
assert store.records == {record.run_id: record}
|
||||
assert [event.type for event in store.events[record.run_id]] == ["run_started", "run_failed"]
|
||||
assert store.statuses[record.run_id] == "failed"
|
||||
assert store.errors[record.run_id] == "run.user_prompts must not be empty"
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_create_run_rejects_invalid_output_schema_before_persisting() -> None:
|
||||
def test_create_run_accepts_invalid_output_schema_and_runner_fails_asynchronously() -> None:
|
||||
async def scenario() -> None:
|
||||
store = FakeStore()
|
||||
async with httpx.AsyncClient() as client:
|
||||
scheduler = RunScheduler(store=store, plugin_daemon_http_client=client)
|
||||
|
||||
with pytest.raises(ValueError, match=r"Recursive \$defs refs are not supported"):
|
||||
await scheduler.create_run(
|
||||
_request(
|
||||
output_config={
|
||||
"json_schema": _recursive_output_schema(),
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
assert store.records == {}
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_create_run_rejects_remote_ref_output_schema_before_persisting() -> None:
|
||||
async def scenario() -> None:
|
||||
store = FakeStore()
|
||||
async with httpx.AsyncClient() as client:
|
||||
scheduler = RunScheduler(store=store, plugin_daemon_http_client=client)
|
||||
|
||||
with pytest.raises(ValueError, match=r"Remote \$ref values are not supported"):
|
||||
await scheduler.create_run(
|
||||
_request(
|
||||
output_config={
|
||||
"json_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"title": {"$ref": "https://example.com/schema.json"},
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
assert store.records == {}
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_create_run_rejects_non_object_output_schema_before_persisting() -> None:
|
||||
async def scenario() -> None:
|
||||
store = FakeStore()
|
||||
async with httpx.AsyncClient() as client:
|
||||
scheduler = RunScheduler(store=store, plugin_daemon_http_client=client)
|
||||
|
||||
with pytest.raises(ValueError, match="Schema must declare an object output"):
|
||||
await scheduler.create_run(
|
||||
_request(
|
||||
output_config={
|
||||
"json_schema": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
},
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
assert store.records == {}
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_create_run_rejects_public_output_tool_name_override_before_persisting() -> None:
|
||||
async def scenario() -> None:
|
||||
store = FakeStore()
|
||||
async with httpx.AsyncClient() as client:
|
||||
scheduler = RunScheduler(store=store, plugin_daemon_http_client=client)
|
||||
|
||||
with pytest.raises(ValueError, match="Extra inputs are not permitted"):
|
||||
await scheduler.create_run(
|
||||
_request(
|
||||
output_config={
|
||||
"name": "incident_summary",
|
||||
"json_schema": {
|
||||
"type": "object",
|
||||
"properties": {"title": {"type": "string"}},
|
||||
"required": ["title"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
assert store.records == {}
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_create_run_rejects_non_defs_local_ref_in_direct_object_schema_before_persisting() -> None:
|
||||
async def scenario() -> None:
|
||||
store = FakeStore()
|
||||
async with httpx.AsyncClient() as client:
|
||||
scheduler = RunScheduler(store=store, plugin_daemon_http_client=client)
|
||||
|
||||
with pytest.raises(ValueError, match=r"Only local refs under '#/\$defs/' are supported"):
|
||||
await scheduler.create_run(
|
||||
_request(
|
||||
output_config={
|
||||
"json_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"items": {"$ref": "#/definitions/itemArray"},
|
||||
},
|
||||
"required": ["items"],
|
||||
"definitions": {
|
||||
"itemArray": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
assert store.records == {}
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_create_run_rejects_misnamed_output_layer_before_persisting() -> None:
|
||||
async def scenario() -> None:
|
||||
store = FakeStore()
|
||||
async with httpx.AsyncClient() as client:
|
||||
scheduler = RunScheduler(store=store, plugin_daemon_http_client=client)
|
||||
|
||||
request = CreateRunRequest(
|
||||
composition=RunComposition(
|
||||
layers=[
|
||||
RunLayerSpec(name="prompt", type="plain.prompt", config=PromptLayerConfig(user="hello")),
|
||||
RunLayerSpec(
|
||||
name="structured-output",
|
||||
type=DIFY_OUTPUT_LAYER_TYPE_ID,
|
||||
config=DifyOutputLayerConfig(
|
||||
json_schema={
|
||||
"type": "object",
|
||||
"properties": {"title": {"type": "string"}},
|
||||
"required": ["title"],
|
||||
"additionalProperties": False,
|
||||
}
|
||||
),
|
||||
),
|
||||
]
|
||||
record = await scheduler.create_run(
|
||||
_request(
|
||||
output_config={
|
||||
"json_schema": _recursive_output_schema(),
|
||||
}
|
||||
)
|
||||
)
|
||||
await asyncio.wait_for(scheduler.active_tasks[record.run_id], timeout=1)
|
||||
|
||||
with pytest.raises(ValueError, match="must use reserved layer name 'output'"):
|
||||
await scheduler.create_run(request)
|
||||
|
||||
assert store.records == {}
|
||||
assert store.records == {record.run_id: record}
|
||||
assert [event.type for event in store.events[record.run_id]] == ["run_started", "run_failed"]
|
||||
assert store.statuses[record.run_id] == "failed"
|
||||
assert "Recursive $defs refs are not supported" in (store.errors[record.run_id] or "")
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_create_run_rejects_multiple_output_layers_before_persisting() -> None:
|
||||
async def scenario() -> None:
|
||||
store = FakeStore()
|
||||
async with httpx.AsyncClient() as client:
|
||||
scheduler = RunScheduler(store=store, plugin_daemon_http_client=client)
|
||||
|
||||
request = CreateRunRequest(
|
||||
composition=RunComposition(
|
||||
layers=[
|
||||
RunLayerSpec(name="prompt", type="plain.prompt", config=PromptLayerConfig(user="hello")),
|
||||
RunLayerSpec(
|
||||
name=DIFY_AGENT_OUTPUT_LAYER_ID,
|
||||
type=DIFY_OUTPUT_LAYER_TYPE_ID,
|
||||
config=DifyOutputLayerConfig(
|
||||
json_schema={
|
||||
"type": "object",
|
||||
"properties": {"title": {"type": "string"}},
|
||||
"required": ["title"],
|
||||
"additionalProperties": False,
|
||||
}
|
||||
),
|
||||
),
|
||||
RunLayerSpec(
|
||||
name="secondary-output",
|
||||
type=DIFY_OUTPUT_LAYER_TYPE_ID,
|
||||
config=DifyOutputLayerConfig(
|
||||
json_schema={
|
||||
"type": "object",
|
||||
"properties": {"summary": {"type": "string"}},
|
||||
"required": ["summary"],
|
||||
"additionalProperties": False,
|
||||
}
|
||||
),
|
||||
),
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="Only one 'dify.output' layer is supported"):
|
||||
await scheduler.create_run(request)
|
||||
|
||||
assert store.records == {}
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_create_run_rejects_reserved_output_name_with_wrong_layer_type_before_persisting() -> None:
|
||||
async def scenario() -> None:
|
||||
store = FakeStore()
|
||||
async with httpx.AsyncClient() as client:
|
||||
scheduler = RunScheduler(store=store, plugin_daemon_http_client=client)
|
||||
|
||||
request = CreateRunRequest(
|
||||
composition=RunComposition(
|
||||
layers=[
|
||||
RunLayerSpec(name="prompt", type="plain.prompt", config=PromptLayerConfig(user="hello")),
|
||||
RunLayerSpec(
|
||||
name=DIFY_AGENT_OUTPUT_LAYER_ID, type="plain.prompt", config=PromptLayerConfig(user="hi")
|
||||
),
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match=r"Layer 'output' must be DifyOutputLayer, got PromptLayer"):
|
||||
await scheduler.create_run(request)
|
||||
|
||||
assert store.records == {}
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_validate_run_request_honors_explicit_empty_layer_providers() -> None:
|
||||
async def scenario() -> None:
|
||||
with pytest.raises(RunRequestValidationError, match="plain.prompt"):
|
||||
await validate_run_request(_request(), layer_providers=())
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_validate_run_request_rejects_misnamed_output_layer_before_provider_checks() -> None:
|
||||
async def scenario() -> None:
|
||||
request = CreateRunRequest(
|
||||
composition=RunComposition(
|
||||
layers=[
|
||||
RunLayerSpec(name="prompt", type="plain.prompt", config=PromptLayerConfig(user="hello")),
|
||||
RunLayerSpec(
|
||||
name="structured-output",
|
||||
type=DIFY_OUTPUT_LAYER_TYPE_ID,
|
||||
config=DifyOutputLayerConfig(
|
||||
json_schema={
|
||||
"type": "object",
|
||||
"properties": {"title": {"type": "string"}},
|
||||
"required": ["title"],
|
||||
"additionalProperties": False,
|
||||
}
|
||||
),
|
||||
),
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
with pytest.raises(RunRequestValidationError, match="must use reserved layer name 'output'"):
|
||||
await validate_run_request(request, layer_providers=())
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_validate_run_request_accepts_reserved_history_layer() -> None:
|
||||
async def scenario() -> None:
|
||||
request = CreateRunRequest(
|
||||
composition=RunComposition(
|
||||
layers=[
|
||||
RunLayerSpec(name="prompt", type="plain.prompt", config=PromptLayerConfig(user="hello")),
|
||||
RunLayerSpec(name=DIFY_AGENT_HISTORY_LAYER_ID, type=PYDANTIC_AI_HISTORY_LAYER_TYPE_ID),
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
await validate_run_request(request)
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_validate_run_request_rejects_misnamed_history_layer_before_provider_checks() -> None:
|
||||
async def scenario() -> None:
|
||||
request = CreateRunRequest(
|
||||
composition=RunComposition(
|
||||
layers=[
|
||||
RunLayerSpec(name="prompt", type="plain.prompt", config=PromptLayerConfig(user="hello")),
|
||||
RunLayerSpec(name="chat-history", type=PYDANTIC_AI_HISTORY_LAYER_TYPE_ID),
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
with pytest.raises(RunRequestValidationError, match="must use reserved layer name 'history'"):
|
||||
await validate_run_request(request, layer_providers=())
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_validate_run_request_rejects_multiple_history_layers_before_provider_checks() -> None:
|
||||
async def scenario() -> None:
|
||||
request = CreateRunRequest(
|
||||
composition=RunComposition(
|
||||
layers=[
|
||||
RunLayerSpec(name="prompt", type="plain.prompt", config=PromptLayerConfig(user="hello")),
|
||||
RunLayerSpec(name=DIFY_AGENT_HISTORY_LAYER_ID, type=PYDANTIC_AI_HISTORY_LAYER_TYPE_ID),
|
||||
RunLayerSpec(name="secondary-history", type=PYDANTIC_AI_HISTORY_LAYER_TYPE_ID),
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
with pytest.raises(RunRequestValidationError, match="Only one 'pydantic_ai.history' layer is supported"):
|
||||
await validate_run_request(request, layer_providers=())
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_validate_run_request_rejects_history_layer_dependencies_before_provider_checks() -> None:
|
||||
async def scenario() -> None:
|
||||
request = CreateRunRequest(
|
||||
composition=RunComposition(
|
||||
layers=[
|
||||
RunLayerSpec(name="prompt", type="plain.prompt", config=PromptLayerConfig(user="hello")),
|
||||
RunLayerSpec(
|
||||
name=DIFY_AGENT_HISTORY_LAYER_ID,
|
||||
type=PYDANTIC_AI_HISTORY_LAYER_TYPE_ID,
|
||||
deps={"prompt": "prompt"},
|
||||
),
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
with pytest.raises(RunRequestValidationError, match="does not support dependencies"):
|
||||
await validate_run_request(request, layer_providers=())
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_create_run_rejects_unknown_layer_exit_signal_before_persisting() -> None:
|
||||
async def scenario() -> None:
|
||||
store = FakeStore()
|
||||
async with httpx.AsyncClient() as client:
|
||||
scheduler = RunScheduler(store=store, plugin_daemon_http_client=client)
|
||||
request = _request()
|
||||
request.on_exit = LayerExitSignals(layers={"missing": ExitIntent.DELETE})
|
||||
|
||||
with pytest.raises(ValueError, match="missing"):
|
||||
await scheduler.create_run(request)
|
||||
|
||||
assert store.records == {}
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_create_run_honors_explicit_empty_layer_providers_before_persisting() -> None:
|
||||
def test_create_run_honors_explicit_empty_layer_providers_by_failing_after_persisting() -> None:
|
||||
async def scenario() -> None:
|
||||
store = FakeStore()
|
||||
async with httpx.AsyncClient() as client:
|
||||
scheduler = RunScheduler(store=store, plugin_daemon_http_client=client, layer_providers=())
|
||||
|
||||
with pytest.raises(RunRequestValidationError, match="plain.prompt"):
|
||||
await scheduler.create_run(_request())
|
||||
record = await scheduler.create_run(_request())
|
||||
await asyncio.wait_for(scheduler.active_tasks[record.run_id], timeout=1)
|
||||
|
||||
assert store.records == {}
|
||||
assert store.records == {record.run_id: record}
|
||||
assert [event.type for event in store.events[record.run_id]] == ["run_started", "run_failed"]
|
||||
assert store.statuses[record.run_id] == "failed"
|
||||
assert "plain.prompt" in (store.errors[record.run_id] or "")
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_create_run_rejects_closed_session_snapshot_before_persisting() -> None:
|
||||
def test_create_run_accepts_closed_session_snapshot_and_runner_fails_asynchronously() -> None:
|
||||
async def scenario() -> None:
|
||||
store = FakeStore()
|
||||
async with httpx.AsyncClient() as client:
|
||||
@ -567,10 +234,13 @@ def test_create_run_rejects_closed_session_snapshot_before_persisting() -> None:
|
||||
]
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="CLOSED snapshots cannot be entered"):
|
||||
_ = await scheduler.create_run(request)
|
||||
record = await scheduler.create_run(request)
|
||||
await asyncio.wait_for(scheduler.active_tasks[record.run_id], timeout=1)
|
||||
|
||||
assert store.records == {}
|
||||
assert store.records == {record.run_id: record}
|
||||
assert [event.type for event in store.events[record.run_id]] == ["run_started", "run_failed"]
|
||||
assert store.statuses[record.run_id] == "failed"
|
||||
assert "CLOSED snapshots cannot be entered" in (store.errors[record.run_id] or "")
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
@ -1,9 +1,11 @@
|
||||
import asyncio
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
from typing import Any, ClassVar, cast
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from pydantic import JsonValue
|
||||
from pydantic_ai import Tool
|
||||
from pydantic_ai.exceptions import UnexpectedModelBehavior
|
||||
from pydantic_ai.messages import (
|
||||
ModelMessage,
|
||||
@ -18,12 +20,22 @@ from pydantic_ai.models import ModelRequestParameters
|
||||
from pydantic_ai.models.test import TestModel
|
||||
from pydantic_ai.settings import ModelSettings
|
||||
|
||||
from agenton.compositor import CompositorSessionSnapshot, LayerSessionSnapshot
|
||||
from agenton.compositor import CompositorSessionSnapshot, LayerProvider, LayerSessionSnapshot
|
||||
from agenton.layers import ExitIntent, LifecycleState
|
||||
from agenton_collections.layers.pydantic_ai import PYDANTIC_AI_HISTORY_LAYER_TYPE_ID, PydanticAIHistoryRuntimeState
|
||||
from agenton_collections.layers.plain import PromptLayerConfig
|
||||
from dify_agent.layers.dify_plugin.configs import DifyPluginLLMLayerConfig, DifyPluginLayerConfig
|
||||
from agenton_collections.layers.plain import PromptLayerConfig, ToolsLayer
|
||||
from dify_agent.layers.execution_context import DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID, DifyExecutionContextLayerConfig
|
||||
from dify_agent.layers.dify_plugin.configs import (
|
||||
DIFY_PLUGIN_TOOLS_LAYER_TYPE_ID,
|
||||
DifyPluginLLMLayerConfig,
|
||||
DifyPluginToolConfig,
|
||||
DifyPluginToolParameter,
|
||||
DifyPluginToolParameterForm,
|
||||
DifyPluginToolParameterType,
|
||||
DifyPluginToolsLayerConfig,
|
||||
)
|
||||
from dify_agent.layers.dify_plugin.llm_layer import DifyPluginLLMLayer
|
||||
from dify_agent.layers.dify_plugin.tools_layer import DifyPluginToolsLayer
|
||||
from dify_agent.layers.output import DIFY_OUTPUT_LAYER_TYPE_ID, DifyOutputLayerConfig
|
||||
from dify_agent.protocol import DIFY_AGENT_HISTORY_LAYER_ID, DIFY_AGENT_MODEL_LAYER_ID, DIFY_AGENT_OUTPUT_LAYER_ID
|
||||
from dify_agent.protocol.schemas import (
|
||||
@ -34,15 +46,20 @@ from dify_agent.protocol.schemas import (
|
||||
RunSucceededEvent,
|
||||
)
|
||||
from dify_agent.runtime.event_sink import InMemoryRunEventSink
|
||||
from dify_agent.runtime.compositor_factory import create_default_layer_providers
|
||||
from dify_agent.runtime.runner import AgentRunRunner, AgentRunValidationError
|
||||
|
||||
|
||||
class StaticToolsTestLayer(ToolsLayer):
|
||||
type_id: ClassVar[str] = "test.static.tools"
|
||||
|
||||
|
||||
def _request(
|
||||
user: str | list[str] = "hello",
|
||||
*,
|
||||
include_history: bool = False,
|
||||
llm_layer_name: str = DIFY_AGENT_MODEL_LAYER_ID,
|
||||
plugin_layer_name: str = "plugin",
|
||||
execution_context_layer_name: str = "execution_context",
|
||||
on_exit: LayerExitSignals | None = None,
|
||||
output_config: Mapping[str, object] | DifyOutputLayerConfig | None = None,
|
||||
) -> CreateRunRequest:
|
||||
@ -58,15 +75,16 @@ def _request(
|
||||
else []
|
||||
),
|
||||
RunLayerSpec(
|
||||
name=plugin_layer_name,
|
||||
type="dify.plugin",
|
||||
config=DifyPluginLayerConfig(tenant_id="tenant-1", plugin_id="langgenius/openai"),
|
||||
name=execution_context_layer_name,
|
||||
type=DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID,
|
||||
config=DifyExecutionContextLayerConfig(tenant_id="tenant-1", invoke_from="workflow_run"),
|
||||
),
|
||||
RunLayerSpec(
|
||||
name=llm_layer_name,
|
||||
type="dify.plugin.llm",
|
||||
deps={"plugin": plugin_layer_name},
|
||||
deps={"execution_context": execution_context_layer_name},
|
||||
config=DifyPluginLLMLayerConfig(
|
||||
plugin_id="langgenius/openai",
|
||||
model_provider="openai",
|
||||
model="demo-model",
|
||||
credentials={"api_key": "secret"},
|
||||
@ -103,6 +121,35 @@ def _recursive_output_schema() -> dict[str, object]:
|
||||
}
|
||||
|
||||
|
||||
def _prepared_plugin_tool_parameters() -> list[DifyPluginToolParameter]:
|
||||
return [
|
||||
DifyPluginToolParameter(
|
||||
name="query",
|
||||
type=DifyPluginToolParameterType.STRING,
|
||||
form=DifyPluginToolParameterForm.LLM,
|
||||
required=True,
|
||||
llm_description="Search query",
|
||||
),
|
||||
DifyPluginToolParameter(
|
||||
name="auth_scope",
|
||||
type=DifyPluginToolParameterType.STRING,
|
||||
form=DifyPluginToolParameterForm.FORM,
|
||||
required=True,
|
||||
llm_description="Hidden auth scope",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def _prepared_plugin_tool_schema() -> dict[str, JsonValue]:
|
||||
return {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {"type": "string", "description": "Search query"},
|
||||
},
|
||||
"required": ["query"],
|
||||
}
|
||||
|
||||
|
||||
class SequenceOutputTestModel(TestModel):
|
||||
outputs: list[str | dict[str, Any] | None]
|
||||
request_count: int
|
||||
@ -170,7 +217,7 @@ def _history_session_snapshot(
|
||||
lifecycle_state=LifecycleState.SUSPENDED,
|
||||
runtime_state=PydanticAIHistoryRuntimeState(messages=messages).model_dump(mode="json"),
|
||||
),
|
||||
LayerSessionSnapshot(name="plugin", lifecycle_state=LifecycleState.SUSPENDED, runtime_state={}),
|
||||
LayerSessionSnapshot(name="execution_context", lifecycle_state=LifecycleState.SUSPENDED, runtime_state={}),
|
||||
LayerSessionSnapshot(
|
||||
name=DIFY_AGENT_MODEL_LAYER_ID, lifecycle_state=LifecycleState.SUSPENDED, runtime_state={}
|
||||
),
|
||||
@ -198,12 +245,12 @@ def test_runner_emits_terminal_success_and_snapshot(monkeypatch: pytest.MonkeyPa
|
||||
|
||||
def fake_get_model(self: DifyPluginLLMLayer, *, http_client: httpx.AsyncClient):
|
||||
assert self.config.model == "demo-model"
|
||||
assert self.deps.plugin.config.plugin_id == "langgenius/openai"
|
||||
assert self.config.plugin_id == "langgenius/openai"
|
||||
seen_clients.append(http_client)
|
||||
return TestModel(custom_output_text="done") # pyright: ignore[reportReturnType]
|
||||
|
||||
monkeypatch.setattr(DifyPluginLLMLayer, "get_model", fake_get_model)
|
||||
request = _request(plugin_layer_name="renamed-plugin")
|
||||
request = _request(execution_context_layer_name="renamed-execution-context")
|
||||
sink = InMemoryRunEventSink()
|
||||
|
||||
async def scenario() -> None:
|
||||
@ -230,7 +277,7 @@ def test_runner_emits_terminal_success_and_snapshot(monkeypatch: pytest.MonkeyPa
|
||||
assert terminal.data.output == "done"
|
||||
assert [layer.name for layer in terminal.data.session_snapshot.layers] == [
|
||||
"prompt",
|
||||
"renamed-plugin",
|
||||
"renamed-execution-context",
|
||||
DIFY_AGENT_MODEL_LAYER_ID,
|
||||
]
|
||||
assert [layer.lifecycle_state for layer in terminal.data.session_snapshot.layers] == [
|
||||
@ -241,6 +288,315 @@ def test_runner_emits_terminal_success_and_snapshot(monkeypatch: pytest.MonkeyPa
|
||||
assert sink.statuses["run-1"] == "succeeded"
|
||||
|
||||
|
||||
def test_runner_passes_dynamic_dify_plugin_tools_to_agent(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
seen_tools: list[Tool[object]] = []
|
||||
|
||||
async def plugin_tool() -> str:
|
||||
return "tool"
|
||||
|
||||
def fake_get_model(_self: DifyPluginLLMLayer, *, http_client: httpx.AsyncClient):
|
||||
assert http_client.is_closed is False
|
||||
return TestModel(custom_output_text="done") # pyright: ignore[reportReturnType]
|
||||
|
||||
async def fake_get_tools(self: DifyPluginToolsLayer, *, http_client: httpx.AsyncClient) -> list[Tool[object]]:
|
||||
assert self.config.tools[0].tool_name == "web_search"
|
||||
assert http_client.is_closed is False
|
||||
return [Tool(plugin_tool, name="web_search")]
|
||||
|
||||
class FakeResult:
|
||||
output: str = "done"
|
||||
|
||||
def new_messages(self) -> list[ModelMessage]:
|
||||
return []
|
||||
|
||||
class FakeAgent:
|
||||
async def run(self, *_args: object, **_kwargs: object) -> FakeResult:
|
||||
return FakeResult()
|
||||
|
||||
def fake_create_agent(model: object, *, tools: list[Tool[object]], output_type: object) -> FakeAgent:
|
||||
del model, output_type
|
||||
seen_tools.extend(tools)
|
||||
return FakeAgent()
|
||||
|
||||
monkeypatch.setattr(DifyPluginLLMLayer, "get_model", fake_get_model)
|
||||
monkeypatch.setattr(DifyPluginToolsLayer, "get_tools", fake_get_tools)
|
||||
monkeypatch.setattr("dify_agent.runtime.runner.create_agent", fake_create_agent)
|
||||
|
||||
request = CreateRunRequest(
|
||||
composition=RunComposition(
|
||||
layers=[
|
||||
RunLayerSpec(
|
||||
name="prompt",
|
||||
type="plain.prompt",
|
||||
config=PromptLayerConfig(prefix="system", user="hello"),
|
||||
),
|
||||
RunLayerSpec(
|
||||
name="execution_context",
|
||||
type=DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID,
|
||||
config=DifyExecutionContextLayerConfig(tenant_id="tenant-1", invoke_from="workflow_run"),
|
||||
),
|
||||
RunLayerSpec(
|
||||
name=DIFY_AGENT_MODEL_LAYER_ID,
|
||||
type="dify.plugin.llm",
|
||||
deps={"execution_context": "execution_context"},
|
||||
config=DifyPluginLLMLayerConfig(
|
||||
plugin_id="langgenius/openai",
|
||||
model_provider="openai",
|
||||
model="demo-model",
|
||||
credentials={"api_key": "secret"},
|
||||
),
|
||||
),
|
||||
RunLayerSpec(
|
||||
name="tools",
|
||||
type=DIFY_PLUGIN_TOOLS_LAYER_TYPE_ID,
|
||||
deps={"execution_context": "execution_context"},
|
||||
config=DifyPluginToolsLayerConfig(
|
||||
tools=[
|
||||
DifyPluginToolConfig(
|
||||
plugin_id="langgenius/tools",
|
||||
provider="search",
|
||||
tool_name="web_search",
|
||||
credential_type="api-key",
|
||||
parameters=_prepared_plugin_tool_parameters(),
|
||||
parameters_json_schema=_prepared_plugin_tool_schema(),
|
||||
)
|
||||
]
|
||||
),
|
||||
),
|
||||
]
|
||||
)
|
||||
)
|
||||
sink = InMemoryRunEventSink()
|
||||
|
||||
async def scenario() -> None:
|
||||
async with httpx.AsyncClient() as client:
|
||||
await AgentRunRunner(
|
||||
sink=sink,
|
||||
request=request,
|
||||
run_id="run-tools",
|
||||
plugin_daemon_http_client=client,
|
||||
).run()
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
assert [tool.name for tool in seen_tools] == ["web_search"]
|
||||
terminal = sink.events["run-tools"][-1]
|
||||
assert isinstance(terminal, RunSucceededEvent)
|
||||
assert terminal.data.output == "done"
|
||||
|
||||
|
||||
def test_runner_rejects_duplicate_tool_names_across_dynamic_tool_layers(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
create_agent_called = False
|
||||
|
||||
async def duplicate_tool() -> str:
|
||||
return "tool"
|
||||
|
||||
def fake_get_model(_self: DifyPluginLLMLayer, *, http_client: httpx.AsyncClient):
|
||||
assert http_client.is_closed is False
|
||||
return TestModel(custom_output_text="done") # pyright: ignore[reportReturnType]
|
||||
|
||||
async def fake_get_tools(_self: DifyPluginToolsLayer, *, http_client: httpx.AsyncClient) -> list[Tool[object]]:
|
||||
assert http_client.is_closed is False
|
||||
return [Tool(duplicate_tool, name="shared_tool")]
|
||||
|
||||
def fake_create_agent(model: object, *, tools: list[Tool[object]], output_type: object) -> object:
|
||||
del model, tools, output_type
|
||||
nonlocal create_agent_called
|
||||
create_agent_called = True
|
||||
raise AssertionError("create_agent should not be called when duplicate tool names are detected")
|
||||
|
||||
monkeypatch.setattr(DifyPluginLLMLayer, "get_model", fake_get_model)
|
||||
monkeypatch.setattr(DifyPluginToolsLayer, "get_tools", fake_get_tools)
|
||||
monkeypatch.setattr("dify_agent.runtime.runner.create_agent", fake_create_agent)
|
||||
|
||||
request = CreateRunRequest(
|
||||
composition=RunComposition(
|
||||
layers=[
|
||||
RunLayerSpec(
|
||||
name="prompt",
|
||||
type="plain.prompt",
|
||||
config=PromptLayerConfig(prefix="system", user="hello"),
|
||||
),
|
||||
RunLayerSpec(
|
||||
name="execution_context",
|
||||
type=DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID,
|
||||
config=DifyExecutionContextLayerConfig(tenant_id="tenant-1", invoke_from="workflow_run"),
|
||||
),
|
||||
RunLayerSpec(
|
||||
name=DIFY_AGENT_MODEL_LAYER_ID,
|
||||
type="dify.plugin.llm",
|
||||
deps={"execution_context": "execution_context"},
|
||||
config=DifyPluginLLMLayerConfig(
|
||||
plugin_id="langgenius/openai",
|
||||
model_provider="openai",
|
||||
model="demo-model",
|
||||
credentials={"api_key": "secret"},
|
||||
),
|
||||
),
|
||||
RunLayerSpec(
|
||||
name="tools-1",
|
||||
type=DIFY_PLUGIN_TOOLS_LAYER_TYPE_ID,
|
||||
deps={"execution_context": "execution_context"},
|
||||
config=DifyPluginToolsLayerConfig(
|
||||
tools=[
|
||||
DifyPluginToolConfig(
|
||||
plugin_id="langgenius/tools",
|
||||
provider="search",
|
||||
tool_name="web_search",
|
||||
credential_type="api-key",
|
||||
parameters=_prepared_plugin_tool_parameters(),
|
||||
parameters_json_schema=_prepared_plugin_tool_schema(),
|
||||
)
|
||||
]
|
||||
),
|
||||
),
|
||||
RunLayerSpec(
|
||||
name="tools-2",
|
||||
type=DIFY_PLUGIN_TOOLS_LAYER_TYPE_ID,
|
||||
deps={"execution_context": "execution_context"},
|
||||
config=DifyPluginToolsLayerConfig(
|
||||
tools=[
|
||||
DifyPluginToolConfig(
|
||||
plugin_id="langgenius/tools",
|
||||
provider="search",
|
||||
tool_name="web_search_two",
|
||||
credential_type="api-key",
|
||||
parameters=_prepared_plugin_tool_parameters(),
|
||||
parameters_json_schema=_prepared_plugin_tool_schema(),
|
||||
)
|
||||
]
|
||||
),
|
||||
),
|
||||
]
|
||||
)
|
||||
)
|
||||
sink = InMemoryRunEventSink()
|
||||
|
||||
async def scenario() -> None:
|
||||
async with httpx.AsyncClient() as client:
|
||||
with pytest.raises(
|
||||
AgentRunValidationError,
|
||||
match="unique tool names across all layers, got duplicates: shared_tool",
|
||||
):
|
||||
await AgentRunRunner(
|
||||
sink=sink,
|
||||
request=request,
|
||||
run_id="run-duplicate-tools",
|
||||
plugin_daemon_http_client=client,
|
||||
).run()
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
assert create_agent_called is False
|
||||
assert [event.type for event in sink.events["run-duplicate-tools"]] == ["run_started", "run_failed"]
|
||||
assert sink.statuses["run-duplicate-tools"] == "failed"
|
||||
|
||||
|
||||
def test_runner_rejects_duplicate_tool_names_between_static_and_dynamic_tools(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
create_agent_called = False
|
||||
|
||||
def web_search(query: str) -> str:
|
||||
return query
|
||||
|
||||
async def dynamic_duplicate_tool() -> str:
|
||||
return "tool"
|
||||
|
||||
def fake_get_model(_self: DifyPluginLLMLayer, *, http_client: httpx.AsyncClient):
|
||||
assert http_client.is_closed is False
|
||||
return TestModel(custom_output_text="done") # pyright: ignore[reportReturnType]
|
||||
|
||||
async def fake_get_tools(_self: DifyPluginToolsLayer, *, http_client: httpx.AsyncClient) -> list[Tool[object]]:
|
||||
assert http_client.is_closed is False
|
||||
return [Tool(dynamic_duplicate_tool, name="web_search")]
|
||||
|
||||
def fake_create_agent(model: object, *, tools: list[Tool[object]], output_type: object) -> object:
|
||||
del model, tools, output_type
|
||||
nonlocal create_agent_called
|
||||
create_agent_called = True
|
||||
raise AssertionError("create_agent should not be called when duplicate tool names are detected")
|
||||
|
||||
monkeypatch.setattr(DifyPluginLLMLayer, "get_model", fake_get_model)
|
||||
monkeypatch.setattr(DifyPluginToolsLayer, "get_tools", fake_get_tools)
|
||||
monkeypatch.setattr("dify_agent.runtime.runner.create_agent", fake_create_agent)
|
||||
|
||||
static_tools_provider = LayerProvider.from_factory(
|
||||
layer_type=StaticToolsTestLayer,
|
||||
create=lambda _config: StaticToolsTestLayer(tool_entries=(web_search,)),
|
||||
)
|
||||
layer_providers = (*create_default_layer_providers(), static_tools_provider)
|
||||
|
||||
request = CreateRunRequest(
|
||||
composition=RunComposition(
|
||||
layers=[
|
||||
RunLayerSpec(
|
||||
name="prompt",
|
||||
type="plain.prompt",
|
||||
config=PromptLayerConfig(prefix="system", user="hello"),
|
||||
),
|
||||
RunLayerSpec(name="static-tools", type=cast(str, StaticToolsTestLayer.type_id)),
|
||||
RunLayerSpec(
|
||||
name="execution_context",
|
||||
type=DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID,
|
||||
config=DifyExecutionContextLayerConfig(tenant_id="tenant-1", invoke_from="workflow_run"),
|
||||
),
|
||||
RunLayerSpec(
|
||||
name=DIFY_AGENT_MODEL_LAYER_ID,
|
||||
type="dify.plugin.llm",
|
||||
deps={"execution_context": "execution_context"},
|
||||
config=DifyPluginLLMLayerConfig(
|
||||
plugin_id="langgenius/openai",
|
||||
model_provider="openai",
|
||||
model="demo-model",
|
||||
credentials={"api_key": "secret"},
|
||||
),
|
||||
),
|
||||
RunLayerSpec(
|
||||
name="tools",
|
||||
type=DIFY_PLUGIN_TOOLS_LAYER_TYPE_ID,
|
||||
deps={"execution_context": "execution_context"},
|
||||
config=DifyPluginToolsLayerConfig(
|
||||
tools=[
|
||||
DifyPluginToolConfig(
|
||||
plugin_id="langgenius/tools",
|
||||
provider="search",
|
||||
tool_name="web_search",
|
||||
credential_type="api-key",
|
||||
parameters=_prepared_plugin_tool_parameters(),
|
||||
parameters_json_schema=_prepared_plugin_tool_schema(),
|
||||
)
|
||||
]
|
||||
),
|
||||
),
|
||||
]
|
||||
)
|
||||
)
|
||||
sink = InMemoryRunEventSink()
|
||||
|
||||
async def scenario() -> None:
|
||||
async with httpx.AsyncClient() as client:
|
||||
with pytest.raises(
|
||||
AgentRunValidationError,
|
||||
match="unique tool names across all layers, got duplicates: web_search",
|
||||
):
|
||||
await AgentRunRunner(
|
||||
sink=sink,
|
||||
request=request,
|
||||
run_id="run-static-dynamic-duplicate-tools",
|
||||
plugin_daemon_http_client=client,
|
||||
layer_providers=layer_providers,
|
||||
).run()
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
assert create_agent_called is False
|
||||
assert [event.type for event in sink.events["run-static-dynamic-duplicate-tools"]] == ["run_started", "run_failed"]
|
||||
assert sink.statuses["run-static-dynamic-duplicate-tools"] == "failed"
|
||||
|
||||
|
||||
def test_runner_passes_temporary_system_prompt_prefix_without_history_layer(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
model = RecordingTestModel(custom_output_text="done")
|
||||
|
||||
@ -271,7 +627,7 @@ def test_runner_passes_temporary_system_prompt_prefix_without_history_layer(monk
|
||||
assert isinstance(terminal, RunSucceededEvent)
|
||||
assert [layer.name for layer in terminal.data.session_snapshot.layers] == [
|
||||
"prompt",
|
||||
"plugin",
|
||||
"execution_context",
|
||||
DIFY_AGENT_MODEL_LAYER_ID,
|
||||
]
|
||||
|
||||
@ -440,7 +796,7 @@ def test_runner_applies_on_exit_overrides_to_success_snapshot(monkeypatch: pytes
|
||||
assert isinstance(terminal, RunSucceededEvent)
|
||||
assert {layer.name: layer.lifecycle_state for layer in terminal.data.session_snapshot.layers} == {
|
||||
"prompt": LifecycleState.CLOSED,
|
||||
"plugin": LifecycleState.SUSPENDED,
|
||||
"execution_context": LifecycleState.SUSPENDED,
|
||||
DIFY_AGENT_MODEL_LAYER_ID: LifecycleState.CLOSED,
|
||||
}
|
||||
|
||||
@ -478,7 +834,12 @@ def test_runner_passes_output_layer_spec_to_agent_and_serializes_structured_resu
|
||||
)
|
||||
)
|
||||
sink = InMemoryRunEventSink()
|
||||
expected_snapshot_layer_names = ["prompt", "plugin", DIFY_AGENT_MODEL_LAYER_ID, DIFY_AGENT_OUTPUT_LAYER_ID]
|
||||
expected_snapshot_layer_names = [
|
||||
"prompt",
|
||||
"execution_context",
|
||||
DIFY_AGENT_MODEL_LAYER_ID,
|
||||
DIFY_AGENT_OUTPUT_LAYER_ID,
|
||||
]
|
||||
|
||||
async def scenario() -> None:
|
||||
async with httpx.AsyncClient() as client:
|
||||
@ -682,15 +1043,16 @@ def test_runner_rejects_misnamed_output_layer_before_model_resolution(monkeypatc
|
||||
config=PromptLayerConfig(prefix="system", user="hello"),
|
||||
),
|
||||
RunLayerSpec(
|
||||
name="plugin",
|
||||
type="dify.plugin",
|
||||
config=DifyPluginLayerConfig(tenant_id="tenant-1", plugin_id="langgenius/openai"),
|
||||
name="execution_context",
|
||||
type=DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID,
|
||||
config=DifyExecutionContextLayerConfig(tenant_id="tenant-1", invoke_from="workflow_run"),
|
||||
),
|
||||
RunLayerSpec(
|
||||
name=DIFY_AGENT_MODEL_LAYER_ID,
|
||||
type="dify.plugin.llm",
|
||||
deps={"plugin": "plugin"},
|
||||
deps={"execution_context": "execution_context"},
|
||||
config=DifyPluginLLMLayerConfig(
|
||||
plugin_id="langgenius/openai",
|
||||
model_provider="openai",
|
||||
model="demo-model",
|
||||
credentials={"api_key": "secret"},
|
||||
@ -750,15 +1112,16 @@ def test_runner_rejects_multiple_output_layers_before_model_resolution(monkeypat
|
||||
config=PromptLayerConfig(prefix="system", user="hello"),
|
||||
),
|
||||
RunLayerSpec(
|
||||
name="plugin",
|
||||
type="dify.plugin",
|
||||
config=DifyPluginLayerConfig(tenant_id="tenant-1", plugin_id="langgenius/openai"),
|
||||
name="execution_context",
|
||||
type=DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID,
|
||||
config=DifyExecutionContextLayerConfig(tenant_id="tenant-1", invoke_from="workflow_run"),
|
||||
),
|
||||
RunLayerSpec(
|
||||
name=DIFY_AGENT_MODEL_LAYER_ID,
|
||||
type="dify.plugin.llm",
|
||||
deps={"plugin": "plugin"},
|
||||
deps={"execution_context": "execution_context"},
|
||||
config=DifyPluginLLMLayerConfig(
|
||||
plugin_id="langgenius/openai",
|
||||
model_provider="openai",
|
||||
model="demo-model",
|
||||
credentials={"api_key": "secret"},
|
||||
@ -840,15 +1203,16 @@ def test_runner_rejects_reserved_output_name_with_wrong_layer_type_before_model_
|
||||
config=PromptLayerConfig(prefix="system", user="hello"),
|
||||
),
|
||||
RunLayerSpec(
|
||||
name="plugin",
|
||||
type="dify.plugin",
|
||||
config=DifyPluginLayerConfig(tenant_id="tenant-1", plugin_id="langgenius/openai"),
|
||||
name="execution_context",
|
||||
type=DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID,
|
||||
config=DifyExecutionContextLayerConfig(tenant_id="tenant-1", invoke_from="workflow_run"),
|
||||
),
|
||||
RunLayerSpec(
|
||||
name=DIFY_AGENT_MODEL_LAYER_ID,
|
||||
type="dify.plugin.llm",
|
||||
deps={"plugin": "plugin"},
|
||||
deps={"execution_context": "execution_context"},
|
||||
config=DifyPluginLLMLayerConfig(
|
||||
plugin_id="langgenius/openai",
|
||||
model_provider="openai",
|
||||
model="demo-model",
|
||||
credentials={"api_key": "secret"},
|
||||
@ -1042,7 +1406,7 @@ def test_runner_rejects_closed_session_snapshot_as_validation_error() -> None:
|
||||
runtime_state={},
|
||||
),
|
||||
LayerSessionSnapshot(
|
||||
name="plugin",
|
||||
name="execution_context",
|
||||
lifecycle_state=LifecycleState.NEW,
|
||||
runtime_state={},
|
||||
),
|
||||
|
||||
@ -6,9 +6,9 @@ import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
import dify_agent.server.app as app_module
|
||||
from dify_agent.layers.execution_context import DifyExecutionContextLayerConfig
|
||||
from dify_agent.layers.execution_context.layer import DifyExecutionContextLayer
|
||||
from dify_agent.runtime.compositor_factory import DifyAgentLayerProvider
|
||||
from dify_agent.layers.dify_plugin.configs import DifyPluginLayerConfig
|
||||
from dify_agent.layers.dify_plugin.plugin_layer import DifyPluginLayer
|
||||
from dify_agent.server.app import create_app, create_plugin_daemon_http_client
|
||||
from dify_agent.server.settings import ServerSettings
|
||||
from dify_agent.storage.redis_run_store import RedisRunStore
|
||||
@ -148,11 +148,15 @@ def test_create_app_creates_scheduler_and_closes_after_shutdown(monkeypatch: pyt
|
||||
assert scheduler.shutdown_grace_seconds == 5
|
||||
layer_providers = scheduler.layer_providers
|
||||
assert isinstance(layer_providers, tuple)
|
||||
plugin_provider = next(provider for provider in layer_providers if provider.type_id == "dify.plugin")
|
||||
plugin_layer = plugin_provider.create_layer(DifyPluginLayerConfig(tenant_id="tenant-1", plugin_id="plugin-1"))
|
||||
assert isinstance(plugin_layer, DifyPluginLayer)
|
||||
assert plugin_layer.daemon_url == "http://plugin-daemon"
|
||||
assert plugin_layer.daemon_api_key == "daemon-secret"
|
||||
execution_context_provider = next(
|
||||
provider for provider in layer_providers if provider.type_id == "dify.execution_context"
|
||||
)
|
||||
execution_context_layer = execution_context_provider.create_layer(
|
||||
DifyExecutionContextLayerConfig(tenant_id="tenant-1", invoke_from="workflow_run")
|
||||
)
|
||||
assert isinstance(execution_context_layer, DifyExecutionContextLayer)
|
||||
assert execution_context_layer.daemon_url == "http://plugin-daemon"
|
||||
assert execution_context_layer.daemon_api_key == "daemon-secret"
|
||||
http_client = scheduler.plugin_daemon_http_client
|
||||
assert http_client is fake_http_client
|
||||
assert http_client.is_closed is False
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from dify_agent.protocol import DIFY_AGENT_MODEL_LAYER_ID
|
||||
from dify_agent.runtime.run_scheduler import RunRequestValidationError, SchedulerStoppingError
|
||||
from dify_agent.runtime.run_scheduler import SchedulerStoppingError
|
||||
from dify_agent.server.routes.runs import create_runs_router
|
||||
from dify_agent.server.schemas import RunRecord
|
||||
|
||||
@ -9,14 +9,14 @@ from dify_agent.server.schemas import RunRecord
|
||||
class FakeScheduler:
|
||||
async def create_run(self, request: object) -> object:
|
||||
del request
|
||||
raise RunRequestValidationError("run.user_prompts must not be empty")
|
||||
return RunRecord(run_id="run-1", status="running")
|
||||
|
||||
|
||||
class FakeStore:
|
||||
pass
|
||||
|
||||
|
||||
def test_create_run_rejects_effectively_blank_user_prompt_list() -> None:
|
||||
def test_create_run_accepts_effectively_blank_user_prompt_list() -> None:
|
||||
from fastapi import FastAPI
|
||||
|
||||
app = FastAPI()
|
||||
@ -35,8 +35,8 @@ def test_create_run_rejects_effectively_blank_user_prompt_list() -> None:
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 422
|
||||
assert response.json()["detail"] == "run.user_prompts must not be empty"
|
||||
assert response.status_code == 202
|
||||
assert response.json() == {"run_id": "run-1", "status": "running"}
|
||||
|
||||
|
||||
def test_create_run_returns_running_from_scheduler() -> None:
|
||||
@ -104,15 +104,16 @@ def test_create_run_accepts_valid_full_plugin_graph() -> None:
|
||||
"layers": [
|
||||
{"name": "prompt", "type": "plain.prompt", "config": {"user": "hello"}},
|
||||
{
|
||||
"name": "plugin-renamed",
|
||||
"type": "dify.plugin",
|
||||
"config": {"tenant_id": "tenant-1", "plugin_id": "langgenius/openai"},
|
||||
"name": "execution-context-renamed",
|
||||
"type": "dify.execution_context",
|
||||
"config": {"tenant_id": "tenant-1", "invoke_from": "workflow_run"},
|
||||
},
|
||||
{
|
||||
"name": DIFY_AGENT_MODEL_LAYER_ID,
|
||||
"type": "dify.plugin.llm",
|
||||
"deps": {"plugin": "plugin-renamed"},
|
||||
"deps": {"execution_context": "execution-context-renamed"},
|
||||
"config": {
|
||||
"plugin_id": "langgenius/openai",
|
||||
"model_provider": "openai",
|
||||
"model": "gpt-4o-mini",
|
||||
"credentials": {"api_key": "secret"},
|
||||
@ -128,17 +129,12 @@ def test_create_run_accepts_valid_full_plugin_graph() -> None:
|
||||
assert response.json() == {"run_id": "run-1", "status": "running"}
|
||||
|
||||
|
||||
def test_create_run_rejects_unknown_layer_exit_signal_before_scheduling() -> None:
|
||||
def test_create_run_accepts_unknown_layer_exit_signal_request() -> None:
|
||||
from fastapi import FastAPI
|
||||
|
||||
class UnknownSignalScheduler:
|
||||
async def create_run(self, request: object) -> RunRecord:
|
||||
del request
|
||||
raise RunRequestValidationError("on_exit.layers references unknown layer ids: missing.")
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(
|
||||
create_runs_router(lambda: FakeStore(), lambda: UnknownSignalScheduler()) # pyright: ignore[reportArgumentType]
|
||||
create_runs_router(lambda: FakeStore(), lambda: FakeScheduler()) # pyright: ignore[reportArgumentType]
|
||||
)
|
||||
client = TestClient(app)
|
||||
|
||||
@ -153,21 +149,16 @@ def test_create_run_rejects_unknown_layer_exit_signal_before_scheduling() -> Non
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 422
|
||||
assert "missing" in response.json()["detail"]
|
||||
assert response.status_code == 202
|
||||
assert response.json() == {"run_id": "run-1", "status": "running"}
|
||||
|
||||
|
||||
def test_create_run_rejects_closed_session_snapshot_with_422() -> None:
|
||||
def test_create_run_accepts_closed_session_snapshot_request() -> None:
|
||||
from fastapi import FastAPI
|
||||
|
||||
class ClosedSnapshotScheduler:
|
||||
async def create_run(self, request: object) -> RunRecord:
|
||||
del request
|
||||
raise RunRequestValidationError("Layer 'prompt' is closed; CLOSED snapshots cannot be entered.")
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(
|
||||
create_runs_router(lambda: FakeStore(), lambda: ClosedSnapshotScheduler()) # pyright: ignore[reportArgumentType]
|
||||
create_runs_router(lambda: FakeStore(), lambda: FakeScheduler()) # pyright: ignore[reportArgumentType]
|
||||
)
|
||||
client = TestClient(app)
|
||||
|
||||
@ -191,8 +182,8 @@ def test_create_run_rejects_closed_session_snapshot_with_422() -> None:
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 422
|
||||
assert "CLOSED snapshots cannot be entered" in response.json()["detail"]
|
||||
assert response.status_code == 202
|
||||
assert response.json() == {"run_id": "run-1", "status": "running"}
|
||||
|
||||
|
||||
def test_create_run_returns_503_when_scheduler_is_stopping() -> None:
|
||||
|
||||
@ -79,8 +79,9 @@ def test_protocol_and_dify_plugin_exports_do_not_import_server_only_modules() ->
|
||||
blocked_imports=[
|
||||
"anthropic",
|
||||
"dify_agent.adapters.llm",
|
||||
"dify_agent.layers.execution_context.layer",
|
||||
"dify_agent.layers.dify_plugin.llm_layer",
|
||||
"dify_agent.layers.dify_plugin.plugin_layer",
|
||||
"dify_agent.layers.dify_plugin.tools_layer",
|
||||
"dify_agent.layers.output.output_layer",
|
||||
"dify_agent.runtime",
|
||||
"dify_agent.server",
|
||||
@ -91,10 +92,16 @@ def test_protocol_and_dify_plugin_exports_do_not_import_server_only_modules() ->
|
||||
"pydantic_settings",
|
||||
"redis",
|
||||
],
|
||||
imports=["dify_agent.protocol", "dify_agent.layers.dify_plugin", "dify_agent.layers.output"],
|
||||
imports=[
|
||||
"dify_agent.protocol",
|
||||
"dify_agent.layers.execution_context",
|
||||
"dify_agent.layers.dify_plugin",
|
||||
"dify_agent.layers.output",
|
||||
],
|
||||
assertions=[
|
||||
"assert hasattr(dify_agent_protocol, 'PydanticAIStreamRunEvent')",
|
||||
"assert dify_agent_layers_dify_plugin.__all__ == ['DIFY_PLUGIN_LAYER_TYPE_ID', 'DIFY_PLUGIN_LLM_LAYER_TYPE_ID', 'DifyPluginCredentialValue', 'DifyPluginLLMLayerConfig', 'DifyPluginLayerConfig']",
|
||||
"assert dify_agent_layers_execution_context.__all__ == ['DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID', 'DifyExecutionContextInvokeFrom', 'DifyExecutionContextLayerConfig']",
|
||||
"assert dify_agent_layers_dify_plugin.__all__ == ['DIFY_PLUGIN_LLM_LAYER_TYPE_ID', 'DIFY_PLUGIN_TOOLS_LAYER_TYPE_ID', 'DifyPluginCredentialValue', 'DifyPluginLLMLayerConfig', 'DifyPluginToolCredentialType', 'DifyPluginToolConfig', 'DifyPluginToolOption', 'DifyPluginToolParameter', 'DifyPluginToolParameterForm', 'DifyPluginToolParameterType', 'DifyPluginToolsLayerConfig', 'DifyPluginToolValue']",
|
||||
"assert dify_agent_layers_output.__all__ == ['DIFY_OUTPUT_LAYER_TYPE_ID', 'DifyOutputLayerConfig']",
|
||||
],
|
||||
)
|
||||
|
||||
@ -1757,14 +1757,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/base/textarea/index.stories.tsx": {
|
||||
"no-console": {
|
||||
"count": 1
|
||||
},
|
||||
"ts/no-explicit-any": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/base/voice-input/__tests__/index.spec.tsx": {
|
||||
"ts/no-explicit-any": {
|
||||
"count": 3
|
||||
|
||||
@ -34,6 +34,7 @@ import { FieldControl, FieldLabel, FieldRoot } from '@langgenius/dify-ui/field'
|
||||
import { Form } from '@langgenius/dify-ui/form'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
|
||||
import { SegmentedControl, SegmentedControlItem } from '@langgenius/dify-ui/segmented-control'
|
||||
import { Textarea } from '@langgenius/dify-ui/textarea'
|
||||
import '@langgenius/dify-ui/styles.css' // once, in the app root
|
||||
```
|
||||
|
||||
@ -41,17 +42,17 @@ Importing from `@langgenius/dify-ui` (no subpath) is intentionally not supported
|
||||
|
||||
## Primitives
|
||||
|
||||
| Category | Subpath | Notes |
|
||||
| ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------ |
|
||||
| Actions | `./button` | Design-system CTA primitive with `cva` variants. |
|
||||
| Controls | `./segmented-control` | SegmentedControl for mode, filter, and view selection. |
|
||||
| Feedback | `./meter`, `./toast` | Meter is inline status; Toast owns the `z-60` layer. |
|
||||
| Form | `./form`, `./field`, `./fieldset`, `./input`, `./checkbox`, `./checkbox-group`, `./radio`, `./radio-group`, `./number-field`, `./select`, `./slider`, `./switch` | Native form boundary, field semantics, and controls. |
|
||||
| Layout | `./scroll-area` | Custom-styled scrollbar over the host viewport. |
|
||||
| Media | `./avatar` | Avatar root, image, and fallback primitives. |
|
||||
| Navigation | `./pagination`, `./tabs` | Pagination for page navigation; Tabs for panels. |
|
||||
| Overlay / menu | `./alert-dialog`, `./context-menu`, `./dialog`, `./drawer`, `./dropdown-menu`, `./popover`, `./preview-card`, `./tooltip` | Portalled. See [Overlay & portal contract] below. |
|
||||
| Search / pickers | `./autocomplete`, `./combobox`, `./select` | Search input, searchable picker, and closed picker. |
|
||||
| Category | Subpath | Notes |
|
||||
| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------ |
|
||||
| Actions | `./button` | Design-system CTA primitive with `cva` variants. |
|
||||
| Controls | `./segmented-control` | SegmentedControl for mode, filter, and view selection. |
|
||||
| Feedback | `./meter`, `./toast` | Meter is inline status; Toast owns the `z-60` layer. |
|
||||
| Form | `./form`, `./field`, `./fieldset`, `./input`, `./textarea`, `./checkbox`, `./checkbox-group`, `./radio`, `./radio-group`, `./number-field`, `./select`, `./slider`, `./switch` | Native form boundary, field semantics, and controls. |
|
||||
| Layout | `./scroll-area` | Custom-styled scrollbar over the host viewport. |
|
||||
| Media | `./avatar` | Avatar root, image, and fallback primitives. |
|
||||
| Navigation | `./pagination`, `./tabs` | Pagination for page navigation; Tabs for panels. |
|
||||
| Overlay / menu | `./alert-dialog`, `./context-menu`, `./dialog`, `./drawer`, `./dropdown-menu`, `./popover`, `./preview-card`, `./tooltip` | Portalled. See [Overlay & portal contract] below. |
|
||||
| Search / pickers | `./autocomplete`, `./combobox`, `./select` | Search input, searchable picker, and closed picker. |
|
||||
|
||||
Utilities:
|
||||
|
||||
@ -72,7 +73,7 @@ Use `Form` for the submit boundary. It renders a native `<form>`, preserves Ente
|
||||
|
||||
Use `FieldRoot` for each standalone named field. A field must have a stable `name`, a label relationship, and either a `FieldControl` or another control that participates in the same Base UI field context. Prefer a visible label for normal form rows; when the surrounding UI already supplies the visible text, use the matching label primitive visually hidden or put `aria-label` on the actual interactive control. `FieldDescription` and `FieldError` provide the message relationships that screen readers need, while the Dify wrapper adds the default Form Input Set styling from the design system.
|
||||
|
||||
Choose the label primitive by the control semantics. Text-like inputs, input-based `Combobox` / `Autocomplete`, single `Checkbox` / `Radio`, `Switch`, and `NumberField` use `FieldLabel`. Trigger-based `Select` fields use `SelectLabel`; `Slider` fields use `SliderLabel`, with per-thumb `aria-label` only when the thumbs need distinct names. `SelectGroupLabel` and `AutocompleteGroupLabel` only label grouped options inside their popup content; they are not field labels.
|
||||
Choose the label primitive by the control semantics. Text-like inputs, `Textarea`, input-based `Combobox` / `Autocomplete`, single `Checkbox` / `Radio`, `Switch`, and `NumberField` use `FieldLabel`. Trigger-based `Select` fields use `SelectLabel`; `Slider` fields use `SliderLabel`, with per-thumb `aria-label` only when the thumbs need distinct names. `SelectGroupLabel` and `AutocompleteGroupLabel` only label grouped options inside their popup content; they are not field labels.
|
||||
|
||||
Use `FieldsetRoot` and `FieldsetLegend` when one field is represented by a group of related controls, such as checkbox groups, radio groups, multi-thumb sliders, or a section that combines several inputs. For checkbox and radio groups, wrap each option with `FieldItem` and give each option its own label:
|
||||
|
||||
|
||||
@ -129,6 +129,10 @@
|
||||
"types": "./src/tabs/index.tsx",
|
||||
"import": "./src/tabs/index.tsx"
|
||||
},
|
||||
"./textarea": {
|
||||
"types": "./src/textarea/index.tsx",
|
||||
"import": "./src/textarea/index.tsx"
|
||||
},
|
||||
"./toast": {
|
||||
"types": "./src/toast/index.tsx",
|
||||
"import": "./src/toast/index.tsx"
|
||||
|
||||
187
packages/dify-ui/src/textarea/__tests__/index.spec.tsx
Normal file
187
packages/dify-ui/src/textarea/__tests__/index.spec.tsx
Normal file
@ -0,0 +1,187 @@
|
||||
import type { FocusEvent } from 'react'
|
||||
import { render } from 'vitest-browser-react'
|
||||
import {
|
||||
FieldDescription,
|
||||
FieldError,
|
||||
FieldLabel,
|
||||
FieldRoot,
|
||||
} from '../../field'
|
||||
import { Form } from '../../form'
|
||||
import { Textarea } from '../index'
|
||||
|
||||
const asHTMLElement = (element: HTMLElement | SVGElement) => element as HTMLElement
|
||||
const setTextareaValue = (element: HTMLElement | SVGElement, value: string) => {
|
||||
const textarea = asHTMLElement(element) as HTMLTextAreaElement
|
||||
const valueSetter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value')?.set
|
||||
valueSetter?.call(textarea, value)
|
||||
textarea.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
}
|
||||
|
||||
describe('Textarea', () => {
|
||||
it('should render a labelled textarea through Base UI Field.Control', async () => {
|
||||
const screen = await render(
|
||||
<FieldRoot name="description">
|
||||
<FieldLabel>Description</FieldLabel>
|
||||
<Textarea defaultValue="A workspace for support automation." />
|
||||
<FieldDescription>Shown to workspace members.</FieldDescription>
|
||||
</FieldRoot>,
|
||||
)
|
||||
|
||||
const textarea = screen.getByRole('textbox', { name: 'Description' })
|
||||
|
||||
await expect.element(textarea).toHaveValue('A workspace for support automation.')
|
||||
await expect.element(textarea).toHaveAccessibleDescription('Shown to workspace members.')
|
||||
await expect.element(textarea).toHaveClass('min-h-20', 'overflow-auto', 'rounded-lg', 'system-sm-regular')
|
||||
expect(asHTMLElement(textarea.element()).tagName).toBe('TEXTAREA')
|
||||
})
|
||||
|
||||
it('should apply size variants and custom classes', async () => {
|
||||
const screen = await render(
|
||||
<label>
|
||||
Prompt
|
||||
<Textarea size="large" className="resize-none" />
|
||||
</label>,
|
||||
)
|
||||
|
||||
await expect.element(screen.getByRole('textbox', { name: 'Prompt' })).toHaveClass(
|
||||
'rounded-[10px]',
|
||||
'px-4',
|
||||
'py-2',
|
||||
'system-md-regular',
|
||||
'resize-none',
|
||||
)
|
||||
})
|
||||
|
||||
it('should call onValueChange and stay controlled until value changes', async () => {
|
||||
const onValueChange = vi.fn()
|
||||
const screen = await render(
|
||||
<label>
|
||||
Notes
|
||||
<Textarea value="" onValueChange={onValueChange} />
|
||||
</label>,
|
||||
)
|
||||
|
||||
const textarea = screen.getByRole('textbox', { name: 'Notes' })
|
||||
setTextareaValue(textarea.element(), 'a')
|
||||
|
||||
expect(onValueChange).toHaveBeenCalledWith('a', expect.any(Object))
|
||||
await expect.element(textarea).toHaveValue('')
|
||||
|
||||
await screen.rerender(
|
||||
<label>
|
||||
Notes
|
||||
<Textarea value="a" onValueChange={onValueChange} />
|
||||
</label>,
|
||||
)
|
||||
await expect.element(screen.getByRole('textbox', { name: 'Notes' })).toHaveValue('a')
|
||||
})
|
||||
|
||||
it('should submit valid values and show validation errors through Base UI Form', async () => {
|
||||
const onFormSubmit = vi.fn()
|
||||
const screen = await render(
|
||||
<Form aria-label="dataset form" onFormSubmit={onFormSubmit}>
|
||||
<FieldRoot name="summary">
|
||||
<FieldLabel>Summary</FieldLabel>
|
||||
<Textarea required minLength={10} />
|
||||
<FieldError match="valueMissing">Summary is required.</FieldError>
|
||||
<FieldError match="tooShort">Summary is too short.</FieldError>
|
||||
</FieldRoot>
|
||||
<button type="submit">Save</button>
|
||||
</Form>,
|
||||
)
|
||||
|
||||
const saveButton = asHTMLElement(screen.getByRole('button', { name: 'Save' }).element())
|
||||
saveButton.click()
|
||||
|
||||
await vi.waitFor(async () => {
|
||||
await expect.element(screen.getByText('Summary is required.')).toBeInTheDocument()
|
||||
await expect.element(screen.getByRole('textbox', { name: 'Summary' })).toHaveAttribute('aria-invalid', 'true')
|
||||
})
|
||||
expect(onFormSubmit).not.toHaveBeenCalled()
|
||||
|
||||
await screen.rerender(
|
||||
<Form aria-label="dataset form" onFormSubmit={onFormSubmit}>
|
||||
<FieldRoot name="summary">
|
||||
<FieldLabel>Summary</FieldLabel>
|
||||
<Textarea key="valid-summary" required minLength={10} defaultValue="Long enough summary" />
|
||||
<FieldError match="valueMissing">Summary is required.</FieldError>
|
||||
<FieldError match="tooShort">Summary is too short.</FieldError>
|
||||
</FieldRoot>
|
||||
<button type="submit">Save</button>
|
||||
</Form>,
|
||||
)
|
||||
|
||||
asHTMLElement(screen.getByRole('button', { name: 'Save' }).element()).click()
|
||||
expect(onFormSubmit).toHaveBeenCalledTimes(1)
|
||||
expect(onFormSubmit.mock.calls[0]?.[0]).toMatchObject({ summary: 'Long enough summary' })
|
||||
})
|
||||
|
||||
it('should pass maxLength to the textarea without rendering a counter', async () => {
|
||||
const screen = await render(
|
||||
<label>
|
||||
Release notes
|
||||
<Textarea defaultValue="Draft" maxLength={20} />
|
||||
</label>,
|
||||
)
|
||||
|
||||
const textarea = screen.getByRole('textbox', { name: 'Release notes' })
|
||||
await expect.element(textarea).toHaveAttribute('maxLength', '20')
|
||||
expect(screen.container.textContent).not.toContain('5/20')
|
||||
})
|
||||
|
||||
it('should route field props through Base UI Field.Control and textarea-only props to textarea', async () => {
|
||||
const onFormSubmit = vi.fn()
|
||||
const onBlur = vi.fn((event: FocusEvent<HTMLTextAreaElement>) => {
|
||||
expect(event.currentTarget.tagName).toBe('TEXTAREA')
|
||||
})
|
||||
const screen = await render(
|
||||
<Form aria-label="profile form" onFormSubmit={onFormSubmit}>
|
||||
<FieldRoot name="profileSummary">
|
||||
<FieldLabel>Profile summary</FieldLabel>
|
||||
<Textarea
|
||||
id="profile-summary"
|
||||
name="ignoredControlName"
|
||||
defaultValue="Long enough summary"
|
||||
rows={6}
|
||||
cols={40}
|
||||
wrap="soft"
|
||||
maxLength={80}
|
||||
onBlur={onBlur}
|
||||
/>
|
||||
</FieldRoot>
|
||||
<FieldRoot disabled>
|
||||
<FieldLabel>Disabled note</FieldLabel>
|
||||
<Textarea name="disabledNote" defaultValue="Disabled value" />
|
||||
</FieldRoot>
|
||||
<button type="submit">Save</button>
|
||||
</Form>,
|
||||
)
|
||||
|
||||
const profileSummary = screen.getByRole('textbox', { name: 'Profile summary' })
|
||||
expect(
|
||||
asHTMLElement(screen.getByText('Profile summary').element()).getAttribute('for'),
|
||||
).toBe('profile-summary')
|
||||
await expect.element(profileSummary).toHaveAttribute('id', 'profile-summary')
|
||||
await expect.element(profileSummary).toHaveAttribute('name', 'profileSummary')
|
||||
await expect.element(profileSummary).toHaveAttribute('rows', '6')
|
||||
await expect.element(profileSummary).toHaveAttribute('cols', '40')
|
||||
await expect.element(profileSummary).toHaveAttribute('wrap', 'soft')
|
||||
await expect.element(profileSummary).toHaveAttribute('maxLength', '80')
|
||||
|
||||
await expect.element(screen.getByRole('textbox', { name: 'Disabled note' })).toBeDisabled()
|
||||
|
||||
asHTMLElement(profileSummary.element()).focus()
|
||||
const saveButton = asHTMLElement(screen.getByRole('button', { name: 'Save' }).element())
|
||||
saveButton.focus()
|
||||
expect(onBlur).toHaveBeenCalledTimes(1)
|
||||
|
||||
saveButton.click()
|
||||
|
||||
expect(onFormSubmit).toHaveBeenCalledTimes(1)
|
||||
expect(onFormSubmit.mock.calls[0]?.[0]).toMatchObject({
|
||||
profileSummary: 'Long enough summary',
|
||||
})
|
||||
expect(onFormSubmit.mock.calls[0]?.[0]).not.toHaveProperty('ignoredControlName')
|
||||
expect(onFormSubmit.mock.calls[0]?.[0]).not.toHaveProperty('disabledNote')
|
||||
})
|
||||
})
|
||||
193
packages/dify-ui/src/textarea/index.stories.tsx
Normal file
193
packages/dify-ui/src/textarea/index.stories.tsx
Normal file
@ -0,0 +1,193 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite'
|
||||
import { useState } from 'react'
|
||||
import { Button } from '../button'
|
||||
import {
|
||||
FieldDescription,
|
||||
FieldError,
|
||||
FieldLabel,
|
||||
FieldRoot,
|
||||
} from '../field'
|
||||
import { Form } from '../form'
|
||||
import { Textarea } from './index'
|
||||
|
||||
const meta = {
|
||||
title: 'Base/Form/Textarea',
|
||||
component: Textarea,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
docs: {
|
||||
description: {
|
||||
component: 'Multiline text control built on Base UI Field.Control. Use it with FieldRoot for labelled, described, and validated form fields.',
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
} satisfies Meta<typeof Textarea>
|
||||
|
||||
export default meta
|
||||
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Basic: Story = {
|
||||
render: () => (
|
||||
<div className="w-80">
|
||||
<label htmlFor="workspace-description" className="mb-1 block w-fit py-1 text-text-secondary system-sm-medium">
|
||||
Workspace description
|
||||
</label>
|
||||
<Textarea
|
||||
id="workspace-description"
|
||||
name="workspaceDescription"
|
||||
placeholder="Describe how this workspace is used..."
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
export const Sizes: Story = {
|
||||
render: () => (
|
||||
<div className="grid w-80 gap-3">
|
||||
<label className="grid gap-1 text-text-secondary system-sm-medium" htmlFor="small-textarea">
|
||||
Small
|
||||
<Textarea id="small-textarea" size="small" name="smallTextarea" placeholder="Short note..." rows={3} />
|
||||
</label>
|
||||
<label className="grid gap-1 text-text-secondary system-sm-medium" htmlFor="medium-textarea">
|
||||
Medium
|
||||
<Textarea id="medium-textarea" name="mediumTextarea" placeholder="Add context..." rows={3} />
|
||||
</label>
|
||||
<label className="grid gap-1 text-text-secondary system-sm-medium" htmlFor="large-textarea">
|
||||
Large
|
||||
<Textarea id="large-textarea" size="large" name="largeTextarea" placeholder="Write a longer instruction..." rows={3} />
|
||||
</label>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
export const States: Story = {
|
||||
render: () => (
|
||||
<div className="grid w-80 gap-3">
|
||||
<FieldRoot name="placeholderState">
|
||||
<FieldLabel>Placeholder</FieldLabel>
|
||||
<Textarea placeholder="Add a description..." rows={3} />
|
||||
</FieldRoot>
|
||||
<FieldRoot name="filledState">
|
||||
<FieldLabel>Filled</FieldLabel>
|
||||
<Textarea defaultValue="Use this dataset for support articles and product FAQs." rows={3} />
|
||||
</FieldRoot>
|
||||
<FieldRoot name="invalidState" invalid>
|
||||
<FieldLabel>Invalid</FieldLabel>
|
||||
<Textarea defaultValue="Too short" rows={3} />
|
||||
<FieldError match>Use at least 20 characters.</FieldError>
|
||||
</FieldRoot>
|
||||
<FieldRoot name="disabledState">
|
||||
<FieldLabel>Disabled</FieldLabel>
|
||||
<Textarea disabled placeholder="Editing is unavailable..." rows={3} />
|
||||
</FieldRoot>
|
||||
<FieldRoot name="readonlyState">
|
||||
<FieldLabel>Read-only</FieldLabel>
|
||||
<Textarea readOnly defaultValue="Generated from the published workflow configuration." rows={3} />
|
||||
</FieldRoot>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
const FormDemo = () => {
|
||||
const [savedDescription, setSavedDescription] = useState<string | null>(null)
|
||||
|
||||
return (
|
||||
<Form
|
||||
aria-label="Dataset settings"
|
||||
className="grid w-80 gap-4"
|
||||
onFormSubmit={(values) => {
|
||||
setSavedDescription(String(values.description ?? ''))
|
||||
}}
|
||||
>
|
||||
<FieldRoot name="description">
|
||||
<FieldLabel>Description</FieldLabel>
|
||||
<Textarea
|
||||
required
|
||||
minLength={20}
|
||||
maxLength={160}
|
||||
placeholder="Describe what this dataset contains..."
|
||||
rows={4}
|
||||
className="resize-y"
|
||||
/>
|
||||
<FieldDescription>Shown to teammates when they choose a knowledge source.</FieldDescription>
|
||||
<FieldError match="valueMissing">Description is required.</FieldError>
|
||||
<FieldError match="tooShort">Use at least 20 characters.</FieldError>
|
||||
</FieldRoot>
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" variant="primary">Save Settings</Button>
|
||||
</div>
|
||||
{savedDescription && (
|
||||
<div className="rounded-lg bg-background-section px-3 py-2 text-text-secondary system-xs-regular">
|
||||
Saved:
|
||||
{' '}
|
||||
{savedDescription}
|
||||
</div>
|
||||
)}
|
||||
</Form>
|
||||
)
|
||||
}
|
||||
|
||||
export const WithField: Story = {
|
||||
render: () => <FormDemo />,
|
||||
}
|
||||
|
||||
const ControlledDemo = () => {
|
||||
const [value, setValue] = useState('Summarize customer feedback into actionable product themes.')
|
||||
|
||||
return (
|
||||
<FieldRoot name="prompt">
|
||||
<FieldLabel>Prompt</FieldLabel>
|
||||
<Textarea
|
||||
value={value}
|
||||
onValueChange={nextValue => setValue(nextValue)}
|
||||
rows={4}
|
||||
className="resize-y"
|
||||
/>
|
||||
<FieldDescription>The saved value is updated from the controlled state.</FieldDescription>
|
||||
</FieldRoot>
|
||||
)
|
||||
}
|
||||
|
||||
export const Controlled: Story = {
|
||||
render: () => (
|
||||
<div className="w-80">
|
||||
<ControlledDemo />
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
const CharacterCounterDemo = () => {
|
||||
const maxLength = 120
|
||||
const [value, setValue] = useState('Summarize customer feedback into actionable product themes.')
|
||||
|
||||
return (
|
||||
<FieldRoot name="limitedPrompt">
|
||||
<FieldLabel>Prompt</FieldLabel>
|
||||
<div className="relative">
|
||||
<Textarea
|
||||
value={value}
|
||||
onValueChange={nextValue => setValue(nextValue)}
|
||||
maxLength={maxLength}
|
||||
rows={4}
|
||||
className="resize-y pb-8"
|
||||
/>
|
||||
<div className="pointer-events-none absolute right-2 bottom-2 flex h-5 items-center rounded-md bg-background-section px-1 text-text-quaternary system-xs-medium">
|
||||
<span>{value.length}</span>
|
||||
/
|
||||
<span className="text-text-tertiary">{maxLength}</span>
|
||||
</div>
|
||||
</div>
|
||||
<FieldDescription>Character counters are composed at the usage site when the workflow needs one.</FieldDescription>
|
||||
</FieldRoot>
|
||||
)
|
||||
}
|
||||
|
||||
export const WithCharacterCounter: Story = {
|
||||
render: () => (
|
||||
<div className="w-80">
|
||||
<CharacterCounterDemo />
|
||||
</div>
|
||||
),
|
||||
}
|
||||
103
packages/dify-ui/src/textarea/index.tsx
Normal file
103
packages/dify-ui/src/textarea/index.tsx
Normal file
@ -0,0 +1,103 @@
|
||||
'use client'
|
||||
|
||||
import type { Field as BaseFieldNS } from '@base-ui/react/field'
|
||||
import type { VariantProps } from 'class-variance-authority'
|
||||
import type { ComponentPropsWithRef } from 'react'
|
||||
import { Field as BaseField } from '@base-ui/react/field'
|
||||
import { cva } from 'class-variance-authority'
|
||||
import { cn } from '../cn'
|
||||
|
||||
const textareaVariants = cva(
|
||||
[
|
||||
'min-h-20 w-full appearance-none overflow-auto border border-transparent bg-components-input-bg-normal text-components-input-text-filled caret-primary-600 outline-hidden transition-[background-color,border-color,box-shadow]',
|
||||
'placeholder:text-components-input-text-placeholder',
|
||||
'hover:border-components-input-border-hover hover:bg-components-input-bg-hover',
|
||||
'focus:border-components-input-border-active focus:bg-components-input-bg-active focus:shadow-xs',
|
||||
'data-invalid:border-components-input-border-destructive data-invalid:bg-components-input-bg-destructive',
|
||||
'read-only:cursor-default read-only:shadow-none read-only:hover:border-transparent read-only:hover:bg-components-input-bg-normal read-only:focus:border-transparent read-only:focus:bg-components-input-bg-normal read-only:focus:shadow-none',
|
||||
'disabled:cursor-not-allowed disabled:border-transparent disabled:bg-components-input-bg-disabled disabled:text-components-input-text-filled-disabled',
|
||||
'disabled:hover:border-transparent disabled:hover:bg-components-input-bg-disabled',
|
||||
'motion-reduce:transition-none',
|
||||
],
|
||||
{
|
||||
variants: {
|
||||
size: {
|
||||
small: 'rounded-md px-2 py-1 system-xs-regular',
|
||||
medium: 'rounded-lg px-3 py-2 system-sm-regular',
|
||||
large: 'rounded-[10px] px-4 py-2 system-md-regular',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
size: 'medium',
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
type TextareaValue = string | number
|
||||
export type TextareaSize = NonNullable<VariantProps<typeof textareaVariants>['size']>
|
||||
export type TextareaChangeEventDetails = BaseFieldNS.Control.ChangeEventDetails
|
||||
type TextareaOnValueChange = (value: string, eventDetails: TextareaChangeEventDetails) => void
|
||||
|
||||
type ControlledTextareaProps = {
|
||||
value: TextareaValue
|
||||
defaultValue?: never
|
||||
onValueChange: TextareaOnValueChange
|
||||
}
|
||||
|
||||
type UncontrolledTextareaProps = {
|
||||
value?: never
|
||||
defaultValue?: TextareaValue
|
||||
onValueChange?: TextareaOnValueChange
|
||||
}
|
||||
|
||||
type TextareaNativeProps = ComponentPropsWithRef<'textarea'>
|
||||
type TextareaOnlyProps = Pick<TextareaNativeProps, 'cols' | 'rows' | 'wrap'>
|
||||
type TextareaElementProps = Omit<
|
||||
TextareaNativeProps,
|
||||
'children' | 'className' | 'cols' | 'defaultValue' | 'onChange' | 'rows' | 'size' | 'value' | 'wrap'
|
||||
>
|
||||
|
||||
type TextareaControlProps = ControlledTextareaProps | UncontrolledTextareaProps
|
||||
type TextareaVariantProps = VariantProps<typeof textareaVariants>
|
||||
type FieldControlTextareaProps = Omit<
|
||||
BaseFieldNS.Control.Props,
|
||||
'className' | 'defaultValue' | 'onValueChange' | 'render' | 'value'
|
||||
>
|
||||
|
||||
export type TextareaProps
|
||||
= TextareaElementProps
|
||||
& TextareaOnlyProps
|
||||
& TextareaControlProps
|
||||
& TextareaVariantProps
|
||||
& {
|
||||
children?: never
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function Textarea({
|
||||
className,
|
||||
cols,
|
||||
defaultValue,
|
||||
onValueChange,
|
||||
ref,
|
||||
rows,
|
||||
size = 'medium',
|
||||
value,
|
||||
wrap,
|
||||
...controlProps
|
||||
}: TextareaProps) {
|
||||
// Base UI types Field.Control as an input even when render replaces it with a textarea.
|
||||
const fieldControlProps = controlProps as FieldControlTextareaProps
|
||||
|
||||
return (
|
||||
<BaseField.Control
|
||||
{...fieldControlProps}
|
||||
className={cn(textareaVariants({ size }), className)}
|
||||
defaultValue={defaultValue}
|
||||
onValueChange={onValueChange}
|
||||
ref={ref}
|
||||
render={<textarea cols={cols} rows={rows} wrap={wrap} />}
|
||||
value={value}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@ -493,8 +493,8 @@ describe('Capacity Full Components Integration', () => {
|
||||
expect(screen.getByText(/upgradeBtn\.encourageShort/i)).toBeInTheDocument()
|
||||
// Should show usage/total fraction "5/5"
|
||||
expect(screen.getByText(/5\/5/)).toBeInTheDocument()
|
||||
// Should have a meter rendered
|
||||
expect(screen.getByRole('meter')).toBeInTheDocument()
|
||||
// Should have an accessible meter rendered
|
||||
expect(screen.getByRole('meter', { name: /usagePage\.buildApps/i })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display upgrade tip and upgrade button for professional plan', () => {
|
||||
|
||||
@ -1,10 +1,10 @@
|
||||
'use client'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { Dialog, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog'
|
||||
import { Textarea } from '@langgenius/dify-ui/textarea'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Textarea from '@/app/components/base/textarea'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import { useRouter } from '@/next/navigation'
|
||||
import { useLogout } from '@/service/use-common'
|
||||
@ -63,11 +63,12 @@ export default function FeedBack(props: DeleteAccountProps) {
|
||||
</DialogTitle>
|
||||
<label className="mt-3 mb-1 flex items-center system-sm-semibold text-text-secondary">{t('account.feedbackLabel', { ns: 'common' })}</label>
|
||||
<Textarea
|
||||
aria-label={t('account.feedbackLabel', { ns: 'common' }) as string}
|
||||
rows={6}
|
||||
value={userFeedback}
|
||||
placeholder={t('account.feedbackPlaceholder', { ns: 'common' }) as string}
|
||||
onChange={(e) => {
|
||||
setUserFeedback(e.target.value)
|
||||
onValueChange={(value) => {
|
||||
setUserFeedback(value)
|
||||
}}
|
||||
/>
|
||||
<div className="mt-3 flex w-full flex-col gap-2">
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import { Textarea } from '@langgenius/dify-ui/textarea'
|
||||
import * as React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Robot, User } from '@/app/components/base/icons/src/public/avatar'
|
||||
import Textarea from '@/app/components/base/textarea'
|
||||
|
||||
export enum EditItemType {
|
||||
Query = 'query',
|
||||
@ -33,8 +33,9 @@ const EditItem: FC<Props> = ({
|
||||
<div className="grow">
|
||||
<div className="mb-1 system-xs-semibold text-text-primary">{name}</div>
|
||||
<Textarea
|
||||
aria-label={name}
|
||||
value={content}
|
||||
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => onChange(e.target.value)}
|
||||
onValueChange={value => onChange(value)}
|
||||
placeholder={placeholder}
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
@ -2,12 +2,12 @@
|
||||
import type { FC } from 'react'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Textarea } from '@langgenius/dify-ui/textarea'
|
||||
import { RiDeleteBinLine, RiEditFill, RiEditLine } from '@remixicon/react'
|
||||
import * as React from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Robot, User } from '@/app/components/base/icons/src/public/avatar'
|
||||
import Textarea from '@/app/components/base/textarea'
|
||||
|
||||
export enum EditItemType {
|
||||
Query = 'query',
|
||||
@ -130,8 +130,9 @@ const EditItem: FC<Props> = ({
|
||||
<div className="mt-3">
|
||||
<EditTitle title={editTitle} />
|
||||
<Textarea
|
||||
aria-label={editTitle}
|
||||
value={newContent}
|
||||
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => setNewContent(e.target.value)}
|
||||
onValueChange={value => setNewContent(value)}
|
||||
placeholder={placeholder}
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
@ -3,12 +3,12 @@ import type { VersionHistory } from '@/types/workflow'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog'
|
||||
import { FieldControl, FieldLabel, FieldRoot } from '@langgenius/dify-ui/field'
|
||||
import { Textarea } from '@langgenius/dify-ui/textarea'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { RiCloseLine } from '@remixicon/react'
|
||||
import * as React from 'react'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Textarea from '../../base/textarea'
|
||||
|
||||
type VersionInfoModalProps = {
|
||||
isOpen: boolean
|
||||
@ -57,8 +57,8 @@ const VersionInfoModal: FC<VersionInfoModalProps> = ({
|
||||
onClose()
|
||||
}
|
||||
|
||||
const handleDescriptionChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
setReleaseNotes(e.target.value)
|
||||
const handleDescriptionChange = useCallback((value: string) => {
|
||||
setReleaseNotes(value)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
@ -95,17 +95,16 @@ const VersionInfoModal: FC<VersionInfoModalProps> = ({
|
||||
onValueChange={setTitle}
|
||||
/>
|
||||
</FieldRoot>
|
||||
<div className="flex flex-col gap-y-1">
|
||||
<div className="flex h-6 items-center system-sm-semibold text-text-secondary">
|
||||
<FieldRoot name="releaseNotes" invalid={releaseNotesError} className="gap-y-1">
|
||||
<FieldLabel className="flex h-6 items-center py-0 system-sm-semibold text-text-secondary">
|
||||
{t('versionHistory.editField.releaseNotes', { ns: 'workflow' })}
|
||||
</div>
|
||||
</FieldLabel>
|
||||
<Textarea
|
||||
value={releaseNotes}
|
||||
placeholder={`${t('versionHistory.releaseNotesPlaceholder', { ns: 'workflow' })}${t('panel.optional', { ns: 'workflow' })}`}
|
||||
onChange={handleDescriptionChange}
|
||||
destructive={releaseNotesError}
|
||||
onValueChange={handleDescriptionChange}
|
||||
/>
|
||||
</div>
|
||||
</FieldRoot>
|
||||
</div>
|
||||
<div className="flex justify-end p-6 pt-5">
|
||||
<div className="flex items-center gap-x-3">
|
||||
|
||||
@ -13,12 +13,12 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@langgenius/dify-ui/select'
|
||||
import { Textarea } from '@langgenius/dify-ui/textarea'
|
||||
import * as React from 'react'
|
||||
import { Trans } from 'react-i18next'
|
||||
import { FileUploaderInAttachmentWrapper } from '@/app/components/base/file-uploader'
|
||||
import { Infotip } from '@/app/components/base/infotip'
|
||||
import Input from '@/app/components/base/input'
|
||||
import Textarea from '@/app/components/base/textarea'
|
||||
import CodeEditor from '@/app/components/workflow/nodes/_base/components/editor/code-editor'
|
||||
import FileUploadSetting from '@/app/components/workflow/nodes/_base/components/file-upload-setting'
|
||||
import { CodeLanguage } from '@/app/components/workflow/nodes/code/types'
|
||||
@ -121,8 +121,9 @@ const ConfigModalFormFields: FC<ConfigModalFormFieldsProps> = ({
|
||||
{type === InputVarType.paragraph && (
|
||||
<Field title={t('variableConfig.defaultValue', { ns: 'appDebug' })}>
|
||||
<Textarea
|
||||
aria-label={t('variableConfig.defaultValue', { ns: 'appDebug' })}
|
||||
value={String(tempPayload.default ?? '')}
|
||||
onChange={e => onPayloadChange('default')(e.target.value || undefined)}
|
||||
onValueChange={value => onPayloadChange('default')(value || undefined)}
|
||||
placeholder={t('variableConfig.inputPlaceholder', { ns: 'appDebug' })}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
@ -1,11 +1,11 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Textarea } from '@langgenius/dify-ui/textarea'
|
||||
import { useBoolean } from 'ahooks'
|
||||
import * as React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ArrowDownRoundFill } from '@/app/components/base/icons/src/vender/solid/general'
|
||||
import Textarea from '@/app/components/base/textarea'
|
||||
|
||||
const i18nPrefix = 'generate'
|
||||
|
||||
@ -40,10 +40,11 @@ const IdeaOutput: FC<Props> = ({
|
||||
</div>
|
||||
{!isFoldIdeaOutput && (
|
||||
<Textarea
|
||||
aria-label={t(`${i18nPrefix}.idealOutput`, { ns: 'appDebug' })}
|
||||
className="h-[80px]"
|
||||
placeholder={t(`${i18nPrefix}.idealOutputPlaceholder`, { ns: 'appDebug' })}
|
||||
value={value}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
onValueChange={value => onChange(value)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -4,13 +4,13 @@ import type { DataSet } from '@/models/datasets'
|
||||
import type { RetrievalConfig } from '@/types/app'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Textarea } from '@langgenius/dify-ui/textarea'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { RiCloseLine } from '@remixicon/react'
|
||||
import { isEqual } from 'es-toolkit/predicate'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Input from '@/app/components/base/input'
|
||||
import Textarea from '@/app/components/base/textarea'
|
||||
import { isReRankModelSelected } from '@/app/components/datasets/common/check-rerank-model'
|
||||
import { IndexingType } from '@/app/components/datasets/create/step-two'
|
||||
import IndexMethod from '@/app/components/datasets/settings/index-method'
|
||||
@ -224,8 +224,9 @@ const SettingsModal: FC<SettingsModalProps> = ({
|
||||
</div>
|
||||
<div className="w-full">
|
||||
<Textarea
|
||||
aria-label={t('form.desc', { ns: 'datasetSettings' })}
|
||||
value={localeCurrentDataset.description || ''}
|
||||
onChange={e => handleValueChange('description', e.target.value)}
|
||||
onValueChange={value => handleValueChange('description', value)}
|
||||
className="resize-none"
|
||||
placeholder={t('form.descPlaceholder', { ns: 'datasetSettings' }) || ''}
|
||||
/>
|
||||
|
||||
@ -84,25 +84,6 @@ vi.mock('@langgenius/dify-ui/select', async () => {
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/app/components/base/textarea', () => ({
|
||||
default: ({ value, onChange, placeholder, readOnly, className }: {
|
||||
value: string
|
||||
onChange: (e: { target: { value: string } }) => void
|
||||
placeholder?: string
|
||||
readOnly?: boolean
|
||||
className?: string
|
||||
}) => (
|
||||
<textarea
|
||||
data-testid={`textarea-${placeholder}`}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
placeholder={placeholder}
|
||||
readOnly={readOnly}
|
||||
className={className}
|
||||
/>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/nodes/_base/components/before-run-form/bool-input', () => ({
|
||||
default: ({ name, value, required, onChange, readonly }: {
|
||||
name: string
|
||||
@ -223,7 +204,7 @@ describe('ChatUserInput', () => {
|
||||
}))
|
||||
|
||||
render(<ChatUserInput inputs={{}} />)
|
||||
expect(screen.getByTestId('textarea-Description')).toBeInTheDocument()
|
||||
expect(screen.getByRole('textbox', { name: 'Description' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render select input type', () => {
|
||||
@ -275,7 +256,7 @@ describe('ChatUserInput', () => {
|
||||
|
||||
render(<ChatUserInput inputs={{}} />)
|
||||
expect(screen.getByTestId('input-Name')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('textarea-Description')).toBeInTheDocument()
|
||||
expect(screen.getByRole('textbox', { name: 'Description' })).toBeInTheDocument()
|
||||
expect(screen.getByTestId('select-input')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
@ -334,7 +315,7 @@ describe('ChatUserInput', () => {
|
||||
}))
|
||||
|
||||
render(<ChatUserInput inputs={{ desc: 'Long text here' }} />)
|
||||
expect(screen.getByTestId('textarea-Description')).toHaveValue('Long text here')
|
||||
expect(screen.getByRole('textbox', { name: 'Description' })).toHaveValue('Long text here')
|
||||
})
|
||||
|
||||
it('should display existing input values for number type', () => {
|
||||
@ -418,7 +399,7 @@ describe('ChatUserInput', () => {
|
||||
}))
|
||||
|
||||
render(<ChatUserInput inputs={{}} />)
|
||||
fireEvent.change(screen.getByTestId('textarea-Description'), { target: { value: 'New Description' } })
|
||||
fireEvent.change(screen.getByRole('textbox', { name: 'Description' }), { target: { value: 'New Description' } })
|
||||
|
||||
expect(mockSetInputs).toHaveBeenCalledWith({ desc: 'New Description' })
|
||||
})
|
||||
@ -526,7 +507,7 @@ describe('ChatUserInput', () => {
|
||||
}))
|
||||
|
||||
render(<ChatUserInput inputs={{}} />)
|
||||
expect(screen.getByTestId('textarea-Description')).toHaveAttribute('readonly')
|
||||
expect(screen.getByRole('textbox', { name: 'Description' })).toHaveAttribute('readonly')
|
||||
})
|
||||
|
||||
it('should disable select when readonly is true', () => {
|
||||
|
||||
@ -1,12 +1,12 @@
|
||||
import type { Inputs } from '@/models/debug'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select'
|
||||
import { Textarea } from '@langgenius/dify-ui/textarea'
|
||||
import * as React from 'react'
|
||||
import { useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useContext } from 'use-context-selector'
|
||||
import Input from '@/app/components/base/input'
|
||||
import Textarea from '@/app/components/base/textarea'
|
||||
import BoolInput from '@/app/components/workflow/nodes/_base/components/before-run-form/bool-input'
|
||||
import ConfigContext from '@/context/debug-configuration'
|
||||
|
||||
@ -94,9 +94,10 @@ const ChatUserInput = ({
|
||||
{type === 'paragraph' && (
|
||||
<Textarea
|
||||
className="h-[120px] grow"
|
||||
aria-label={name || key}
|
||||
placeholder={name}
|
||||
value={inputs[key] ? `${inputs[key]}` : ''}
|
||||
onChange={(e) => { handleInputValueChange(key, e.target.value) }}
|
||||
onValueChange={(value) => { handleInputValueChange(key, value) }}
|
||||
readOnly={readonly}
|
||||
/>
|
||||
)}
|
||||
|
||||
@ -5,6 +5,7 @@ import type { VisionFile, VisionSettings } from '@/types/app'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select'
|
||||
import { Textarea } from '@langgenius/dify-ui/textarea'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
|
||||
import {
|
||||
RiArrowDownSLine,
|
||||
@ -19,7 +20,6 @@ import { useStore as useAppStore } from '@/app/components/app/store'
|
||||
import FeatureBar from '@/app/components/base/features/new-feature-panel/feature-bar'
|
||||
import TextGenerationImageUploader from '@/app/components/base/image-uploader/text-generation-image-uploader'
|
||||
import Input from '@/app/components/base/input'
|
||||
import Textarea from '@/app/components/base/textarea'
|
||||
import BoolInput from '@/app/components/workflow/nodes/_base/components/before-run-form/bool-input'
|
||||
import ConfigContext from '@/context/debug-configuration'
|
||||
import { AppModeEnum, ModelModeType } from '@/types/app'
|
||||
@ -151,10 +151,11 @@ const PromptValuePanel: FC<IPromptValuePanelProps> = ({
|
||||
)}
|
||||
{type === 'paragraph' && (
|
||||
<Textarea
|
||||
aria-label={name}
|
||||
className="h-[120px] grow"
|
||||
placeholder={name}
|
||||
value={inputs[key] ? `${inputs[key]}` : ''}
|
||||
onChange={(e) => { handleInputValueChange(key, e.target.value) }}
|
||||
onValueChange={(value) => { handleInputValueChange(key, value) }}
|
||||
readOnly={readonly}
|
||||
/>
|
||||
)}
|
||||
|
||||
@ -4,6 +4,7 @@ import type { AppIconSelection } from '../../base/app-icon-picker'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Textarea } from '@langgenius/dify-ui/textarea'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { RiArrowRightLine, RiArrowRightSLine, RiExchange2Fill } from '@remixicon/react'
|
||||
import { useDebounceFn, useKeyPress } from 'ahooks'
|
||||
@ -13,7 +14,6 @@ import AppIcon from '@/app/components/base/app-icon'
|
||||
import Divider from '@/app/components/base/divider'
|
||||
import { BubbleTextMod, ChatBot, ListSparkle, Logic } from '@/app/components/base/icons/src/vender/solid/communication'
|
||||
import Input from '@/app/components/base/input'
|
||||
import Textarea from '@/app/components/base/textarea'
|
||||
import AppsFull from '@/app/components/billing/apps-full-in-dialog'
|
||||
import { NEED_REFRESH_APP_LIST_KEY } from '@/config'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
@ -241,10 +241,11 @@ function CreateApp({ onClose, onSuccess, onCreateFromTemplate, defaultAppMode }:
|
||||
</span>
|
||||
</div>
|
||||
<Textarea
|
||||
aria-label={t('newApp.captionDescription', { ns: 'app' })}
|
||||
className="resize-none"
|
||||
placeholder={t('newApp.appDescriptionPlaceholder', { ns: 'app' }) || ''}
|
||||
value={description}
|
||||
onChange={e => setDescription(e.target.value)}
|
||||
onValueChange={value => setDescription(value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -8,6 +8,7 @@ import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Dialog, DialogCloseButton, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog'
|
||||
import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select'
|
||||
import { Switch } from '@langgenius/dify-ui/switch'
|
||||
import { Textarea } from '@langgenius/dify-ui/textarea'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
|
||||
import * as React from 'react'
|
||||
@ -18,7 +19,6 @@ import AppIconPicker from '@/app/components/base/app-icon-picker'
|
||||
import Divider from '@/app/components/base/divider'
|
||||
import Input from '@/app/components/base/input'
|
||||
import { PremiumBadgeButton } from '@/app/components/base/premium-badge'
|
||||
import Textarea from '@/app/components/base/textarea'
|
||||
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
|
||||
import { useModalContext } from '@/context/modal-context'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
@ -289,9 +289,10 @@ const SettingsModal: FC<ISettingsModalProps> = ({
|
||||
<div className="relative">
|
||||
<div className={cn('py-1 system-sm-semibold text-text-secondary')}>{t(`${prefixSettings}.webDesc`, { ns: 'appOverview' })}</div>
|
||||
<Textarea
|
||||
aria-label={t(`${prefixSettings}.webDesc`, { ns: 'appOverview' })}
|
||||
className="mt-1"
|
||||
value={inputInfo.desc}
|
||||
onChange={e => onDesChange(e.target.value)}
|
||||
onValueChange={onDesChange}
|
||||
placeholder={t(`${prefixSettings}.webDescPlaceholder`, { ns: 'appOverview' }) as string}
|
||||
/>
|
||||
<p className={cn('pb-0.5 body-xs-regular text-text-tertiary')}>{t(`${prefixSettings}.webDescTip`, { ns: 'appOverview' })}</p>
|
||||
@ -464,9 +465,10 @@ const SettingsModal: FC<ISettingsModalProps> = ({
|
||||
<div className={cn('py-1 system-sm-semibold text-text-secondary')}>{t(`${prefixSettings}.more.customDisclaimer`, { ns: 'appOverview' })}</div>
|
||||
<p className={cn('pb-0.5 body-xs-regular text-text-tertiary')}>{t(`${prefixSettings}.more.customDisclaimerTip`, { ns: 'appOverview' })}</p>
|
||||
<Textarea
|
||||
aria-label={t(`${prefixSettings}.more.customDisclaimer`, { ns: 'appOverview' })}
|
||||
className="mt-1"
|
||||
value={inputInfo.customDisclaimer}
|
||||
onChange={onChange('customDisclaimer')}
|
||||
onValueChange={value => setInputInfo(item => ({ ...item, customDisclaimer: value }))}
|
||||
placeholder={t(`${prefixSettings}.more.customDisclaimerPlaceholder`, { ns: 'appOverview' }) as string}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -7,8 +7,8 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@langgenius/dify-ui/select'
|
||||
import { Textarea } from '@langgenius/dify-ui/textarea'
|
||||
import Input from '@/app/components/base/input'
|
||||
import Textarea from '@/app/components/base/textarea'
|
||||
|
||||
import { InputVarType } from '@/app/components/workflow/types'
|
||||
|
||||
@ -74,7 +74,7 @@ const WorkflowHiddenInputFields = ({
|
||||
<Textarea
|
||||
id={fieldId}
|
||||
value={typeof fieldValue === 'string' ? fieldValue : ''}
|
||||
onChange={(event: ChangeEvent<HTMLTextAreaElement>) => onValueChange(variable.variable, event.target.value)}
|
||||
onValueChange={value => onValueChange(variable.variable, value)}
|
||||
placeholder={label}
|
||||
maxLength={variable.max_length}
|
||||
className="min-h-24"
|
||||
|
||||
@ -1,10 +1,10 @@
|
||||
import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select'
|
||||
import { Textarea } from '@langgenius/dify-ui/textarea'
|
||||
import * as React from 'react'
|
||||
import { memo, useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { FileUploaderInAttachmentWrapper } from '@/app/components/base/file-uploader'
|
||||
import Input from '@/app/components/base/input'
|
||||
import Textarea from '@/app/components/base/textarea'
|
||||
import BoolInput from '@/app/components/workflow/nodes/_base/components/before-run-form/bool-input'
|
||||
import CodeEditor from '@/app/components/workflow/nodes/_base/components/editor/code-editor'
|
||||
import { CodeLanguage } from '@/app/components/workflow/nodes/code/types'
|
||||
@ -71,8 +71,9 @@ const InputsFormContent = ({ showTip }: Props) => {
|
||||
)}
|
||||
{form.type === InputVarType.paragraph && (
|
||||
<Textarea
|
||||
aria-label={form.label}
|
||||
value={inputsFormValue?.[form.variable] || ''}
|
||||
onChange={e => handleFormChange(form.variable, e.target.value)}
|
||||
onValueChange={value => handleFormChange(form.variable, value)}
|
||||
placeholder={form.label}
|
||||
/>
|
||||
)}
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
import type { ContentItemProps } from './type'
|
||||
import { Textarea } from '@langgenius/dify-ui/textarea'
|
||||
import * as React from 'react'
|
||||
import { useMemo } from 'react'
|
||||
import { Markdown } from '@/app/components/base/markdown'
|
||||
import Textarea from '@/app/components/base/textarea'
|
||||
|
||||
const ContentItem = ({
|
||||
content,
|
||||
@ -42,9 +42,10 @@ const ContentItem = ({
|
||||
<div className="py-3">
|
||||
{formInputField.type === 'paragraph' && (
|
||||
<Textarea
|
||||
aria-label={fieldName}
|
||||
className="h-[104px] sm:text-xs"
|
||||
value={inputs[fieldName]!}
|
||||
onChange={(e) => { onInputChange(fieldName, e.target.value) }}
|
||||
onValueChange={(value) => { onInputChange(fieldName, value) }}
|
||||
data-testid="content-item-textarea"
|
||||
/>
|
||||
)}
|
||||
|
||||
@ -6,6 +6,7 @@ import type {
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Dialog, DialogCloseButton, DialogContent, DialogDescription, DialogTitle } from '@langgenius/dify-ui/dialog'
|
||||
import { Textarea } from '@langgenius/dify-ui/textarea'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
|
||||
import copy from 'copy-to-clipboard'
|
||||
@ -21,7 +22,6 @@ import ActionButton, { ActionButtonState } from '@/app/components/base/action-bu
|
||||
import Log from '@/app/components/base/chat/chat/log'
|
||||
import AnnotationCtrlButton from '@/app/components/base/features/new-feature-panel/annotation-reply/annotation-ctrl-button'
|
||||
import NewAudioButton from '@/app/components/base/new-audio-button'
|
||||
import Textarea from '@/app/components/base/textarea'
|
||||
import { useChatContext } from '../context'
|
||||
|
||||
type OperationProps = {
|
||||
@ -394,7 +394,7 @@ function Operation({
|
||||
id={feedbackTextareaId}
|
||||
name="feedback-content"
|
||||
value={feedbackContent}
|
||||
onChange={e => setFeedbackContent(e.target.value)}
|
||||
onValueChange={value => setFeedbackContent(value)}
|
||||
placeholder={t('feedback.placeholder', { ns: 'common' }) || 'Please describe what went wrong or how we can improve…'}
|
||||
rows={4}
|
||||
className="w-full"
|
||||
|
||||
@ -1,10 +1,10 @@
|
||||
import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select'
|
||||
import { Textarea } from '@langgenius/dify-ui/textarea'
|
||||
import * as React from 'react'
|
||||
import { memo, useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { FileUploaderInAttachmentWrapper } from '@/app/components/base/file-uploader'
|
||||
import Input from '@/app/components/base/input'
|
||||
import Textarea from '@/app/components/base/textarea'
|
||||
import BoolInput from '@/app/components/workflow/nodes/_base/components/before-run-form/bool-input'
|
||||
import CodeEditor from '@/app/components/workflow/nodes/_base/components/editor/code-editor'
|
||||
import { CodeLanguage } from '@/app/components/workflow/nodes/code/types'
|
||||
@ -71,8 +71,9 @@ const InputsFormContent = ({ showTip }: Props) => {
|
||||
)}
|
||||
{form.type === InputVarType.paragraph && (
|
||||
<Textarea
|
||||
aria-label={form.label}
|
||||
value={inputsFormValue?.[form.variable] || ''}
|
||||
onChange={e => handleFormChange(form.variable, e.target.value)}
|
||||
onValueChange={value => handleFormChange(form.variable, value)}
|
||||
placeholder={form.label}
|
||||
/>
|
||||
)}
|
||||
|
||||
@ -12,10 +12,10 @@ import { FieldItem, FieldRoot } from '@langgenius/dify-ui/field'
|
||||
import { FieldsetLegend, FieldsetRoot } from '@langgenius/dify-ui/fieldset'
|
||||
import { RadioControl, RadioRoot } from '@langgenius/dify-ui/radio'
|
||||
import { RadioGroup } from '@langgenius/dify-ui/radio-group'
|
||||
import { Textarea } from '@langgenius/dify-ui/textarea'
|
||||
import { produce } from 'immer'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Textarea from '@/app/components/base/textarea'
|
||||
import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import { useModelListAndDefaultModelAndCurrentProviderAndModel } from '@/app/components/header/account-setting/model-provider-page/hooks'
|
||||
import ModelParameterModal from '@/app/components/header/account-setting/model-provider-page/model-parameter-modal'
|
||||
@ -220,9 +220,10 @@ const FollowUpSettingModal = ({
|
||||
</div>
|
||||
{promptMode === PROMPT_MODE.custom && (
|
||||
<Textarea
|
||||
aria-label={t('feature.suggestedQuestionsAfterAnswer.modal.customPromptOption', { ns: 'appDebug' })}
|
||||
className="mt-3 min-h-32 resize-y border-components-input-border-active bg-components-input-bg-normal"
|
||||
value={prompt}
|
||||
onChange={e => setPrompt(e.target.value)}
|
||||
onValueChange={value => setPrompt(value)}
|
||||
maxLength={CUSTOM_FOLLOW_UP_PROMPT_MAX_LENGTH}
|
||||
placeholder={t('feature.suggestedQuestionsAfterAnswer.modal.promptPlaceholder', { ns: 'appDebug' }) || ''}
|
||||
/>
|
||||
|
||||
@ -2,7 +2,7 @@ import type { FC } from 'react'
|
||||
import type { CodeBasedExtensionForm } from '@/models/common'
|
||||
import type { ModerationConfig } from '@/models/debug'
|
||||
import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select'
|
||||
import Textarea from '@/app/components/base/textarea'
|
||||
import { Textarea } from '@langgenius/dify-ui/textarea'
|
||||
import { useLocale } from '@/context/i18n'
|
||||
|
||||
type FormGenerationProps = {
|
||||
@ -55,10 +55,11 @@ const FormGeneration: FC<FormGenerationProps> = ({
|
||||
form.type === 'paragraph' && (
|
||||
<div className="relative">
|
||||
<Textarea
|
||||
aria-label={locale === 'zh-Hans' ? form.label['zh-Hans'] : form.label['en-US']}
|
||||
className="resize-none"
|
||||
value={value?.[form.variable] || ''}
|
||||
placeholder={form.placeholder}
|
||||
onChange={e => handleFormChange(form.variable, e.target.value)}
|
||||
onValueChange={value => handleFormChange(form.variable, value)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import type { FC } from 'react'
|
||||
import type { ModerationContentConfig } from '@/models/debug'
|
||||
import { Switch } from '@langgenius/dify-ui/switch'
|
||||
import { Textarea } from '@langgenius/dify-ui/textarea'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
type ModerationContentProps = {
|
||||
@ -50,12 +51,14 @@ const ModerationContent: FC<ModerationContentProps> = ({
|
||||
{t('feature.moderation.modal.content.preset', { ns: 'appDebug' })}
|
||||
<span className="text-xs font-normal text-text-tertiary">{t('feature.moderation.modal.content.supportMarkdown', { ns: 'appDebug' })}</span>
|
||||
</div>
|
||||
<div className="relative h-20 rounded-lg bg-components-input-bg-normal px-3 py-2">
|
||||
<textarea
|
||||
{/* Keep this counter composed locally; extract only if more textarea counter cases repeat. */}
|
||||
<div className="relative h-20">
|
||||
<Textarea
|
||||
aria-label={t('feature.moderation.modal.content.preset', { ns: 'appDebug' }) as string}
|
||||
value={config.preset_response || ''}
|
||||
className="block size-full resize-none appearance-none bg-transparent text-sm text-text-secondary outline-hidden"
|
||||
className="size-full resize-none pb-8"
|
||||
placeholder={t('feature.moderation.modal.content.placeholder', { ns: 'appDebug' }) || ''}
|
||||
onChange={e => handleConfigChange('preset_response', e.target.value)}
|
||||
onValueChange={value => handleConfigChange('preset_response', value)}
|
||||
/>
|
||||
<div className="absolute right-2 bottom-2 flex h-5 items-center rounded-md bg-background-section px-1 text-xs font-medium text-text-quaternary">
|
||||
<span>{(config.preset_response || '').length}</span>
|
||||
|
||||
@ -1,9 +1,10 @@
|
||||
import type { ChangeEvent, FC } from 'react'
|
||||
import type { FC } from 'react'
|
||||
import type { CodeBasedExtensionItem } from '@/models/common'
|
||||
import type { ModerationConfig, ModerationContentConfig } from '@/models/debug'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog'
|
||||
import { Textarea } from '@langgenius/dify-ui/textarea'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@ -103,9 +104,7 @@ const ModerationSettingModal: FC<ModerationSettingModalProps> = ({
|
||||
})
|
||||
}
|
||||
|
||||
const handleDataKeywordsChange = (e: ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const value = e.target.value
|
||||
|
||||
const handleDataKeywordsChange = (value: string) => {
|
||||
const arr = value.split('\n').reduce((prev: string[], next: string) => {
|
||||
if (next !== '')
|
||||
prev.push(next.slice(0, 100))
|
||||
@ -292,11 +291,13 @@ const ModerationSettingModal: FC<ModerationSettingModalProps> = ({
|
||||
<div className="py-2">
|
||||
<div className="mb-1 text-sm font-medium text-text-primary">{t('feature.moderation.modal.provider.keywords', { ns: 'appDebug' })}</div>
|
||||
<div className="mb-2 text-xs text-text-tertiary">{t('feature.moderation.modal.keywords.tip', { ns: 'appDebug' })}</div>
|
||||
<div className="relative h-[88px] rounded-lg bg-components-input-bg-normal px-3 py-2">
|
||||
<textarea
|
||||
{/* Keep this counter composed locally; extract only if more textarea counter cases repeat. */}
|
||||
<div className="relative h-[88px]">
|
||||
<Textarea
|
||||
aria-label={t('feature.moderation.modal.provider.keywords', { ns: 'appDebug' }) as string}
|
||||
value={localeData.config?.keywords || ''}
|
||||
onChange={handleDataKeywordsChange}
|
||||
className="block size-full resize-none appearance-none bg-transparent text-sm text-text-secondary outline-hidden"
|
||||
onValueChange={handleDataKeywordsChange}
|
||||
className="size-full resize-none pb-8"
|
||||
placeholder={t('feature.moderation.modal.keywords.placeholder', { ns: 'appDebug' }) || ''}
|
||||
/>
|
||||
<div className="absolute right-2 bottom-2 flex h-5 items-center rounded-md bg-background-section px-1 text-xs font-medium text-text-quaternary">
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import type { ComponentProps } from 'react'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import TextAreaField from '../text-area'
|
||||
|
||||
@ -30,4 +31,20 @@ describe('TextAreaField', () => {
|
||||
fireEvent.change(screen.getByLabelText('Note'), { target: { value: 'Updated note' } })
|
||||
expect(mockField.handleChange).toHaveBeenCalledWith('Updated note')
|
||||
})
|
||||
|
||||
it('should keep form writeback when external props contain onValueChange', () => {
|
||||
const externalOnValueChange = vi.fn()
|
||||
|
||||
render(
|
||||
<TextAreaField
|
||||
label="Note"
|
||||
{...({ onValueChange: externalOnValueChange } as Partial<ComponentProps<typeof TextAreaField>>)}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.change(screen.getByLabelText('Note'), { target: { value: 'Updated note' } })
|
||||
|
||||
expect(mockField.handleChange).toHaveBeenCalledWith('Updated note')
|
||||
expect(externalOnValueChange).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,16 +1,16 @@
|
||||
import type { TextareaProps } from '../../../textarea'
|
||||
import type { TextareaProps } from '@langgenius/dify-ui/textarea'
|
||||
import type { LabelProps } from '../label'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Textarea } from '@langgenius/dify-ui/textarea'
|
||||
import * as React from 'react'
|
||||
import { useFieldContext } from '../..'
|
||||
import Textarea from '../../../textarea'
|
||||
import Label from '../label'
|
||||
|
||||
type TextAreaFieldProps = {
|
||||
label: string
|
||||
labelOptions?: Omit<LabelProps, 'htmlFor' | 'label'>
|
||||
className?: string
|
||||
} & Omit<TextareaProps, 'className' | 'onChange' | 'onBlur' | 'value' | 'id'>
|
||||
} & Omit<TextareaProps, 'className' | 'defaultValue' | 'onBlur' | 'onValueChange' | 'value' | 'id'>
|
||||
|
||||
const TextAreaField = ({
|
||||
label,
|
||||
@ -28,11 +28,11 @@ const TextAreaField = ({
|
||||
{...(labelOptions ?? {})}
|
||||
/>
|
||||
<Textarea
|
||||
{...inputProps}
|
||||
id={field.name}
|
||||
value={field.state.value}
|
||||
onChange={e => field.handleChange(e.target.value)}
|
||||
onValueChange={value => field.handleChange(value)}
|
||||
onBlur={field.handleBlur}
|
||||
{...inputProps}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
@ -3,6 +3,7 @@ import type { Dayjs } from 'dayjs'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { Checkbox } from '@langgenius/dify-ui/checkbox'
|
||||
import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger, SelectValue } from '@langgenius/dify-ui/select'
|
||||
import { Textarea } from '@langgenius/dify-ui/textarea'
|
||||
import * as React from 'react'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { useChatContext } from '@/app/components/base/chat/chat/context'
|
||||
@ -10,7 +11,6 @@ import DatePicker from '@/app/components/base/date-and-time-picker/date-picker'
|
||||
import TimePicker from '@/app/components/base/date-and-time-picker/time-picker'
|
||||
import { formatDateForOutput, toDayjs } from '@/app/components/base/date-and-time-picker/utils/dayjs'
|
||||
import Input from '@/app/components/base/input'
|
||||
import Textarea from '@/app/components/base/textarea'
|
||||
|
||||
const DATA_FORMAT = {
|
||||
TEXT: 'text',
|
||||
@ -372,11 +372,12 @@ const MarkdownForm = ({ node }: { node: HastElement }) => {
|
||||
return null
|
||||
return (
|
||||
<Textarea
|
||||
aria-label={name}
|
||||
key={key}
|
||||
name={name}
|
||||
placeholder={str(child.properties.placeholder)}
|
||||
value={str(formValues[name])}
|
||||
onChange={e => updateValue(name, e.target.value)}
|
||||
onValueChange={value => updateValue(name, value)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@ -2,12 +2,12 @@
|
||||
import type { FC } from 'react'
|
||||
import type { ValueSelector, Var } from '@/app/components/workflow/types'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Textarea } from '@langgenius/dify-ui/textarea'
|
||||
import * as React from 'react'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Trans, useTranslation } from 'react-i18next'
|
||||
import VarReferencePicker from '@/app/components/workflow/nodes/_base/components/variable/var-reference-picker'
|
||||
import { VarType } from '@/app/components/workflow/types'
|
||||
import Textarea from '../../../textarea'
|
||||
import TagLabel from './tag-label'
|
||||
import TypeSwitch from './type-switch'
|
||||
|
||||
@ -72,6 +72,7 @@ const PrePopulate: FC<Props> = ({
|
||||
value,
|
||||
onValueChange,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const [onPlaceholderClicked, setOnPlaceholderClicked] = useState(false)
|
||||
const handleTypeChange = useCallback((isVar: boolean) => {
|
||||
setOnPlaceholderClicked(true)
|
||||
@ -127,9 +128,10 @@ const PrePopulate: FC<Props> = ({
|
||||
return (
|
||||
<div className={cn('relative min-h-[80px] rounded-lg border border-transparent bg-components-input-bg-normal pb-1', isFocus && 'border-components-input-border-active bg-components-input-bg-active shadow-xs')}>
|
||||
<Textarea
|
||||
aria-label={t(`${i18nPrefix}.staticContent`, { ns: 'workflow' })}
|
||||
value={value || ''}
|
||||
className="h-[43px] min-h-[43px] rounded-none border-none bg-transparent px-3 hover:bg-transparent focus:bg-transparent focus:shadow-none"
|
||||
onChange={e => onValueChange?.(e.target.value)}
|
||||
onValueChange={value => onValueChange?.(value)}
|
||||
onFocus={() => {
|
||||
setOnPlaceholderClicked(true)
|
||||
setIsFocus(true)
|
||||
|
||||
@ -1,77 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import TextArea from '../index'
|
||||
|
||||
describe('TextArea', () => {
|
||||
it('should render correctly with default props', () => {
|
||||
render(<TextArea value="" onChange={vi.fn()} />)
|
||||
const textarea = screen.getByTestId('text-area')
|
||||
expect(textarea).toBeInTheDocument()
|
||||
expect(textarea).toHaveValue('')
|
||||
})
|
||||
|
||||
it('should handle value and onChange correctly', async () => {
|
||||
const user = userEvent.setup()
|
||||
const handleChange = vi.fn()
|
||||
const { rerender } = render(<TextArea value="initial" onChange={handleChange} />)
|
||||
const textarea = screen.getByTestId('text-area')
|
||||
expect(textarea).toHaveValue('initial')
|
||||
|
||||
await user.type(textarea, ' updated')
|
||||
expect(handleChange).toHaveBeenCalled()
|
||||
|
||||
rerender(<TextArea value="initial updated" onChange={handleChange} />)
|
||||
expect(textarea).toHaveValue('initial updated')
|
||||
})
|
||||
|
||||
it('should handle autoFocus correctly', () => {
|
||||
render(<TextArea value="" onChange={vi.fn()} autoFocus />)
|
||||
const textarea = screen.getByTestId('text-area')
|
||||
expect(textarea).toHaveFocus()
|
||||
})
|
||||
|
||||
it('should handle disabled state', () => {
|
||||
render(<TextArea value="" onChange={vi.fn()} disabled />)
|
||||
const textarea = screen.getByTestId('text-area')
|
||||
expect(textarea).toBeDisabled()
|
||||
expect(textarea).toHaveClass('cursor-not-allowed')
|
||||
})
|
||||
|
||||
it('should handle placeholder', () => {
|
||||
render(<TextArea value="" onChange={vi.fn()} placeholder="Enter text here" />)
|
||||
expect(screen.getByPlaceholderText('Enter text here')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle className', () => {
|
||||
render(<TextArea value="" onChange={vi.fn()} className="custom-class" />)
|
||||
expect(screen.getByTestId('text-area')).toHaveClass('custom-class')
|
||||
})
|
||||
|
||||
it('should handle size variants', () => {
|
||||
const { rerender } = render(<TextArea value="" onChange={vi.fn()} size="small" />)
|
||||
expect(screen.getByTestId('text-area')).toHaveClass('py-1')
|
||||
|
||||
rerender(<TextArea value="" onChange={vi.fn()} size="large" />)
|
||||
expect(screen.getByTestId('text-area')).toHaveClass('px-4')
|
||||
})
|
||||
|
||||
it('should handle destructive state', () => {
|
||||
render(<TextArea value="" onChange={vi.fn()} destructive />)
|
||||
expect(screen.getByTestId('text-area')).toHaveClass('border-components-input-border-destructive')
|
||||
})
|
||||
|
||||
it('should handle onFocus and onBlur', async () => {
|
||||
const user = userEvent.setup()
|
||||
const handleFocus = vi.fn()
|
||||
const handleBlur = vi.fn()
|
||||
render(<TextArea value="" onChange={vi.fn()} onFocus={handleFocus} onBlur={handleBlur} />)
|
||||
const textarea = screen.getByTestId('text-area')
|
||||
|
||||
await user.click(textarea)
|
||||
expect(handleFocus).toHaveBeenCalled()
|
||||
|
||||
await user.tab()
|
||||
expect(handleBlur).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@ -1,562 +0,0 @@
|
||||
import type { Meta, StoryObj } from '@storybook/nextjs-vite'
|
||||
import { useState } from 'react'
|
||||
import Textarea from '.'
|
||||
|
||||
const meta = {
|
||||
title: 'Base/Data Entry/Textarea',
|
||||
component: Textarea,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
docs: {
|
||||
description: {
|
||||
component: 'Textarea component with multiple sizes (small, regular, large). Built with class-variance-authority for consistent styling.',
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
argTypes: {
|
||||
size: {
|
||||
control: 'select',
|
||||
options: ['small', 'regular', 'large'],
|
||||
description: 'Textarea size',
|
||||
},
|
||||
value: {
|
||||
control: 'text',
|
||||
description: 'Textarea value',
|
||||
},
|
||||
placeholder: {
|
||||
control: 'text',
|
||||
description: 'Placeholder text',
|
||||
},
|
||||
disabled: {
|
||||
control: 'boolean',
|
||||
description: 'Disabled state',
|
||||
},
|
||||
destructive: {
|
||||
control: 'boolean',
|
||||
description: 'Error/destructive state',
|
||||
},
|
||||
rows: {
|
||||
control: 'number',
|
||||
description: 'Number of visible text rows',
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof Textarea>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
// Interactive demo wrapper
|
||||
const TextareaDemo = (args: any) => {
|
||||
const [value, setValue] = useState(args.value || '')
|
||||
|
||||
return (
|
||||
<div style={{ width: '500px' }}>
|
||||
<Textarea
|
||||
{...args}
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
setValue(e.target.value)
|
||||
console.log('Textarea changed:', e.target.value)
|
||||
}}
|
||||
/>
|
||||
{value && (
|
||||
<div className="mt-3 text-sm text-gray-600">
|
||||
Character count:
|
||||
{' '}
|
||||
<span className="font-semibold">{value.length}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Default state
|
||||
export const Default: Story = {
|
||||
render: args => <TextareaDemo {...args} />,
|
||||
args: {
|
||||
size: 'regular',
|
||||
placeholder: 'Enter text...',
|
||||
rows: 4,
|
||||
value: '',
|
||||
},
|
||||
}
|
||||
|
||||
// Small size
|
||||
export const SmallSize: Story = {
|
||||
render: args => <TextareaDemo {...args} />,
|
||||
args: {
|
||||
size: 'small',
|
||||
placeholder: 'Small textarea...',
|
||||
rows: 3,
|
||||
value: '',
|
||||
},
|
||||
}
|
||||
|
||||
// Large size
|
||||
export const LargeSize: Story = {
|
||||
render: args => <TextareaDemo {...args} />,
|
||||
args: {
|
||||
size: 'large',
|
||||
placeholder: 'Large textarea...',
|
||||
rows: 5,
|
||||
value: '',
|
||||
},
|
||||
}
|
||||
|
||||
// With initial value
|
||||
export const WithInitialValue: Story = {
|
||||
render: args => <TextareaDemo {...args} />,
|
||||
args: {
|
||||
size: 'regular',
|
||||
value: 'This is some initial text content.\n\nIt spans multiple lines.',
|
||||
rows: 4,
|
||||
},
|
||||
}
|
||||
|
||||
// Disabled state
|
||||
export const Disabled: Story = {
|
||||
render: args => <TextareaDemo {...args} />,
|
||||
args: {
|
||||
size: 'regular',
|
||||
value: 'This textarea is disabled and cannot be edited.',
|
||||
disabled: true,
|
||||
rows: 3,
|
||||
},
|
||||
}
|
||||
|
||||
// Destructive/error state
|
||||
export const DestructiveState: Story = {
|
||||
render: args => <TextareaDemo {...args} />,
|
||||
args: {
|
||||
size: 'regular',
|
||||
value: 'This content has an error.',
|
||||
destructive: true,
|
||||
rows: 3,
|
||||
},
|
||||
}
|
||||
|
||||
// Size comparison
|
||||
const SizeComparisonDemo = () => {
|
||||
const [small, setSmall] = useState('')
|
||||
const [regular, setRegular] = useState('')
|
||||
const [large, setLarge] = useState('')
|
||||
|
||||
return (
|
||||
<div style={{ width: '600px' }} className="space-y-4">
|
||||
<div>
|
||||
<label className="mb-2 block text-xs font-medium text-gray-600">Small</label>
|
||||
<Textarea
|
||||
size="small"
|
||||
value={small}
|
||||
onChange={e => setSmall(e.target.value)}
|
||||
placeholder="Small textarea..."
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-2 block text-xs font-medium text-gray-600">Regular</label>
|
||||
<Textarea
|
||||
size="regular"
|
||||
value={regular}
|
||||
onChange={e => setRegular(e.target.value)}
|
||||
placeholder="Regular textarea..."
|
||||
rows={4}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-2 block text-xs font-medium text-gray-600">Large</label>
|
||||
<Textarea
|
||||
size="large"
|
||||
value={large}
|
||||
onChange={e => setLarge(e.target.value)}
|
||||
placeholder="Large textarea..."
|
||||
rows={5}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const SizeComparison: Story = {
|
||||
render: () => <SizeComparisonDemo />,
|
||||
parameters: { controls: { disable: true } },
|
||||
} as unknown as Story
|
||||
|
||||
// State comparison
|
||||
const StateComparisonDemo = () => {
|
||||
const [normal, setNormal] = useState('Normal state')
|
||||
const [error, setError] = useState('Error state')
|
||||
|
||||
return (
|
||||
<div style={{ width: '500px' }} className="space-y-4">
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700">Normal</label>
|
||||
<Textarea
|
||||
value={normal}
|
||||
onChange={e => setNormal(e.target.value)}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700">Destructive</label>
|
||||
<Textarea
|
||||
value={error}
|
||||
onChange={e => setError(e.target.value)}
|
||||
destructive
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700">Disabled</label>
|
||||
<Textarea
|
||||
value="Disabled state"
|
||||
onChange={() => undefined}
|
||||
disabled
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const StateComparison: Story = {
|
||||
render: () => <StateComparisonDemo />,
|
||||
parameters: { controls: { disable: true } },
|
||||
} as unknown as Story
|
||||
|
||||
// Real-world example - Comment form
|
||||
const CommentFormDemo = () => {
|
||||
const [comment, setComment] = useState('')
|
||||
const maxLength = 500
|
||||
|
||||
return (
|
||||
<div style={{ width: '600px' }} className="rounded-lg border border-gray-200 bg-white p-6">
|
||||
<h3 className="mb-4 text-lg font-semibold">Leave a Comment</h3>
|
||||
<Textarea
|
||||
value={comment}
|
||||
onChange={e => setComment(e.target.value)}
|
||||
placeholder="Share your thoughts..."
|
||||
rows={5}
|
||||
maxLength={maxLength}
|
||||
/>
|
||||
<div className="mt-2 flex items-center justify-between">
|
||||
<span className="text-xs text-gray-500">
|
||||
{comment.length}
|
||||
{' '}
|
||||
/
|
||||
{maxLength}
|
||||
{' '}
|
||||
characters
|
||||
</span>
|
||||
<button
|
||||
className="rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
disabled={comment.trim().length === 0}
|
||||
>
|
||||
Post Comment
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const CommentForm: Story = {
|
||||
render: () => <CommentFormDemo />,
|
||||
parameters: { controls: { disable: true } },
|
||||
} as unknown as Story
|
||||
|
||||
// Real-world example - Feedback form
|
||||
const FeedbackFormDemo = () => {
|
||||
const [feedback, setFeedback] = useState('')
|
||||
const [email, setEmail] = useState('')
|
||||
|
||||
return (
|
||||
<div style={{ width: '600px' }} className="rounded-lg border border-gray-200 bg-white p-6">
|
||||
<h3 className="mb-2 text-lg font-semibold">Send Feedback</h3>
|
||||
<p className="mb-4 text-sm text-gray-600">Help us improve our product</p>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700">Your Email</label>
|
||||
<input
|
||||
type="email"
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm"
|
||||
value={email}
|
||||
onChange={e => setEmail(e.target.value)}
|
||||
placeholder="email@example.com"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700">Your Feedback</label>
|
||||
<Textarea
|
||||
value={feedback}
|
||||
onChange={e => setFeedback(e.target.value)}
|
||||
placeholder="Tell us what you think..."
|
||||
rows={6}
|
||||
/>
|
||||
</div>
|
||||
<button className="w-full rounded-lg bg-green-600 px-4 py-2 text-sm font-medium text-white hover:bg-green-700">
|
||||
Submit Feedback
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const FeedbackForm: Story = {
|
||||
render: () => <FeedbackFormDemo />,
|
||||
parameters: { controls: { disable: true } },
|
||||
} as unknown as Story
|
||||
|
||||
// Real-world example - Code snippet
|
||||
const CodeSnippetDemo = () => {
|
||||
const [code, setCode] = useState(`function hello() {
|
||||
console.log("Hello, world!");
|
||||
}`)
|
||||
|
||||
return (
|
||||
<div style={{ width: '600px' }} className="rounded-lg border border-gray-200 bg-white p-6">
|
||||
<h3 className="mb-4 text-lg font-semibold">Code Editor</h3>
|
||||
<Textarea
|
||||
value={code}
|
||||
onChange={e => setCode(e.target.value)}
|
||||
className="font-mono"
|
||||
rows={8}
|
||||
/>
|
||||
<div className="mt-4 flex gap-2">
|
||||
<button className="rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700">
|
||||
Run Code
|
||||
</button>
|
||||
<button className="rounded-lg bg-gray-200 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-300">
|
||||
Copy
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const CodeSnippet: Story = {
|
||||
render: () => <CodeSnippetDemo />,
|
||||
parameters: { controls: { disable: true } },
|
||||
} as unknown as Story
|
||||
|
||||
// Real-world example - Message composer
|
||||
const MessageComposerDemo = () => {
|
||||
const [message, setMessage] = useState('')
|
||||
|
||||
return (
|
||||
<div style={{ width: '600px' }} className="rounded-lg border border-gray-200 bg-white p-6">
|
||||
<h3 className="mb-4 text-lg font-semibold">Compose Message</h3>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700">To</label>
|
||||
<input
|
||||
type="text"
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm"
|
||||
placeholder="Recipient name"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700">Subject</label>
|
||||
<input
|
||||
type="text"
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm"
|
||||
placeholder="Message subject"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700">Message</label>
|
||||
<Textarea
|
||||
value={message}
|
||||
onChange={e => setMessage(e.target.value)}
|
||||
placeholder="Type your message here..."
|
||||
rows={8}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button className="rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700">
|
||||
Send Message
|
||||
</button>
|
||||
<button className="rounded-lg bg-gray-200 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-300">
|
||||
Save Draft
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const MessageComposer: Story = {
|
||||
render: () => <MessageComposerDemo />,
|
||||
parameters: { controls: { disable: true } },
|
||||
} as unknown as Story
|
||||
|
||||
// Real-world example - Bio editor
|
||||
const BioEditorDemo = () => {
|
||||
const [bio, setBio] = useState('Software developer passionate about building great products.')
|
||||
const maxLength = 200
|
||||
|
||||
return (
|
||||
<div style={{ width: '600px' }} className="rounded-lg border border-gray-200 bg-white p-6">
|
||||
<h3 className="mb-4 text-lg font-semibold">Edit Your Bio</h3>
|
||||
<Textarea
|
||||
value={bio}
|
||||
onChange={e => setBio(e.target.value.slice(0, maxLength))}
|
||||
placeholder="Tell us about yourself..."
|
||||
rows={4}
|
||||
/>
|
||||
<div className="mt-2 flex items-center justify-between text-xs">
|
||||
<span className={bio.length > maxLength * 0.9 ? 'text-orange-600' : 'text-gray-500'}>
|
||||
{bio.length}
|
||||
{' '}
|
||||
/
|
||||
{maxLength}
|
||||
{' '}
|
||||
characters
|
||||
</span>
|
||||
{bio.length > maxLength * 0.9 && (
|
||||
<span className="text-orange-600">
|
||||
{maxLength - bio.length}
|
||||
{' '}
|
||||
characters remaining
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-4 rounded-lg bg-gray-50 p-4">
|
||||
<div className="mb-2 text-xs font-medium text-gray-600">Preview:</div>
|
||||
<p className="text-sm text-gray-800">{bio || 'Your bio will appear here...'}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const BioEditor: Story = {
|
||||
render: () => <BioEditorDemo />,
|
||||
parameters: { controls: { disable: true } },
|
||||
} as unknown as Story
|
||||
|
||||
// Real-world example - JSON editor
|
||||
const JSONEditorDemo = () => {
|
||||
const [json, setJson] = useState(`{
|
||||
"name": "John Doe",
|
||||
"age": 30,
|
||||
"email": "john@example.com"
|
||||
}`)
|
||||
const [isValid, setIsValid] = useState(true)
|
||||
|
||||
const validateJSON = (value: string) => {
|
||||
try {
|
||||
JSON.parse(value)
|
||||
setIsValid(true)
|
||||
}
|
||||
catch {
|
||||
setIsValid(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ width: '600px' }} className="rounded-lg border border-gray-200 bg-white p-6">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold">JSON Editor</h3>
|
||||
<span className={`rounded-sm px-2 py-1 text-xs ${isValid ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'}`}>
|
||||
{isValid ? '✓ Valid' : '✗ Invalid'}
|
||||
</span>
|
||||
</div>
|
||||
<Textarea
|
||||
value={json}
|
||||
onChange={(e) => {
|
||||
setJson(e.target.value)
|
||||
validateJSON(e.target.value)
|
||||
}}
|
||||
className="font-mono"
|
||||
destructive={!isValid}
|
||||
rows={10}
|
||||
/>
|
||||
<div className="mt-4 flex gap-2">
|
||||
<button className="rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50" disabled={!isValid}>
|
||||
Save JSON
|
||||
</button>
|
||||
<button
|
||||
className="rounded-lg bg-gray-200 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-300"
|
||||
onClick={() => {
|
||||
try {
|
||||
const formatted = JSON.stringify(JSON.parse(json), null, 2)
|
||||
setJson(formatted)
|
||||
}
|
||||
catch {
|
||||
// Invalid JSON, do nothing
|
||||
}
|
||||
}}
|
||||
>
|
||||
Format
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const JSONEditor: Story = {
|
||||
render: () => <JSONEditorDemo />,
|
||||
parameters: { controls: { disable: true } },
|
||||
} as unknown as Story
|
||||
|
||||
// Real-world example - Task description
|
||||
const TaskDescriptionDemo = () => {
|
||||
const [title, setTitle] = useState('Implement user authentication')
|
||||
const [description, setDescription] = useState('Add login and registration functionality with JWT tokens.')
|
||||
|
||||
return (
|
||||
<div style={{ width: '600px' }} className="rounded-lg border border-gray-200 bg-white p-6">
|
||||
<h3 className="mb-4 text-lg font-semibold">Create New Task</h3>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700">Task Title</label>
|
||||
<input
|
||||
type="text"
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm"
|
||||
value={title}
|
||||
onChange={e => setTitle(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700">Description</label>
|
||||
<Textarea
|
||||
value={description}
|
||||
onChange={e => setDescription(e.target.value)}
|
||||
placeholder="Describe the task in detail..."
|
||||
rows={6}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700">Priority</label>
|
||||
<select className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm">
|
||||
<option>Low</option>
|
||||
<option>Medium</option>
|
||||
<option>High</option>
|
||||
<option>Urgent</option>
|
||||
</select>
|
||||
</div>
|
||||
<button className="w-full rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700">
|
||||
Create Task
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const TaskDescription: Story = {
|
||||
render: () => <TaskDescriptionDemo />,
|
||||
parameters: { controls: { disable: true } },
|
||||
} as unknown as Story
|
||||
|
||||
// Interactive playground
|
||||
export const Playground: Story = {
|
||||
render: args => <TextareaDemo {...args} />,
|
||||
args: {
|
||||
size: 'regular',
|
||||
placeholder: 'Enter text...',
|
||||
rows: 4,
|
||||
disabled: false,
|
||||
destructive: false,
|
||||
value: '',
|
||||
},
|
||||
}
|
||||
@ -1,60 +0,0 @@
|
||||
import type { VariantProps } from 'class-variance-authority'
|
||||
import type { CSSProperties } from 'react'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { cva } from 'class-variance-authority'
|
||||
import * as React from 'react'
|
||||
|
||||
const textareaVariants = cva(
|
||||
'',
|
||||
{
|
||||
variants: {
|
||||
size: {
|
||||
small: 'rounded-md py-1 system-xs-regular',
|
||||
regular: 'rounded-md px-3 system-sm-regular',
|
||||
large: 'rounded-lg px-4 system-md-regular',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
size: 'regular',
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
export type TextareaProps = {
|
||||
value: string | number
|
||||
disabled?: boolean
|
||||
destructive?: boolean
|
||||
styleCss?: CSSProperties
|
||||
ref?: React.Ref<HTMLTextAreaElement>
|
||||
onFocus?: React.FocusEventHandler<HTMLTextAreaElement>
|
||||
onBlur?: React.FocusEventHandler<HTMLTextAreaElement>
|
||||
} & React.TextareaHTMLAttributes<HTMLTextAreaElement> & VariantProps<typeof textareaVariants>
|
||||
|
||||
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||
({ className, value, onChange, disabled, size, destructive, styleCss, onFocus, onBlur, ...props }, ref) => {
|
||||
return (
|
||||
<textarea
|
||||
ref={ref}
|
||||
onFocus={onFocus}
|
||||
onBlur={onBlur}
|
||||
style={styleCss}
|
||||
className={cn(
|
||||
'min-h-20 w-full appearance-none border border-transparent bg-components-input-bg-normal p-2 text-components-input-text-filled caret-primary-600 outline-hidden placeholder:text-components-input-text-placeholder hover:border-components-input-border-hover hover:bg-components-input-bg-hover focus:border-components-input-border-active focus:bg-components-input-bg-active focus:shadow-xs',
|
||||
textareaVariants({ size }),
|
||||
disabled && 'cursor-not-allowed border-transparent bg-components-input-bg-disabled text-components-input-text-filled-disabled hover:border-transparent hover:bg-components-input-bg-disabled',
|
||||
destructive && 'border-components-input-border-destructive bg-components-input-bg-destructive text-components-input-text-filled hover:border-components-input-border-destructive hover:bg-components-input-bg-destructive focus:border-components-input-border-destructive focus:bg-components-input-bg-destructive',
|
||||
className,
|
||||
)}
|
||||
value={value ?? ''}
|
||||
onChange={onChange}
|
||||
disabled={disabled}
|
||||
data-testid="text-area"
|
||||
{...props}
|
||||
>
|
||||
</textarea>
|
||||
)
|
||||
},
|
||||
)
|
||||
Textarea.displayName = 'Textarea'
|
||||
|
||||
export default Textarea
|
||||
@ -25,6 +25,7 @@ const AppsFull: FC<{ loc: string, className?: string }> = ({
|
||||
const total = plan.total.buildApps
|
||||
const percent = total > 0 ? (usage / total) * 100 : 0
|
||||
const tone: MeterTone = percent >= 80 ? 'error' : percent >= 50 ? 'warning' : 'neutral'
|
||||
const buildAppsLabel = t('usagePage.buildApps', { ns: 'billing' })
|
||||
return (
|
||||
<div className={cn(
|
||||
'flex flex-col gap-3 rounded-xl border-[0.5px] border-components-panel-border-subtle bg-components-panel-on-panel-item-bg p-4 shadow-xs backdrop-blur-xs',
|
||||
@ -61,14 +62,14 @@ const AppsFull: FC<{ loc: string, className?: string }> = ({
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between system-xs-medium text-text-secondary">
|
||||
<div>{t('usagePage.buildApps', { ns: 'billing' })}</div>
|
||||
<div>{buildAppsLabel}</div>
|
||||
<div>
|
||||
{usage}
|
||||
/
|
||||
{total}
|
||||
</div>
|
||||
</div>
|
||||
<MeterRoot value={Math.min(percent, 100)} max={100}>
|
||||
<MeterRoot value={Math.min(percent, 100)} max={100} aria-label={buildAppsLabel}>
|
||||
<MeterTrack>
|
||||
<MeterIndicator tone={tone} />
|
||||
</MeterTrack>
|
||||
|
||||
@ -229,7 +229,7 @@ describe('UsageInfo', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('meter')).toBeInTheDocument()
|
||||
expect(screen.getByRole('meter', { name: 'Storage' })).toBeInTheDocument()
|
||||
expect(container.querySelector('[aria-hidden="true"]')).toBeNull()
|
||||
})
|
||||
|
||||
@ -270,7 +270,7 @@ describe('UsageInfo', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('meter')).toBeInTheDocument()
|
||||
expect(screen.getByRole('meter', { name: 'Storage' })).toBeInTheDocument()
|
||||
expect(container.querySelector('[aria-hidden="true"]')).toBeNull()
|
||||
})
|
||||
|
||||
|
||||
@ -144,13 +144,13 @@ const UsageInfo: FC<Props> = ({
|
||||
<div
|
||||
className={cn(
|
||||
'h-1 rounded-md bg-progress-bar-indeterminate-stripe',
|
||||
isSandboxPlan ? 'w-full' : 'w-[30px]',
|
||||
isSandboxPlan ? 'w-full' : 'w-7.5',
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<MeterRoot value={effectivePercent} max={100}>
|
||||
<MeterRoot value={effectivePercent} max={100} aria-label={name}>
|
||||
<MeterTrack>
|
||||
<MeterIndicator tone={tone} />
|
||||
</MeterTrack>
|
||||
@ -162,7 +162,7 @@ const UsageInfo: FC<Props> = ({
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger render={<div className="cursor-default">{children}</div>} />
|
||||
<TooltipContent className="w-[200px] max-w-[200px]">
|
||||
<TooltipContent className="w-50 max-w-50">
|
||||
{storageTooltip}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import type { AppIconSelection } from '@/app/components/base/app-icon-picker'
|
||||
import type { PipelineTemplate } from '@/models/pipeline'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { Textarea } from '@langgenius/dify-ui/textarea'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { RiCloseLine } from '@remixicon/react'
|
||||
import * as React from 'react'
|
||||
@ -9,7 +10,6 @@ import { useTranslation } from 'react-i18next'
|
||||
import AppIcon from '@/app/components/base/app-icon'
|
||||
import AppIconPicker from '@/app/components/base/app-icon-picker'
|
||||
import Input from '@/app/components/base/input'
|
||||
import Textarea from '@/app/components/base/textarea'
|
||||
import { useInvalidCustomizedTemplateList, useUpdateTemplateInfo } from '@/service/use-pipeline'
|
||||
|
||||
type EditPipelineInfoProps = {
|
||||
@ -45,8 +45,7 @@ const EditPipelineInfo = ({
|
||||
setAppIcon(icon)
|
||||
}, [])
|
||||
|
||||
const handleDescriptionChange = useCallback((event: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const value = event.target.value
|
||||
const handleDescriptionChange = useCallback((value: string) => {
|
||||
setDescription(value)
|
||||
}, [])
|
||||
|
||||
@ -121,7 +120,8 @@ const EditPipelineInfo = ({
|
||||
{t('knowledgeDescription', { ns: 'datasetPipeline' })}
|
||||
</label>
|
||||
<Textarea
|
||||
onChange={handleDescriptionChange}
|
||||
aria-label={t('knowledgeDescription', { ns: 'datasetPipeline' })}
|
||||
onValueChange={handleDescriptionChange}
|
||||
value={description}
|
||||
placeholder={t('knowledgeDescriptionPlaceholder', { ns: 'datasetPipeline' })}
|
||||
/>
|
||||
|
||||
@ -244,6 +244,15 @@ describe('DatasetCard Component', () => {
|
||||
expect(mockPush).toHaveBeenCalledWith('/datasets/dataset-1/documents')
|
||||
})
|
||||
|
||||
it('should not change background color on hover', () => {
|
||||
const dataset = createMockDataset()
|
||||
render(<DatasetCard dataset={dataset} />)
|
||||
const card = screen.getByText('Test Dataset').closest('[data-disable-nprogress]')
|
||||
|
||||
expect(card).toHaveClass('bg-components-card-bg')
|
||||
expect(card).not.toHaveClass('hover:bg-components-card-bg-alt')
|
||||
})
|
||||
|
||||
it('should navigate to hitTesting for external provider', () => {
|
||||
const dataset = createMockDataset({ provider: 'external' })
|
||||
render(<DatasetCard dataset={dataset} />)
|
||||
|
||||
@ -63,7 +63,7 @@ const DatasetCard = ({
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="group relative col-span-1 flex h-[190px] cursor-pointer flex-col rounded-xl border-[0.5px] border-solid border-components-card-border bg-components-card-bg shadow-xs shadow-shadow-shadow-3 transition-all duration-200 ease-in-out hover:bg-components-card-bg-alt hover:shadow-md hover:shadow-shadow-shadow-5"
|
||||
className="group relative col-span-1 flex h-47.5 cursor-pointer flex-col rounded-xl border-[0.5px] border-solid border-components-card-border bg-components-card-bg shadow-xs shadow-shadow-shadow-3 transition-all duration-200 ease-in-out hover:shadow-md hover:shadow-shadow-shadow-5"
|
||||
data-disable-nprogress={true}
|
||||
onClick={handleCardClick}
|
||||
>
|
||||
|
||||
@ -5,12 +5,12 @@ import type { DataSet } from '@/models/datasets'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog'
|
||||
import { Textarea } from '@langgenius/dify-ui/textarea'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { RiCloseLine } from '@remixicon/react'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Input from '@/app/components/base/input'
|
||||
import Textarea from '@/app/components/base/textarea'
|
||||
import { updateDatasetSetting } from '@/service/datasets'
|
||||
import AppIcon from '../../base/app-icon'
|
||||
import AppIconPicker from '../../base/app-icon-picker'
|
||||
@ -108,7 +108,7 @@ const RenameDatasetModal = ({ show, dataset, onSuccess, onClose }: RenameDataset
|
||||
{t('form.desc', { ns: 'datasetSettings' })}
|
||||
</div>
|
||||
<div className="w-full">
|
||||
<Textarea value={description} onChange={e => setDescription(e.target.value)} className="resize-none" placeholder={t('form.descPlaceholder', { ns: 'datasetSettings' }) || ''} />
|
||||
<Textarea aria-label={t('form.desc', { ns: 'datasetSettings' })} value={description} onValueChange={value => setDescription(value)} className="resize-none" placeholder={t('form.descPlaceholder', { ns: 'datasetSettings' }) || ''} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -3,11 +3,11 @@ import type { AppIconSelection } from '@/app/components/base/app-icon-picker'
|
||||
import type { Member } from '@/models/common'
|
||||
import type { DataSet, DatasetPermission, IconInfo } from '@/models/datasets'
|
||||
import type { AppIconType } from '@/types/app'
|
||||
import { Textarea } from '@langgenius/dify-ui/textarea'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import AppIcon from '@/app/components/base/app-icon'
|
||||
import AppIconPicker from '@/app/components/base/app-icon-picker'
|
||||
import Input from '@/app/components/base/input'
|
||||
import Textarea from '@/app/components/base/textarea'
|
||||
import PermissionSelector from '../../permission-selector'
|
||||
|
||||
const rowClass = 'flex gap-x-1'
|
||||
@ -85,11 +85,12 @@ const BasicInfoSection = ({
|
||||
</div>
|
||||
<div className="grow">
|
||||
<Textarea
|
||||
aria-label={t('form.desc', { ns: 'datasetSettings' })}
|
||||
disabled={!currentDataset?.embedding_available}
|
||||
className="resize-none"
|
||||
placeholder={t('form.descPlaceholder', { ns: 'datasetSettings' }) || ''}
|
||||
value={description}
|
||||
onChange={e => setDescription(e.target.value)}
|
||||
onValueChange={value => setDescription(value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import type { ChangeEvent } from 'react'
|
||||
import type { DefaultModel } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import type { SummaryIndexSetting as SummaryIndexSettingType } from '@/models/datasets'
|
||||
import { Switch } from '@langgenius/dify-ui/switch'
|
||||
import { Textarea } from '@langgenius/dify-ui/textarea'
|
||||
import {
|
||||
memo,
|
||||
useCallback,
|
||||
@ -9,7 +9,6 @@ import {
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Infotip } from '@/app/components/base/infotip'
|
||||
import Textarea from '@/app/components/base/textarea'
|
||||
import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import { useModelList } from '@/app/components/header/account-setting/model-provider-page/hooks'
|
||||
import ModelSelector from '@/app/components/header/account-setting/model-provider-page/model-selector'
|
||||
@ -53,9 +52,9 @@ const SummaryIndexSetting = ({
|
||||
})
|
||||
}, [onSummaryIndexSettingChange])
|
||||
|
||||
const handleSummaryIndexPromptChange = useCallback((e: ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const handleSummaryIndexPromptChange = useCallback((value: string) => {
|
||||
onSummaryIndexSettingChange?.({
|
||||
summary_prompt: e.target.value,
|
||||
summary_prompt: value,
|
||||
})
|
||||
}, [onSummaryIndexSettingChange])
|
||||
|
||||
@ -95,8 +94,9 @@ const SummaryIndexSetting = ({
|
||||
{t('form.summaryInstructions', { ns: 'datasetSettings' })}
|
||||
</div>
|
||||
<Textarea
|
||||
aria-label={t('form.summaryInstructions', { ns: 'datasetSettings' })}
|
||||
value={summaryIndexSetting?.summary_prompt ?? ''}
|
||||
onChange={handleSummaryIndexPromptChange}
|
||||
onValueChange={handleSummaryIndexPromptChange}
|
||||
disabled={readonly}
|
||||
placeholder={t('form.summaryInstructionsPlaceholder', { ns: 'datasetSettings' })}
|
||||
/>
|
||||
@ -166,8 +166,9 @@ const SummaryIndexSetting = ({
|
||||
</div>
|
||||
<div className="grow">
|
||||
<Textarea
|
||||
aria-label={t('form.summaryInstructions', { ns: 'datasetSettings' })}
|
||||
value={summaryIndexSetting?.summary_prompt ?? ''}
|
||||
onChange={handleSummaryIndexPromptChange}
|
||||
onValueChange={handleSummaryIndexPromptChange}
|
||||
disabled={readonly}
|
||||
placeholder={t('form.summaryInstructionsPlaceholder', { ns: 'datasetSettings' })}
|
||||
/>
|
||||
@ -214,8 +215,9 @@ const SummaryIndexSetting = ({
|
||||
{t('form.summaryInstructions', { ns: 'datasetSettings' })}
|
||||
</div>
|
||||
<Textarea
|
||||
aria-label={t('form.summaryInstructions', { ns: 'datasetSettings' })}
|
||||
value={summaryIndexSetting?.summary_prompt ?? ''}
|
||||
onChange={handleSummaryIndexPromptChange}
|
||||
onValueChange={handleSummaryIndexPromptChange}
|
||||
disabled={readonly}
|
||||
placeholder={t('form.summaryInstructionsPlaceholder', { ns: 'datasetSettings' })}
|
||||
/>
|
||||
|
||||
@ -3,6 +3,7 @@ import type { AppIconType } from '@/types/app'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { Dialog, DialogCloseButton, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog'
|
||||
import { Switch } from '@langgenius/dify-ui/switch'
|
||||
import { Textarea } from '@langgenius/dify-ui/textarea'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { useDebounceFn, useKeyPress } from 'ahooks'
|
||||
import * as React from 'react'
|
||||
@ -10,7 +11,6 @@ import { useCallback, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import AppIcon from '@/app/components/base/app-icon'
|
||||
import Input from '@/app/components/base/input'
|
||||
import Textarea from '@/app/components/base/textarea'
|
||||
import AppsFull from '@/app/components/billing/apps-full-in-dialog'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
@ -145,10 +145,11 @@ const CreateAppModal = ({
|
||||
<div className="pt-2">
|
||||
<div className="py-2 text-sm leading-[20px] font-medium text-text-primary">{t('newApp.captionDescription', { ns: 'app' })}</div>
|
||||
<Textarea
|
||||
aria-label={t('newApp.captionDescription', { ns: 'app' })}
|
||||
className="resize-none"
|
||||
placeholder={t('newApp.appDescriptionPlaceholder', { ns: 'app' }) || ''}
|
||||
value={description}
|
||||
onChange={e => setDescription(e.target.value)}
|
||||
onValueChange={value => setDescription(value)}
|
||||
/>
|
||||
</div>
|
||||
{/* answer icon */}
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select'
|
||||
import { Textarea } from '@langgenius/dify-ui/textarea'
|
||||
import { useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { FileUploaderInAttachmentWrapper } from '@/app/components/base/file-uploader'
|
||||
import Input from '@/app/components/base/input'
|
||||
import Textarea from '@/app/components/base/textarea'
|
||||
import { InputVarType } from '@/app/components/workflow/types'
|
||||
|
||||
type Props = {
|
||||
@ -55,8 +55,9 @@ const AppInputsForm = ({
|
||||
if (form.type === InputVarType.paragraph) {
|
||||
return (
|
||||
<Textarea
|
||||
aria-label={label}
|
||||
value={inputs[variable] || ''}
|
||||
onChange={e => handleFormChange(variable, e.target.value)}
|
||||
onValueChange={value => handleFormChange(variable, value)}
|
||||
placeholder={label}
|
||||
/>
|
||||
)
|
||||
|
||||
@ -523,9 +523,7 @@ describe('useToolSelectorState Hook', () => {
|
||||
)
|
||||
|
||||
act(() => {
|
||||
result.current.handleDescriptionChange({
|
||||
target: { value: 'new description' },
|
||||
} as React.ChangeEvent<HTMLTextAreaElement>)
|
||||
result.current.handleDescriptionChange('new description')
|
||||
})
|
||||
|
||||
expect(onSelect).toHaveBeenCalledWith(
|
||||
@ -1724,9 +1722,7 @@ describe('Edge Cases', () => {
|
||||
)
|
||||
|
||||
act(() => {
|
||||
result.current.handleDescriptionChange({
|
||||
target: { value: '' },
|
||||
} as React.ChangeEvent<HTMLTextAreaElement>)
|
||||
result.current.handleDescriptionChange('')
|
||||
})
|
||||
|
||||
expect(onSelect).toHaveBeenCalledWith(
|
||||
|
||||
@ -1,24 +1,6 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('@/app/components/base/textarea', () => ({
|
||||
default: ({ value, onChange, disabled, placeholder }: {
|
||||
value?: string
|
||||
onChange?: (e: React.ChangeEvent<HTMLTextAreaElement>) => void
|
||||
disabled?: boolean
|
||||
placeholder?: string
|
||||
}) => (
|
||||
<textarea
|
||||
data-testid="description-textarea"
|
||||
value={value || ''}
|
||||
onChange={onChange}
|
||||
disabled={disabled}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('../../../../readme-panel/entrance', () => ({
|
||||
ReadmeEntrance: () => <div data-testid="readme-entrance" />,
|
||||
}))
|
||||
@ -68,28 +50,28 @@ describe('ToolBaseForm', () => {
|
||||
it('should render description textarea', () => {
|
||||
render(<ToolBaseForm {...defaultProps} />)
|
||||
|
||||
expect(screen.getByTestId('description-textarea')).toBeInTheDocument()
|
||||
expect(screen.getByRole('textbox')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should disable textarea when no provider_name in value', () => {
|
||||
render(<ToolBaseForm {...defaultProps} />)
|
||||
|
||||
expect(screen.getByTestId('description-textarea')).toBeDisabled()
|
||||
expect(screen.getByRole('textbox')).toBeDisabled()
|
||||
})
|
||||
|
||||
it('should enable textarea when value has provider_name', () => {
|
||||
const value = { provider_name: 'test-provider', tool_name: 'test', extra: { description: 'Hello' } } as never
|
||||
render(<ToolBaseForm {...defaultProps} value={value} />)
|
||||
|
||||
expect(screen.getByTestId('description-textarea')).not.toBeDisabled()
|
||||
expect(screen.getByRole('textbox')).not.toBeDisabled()
|
||||
})
|
||||
|
||||
it('should call onDescriptionChange when textarea content changes', () => {
|
||||
const value = { provider_name: 'test-provider', tool_name: 'test', extra: { description: 'Hello' } } as never
|
||||
render(<ToolBaseForm {...defaultProps} value={value} />)
|
||||
|
||||
fireEvent.change(screen.getByTestId('description-textarea'), { target: { value: 'Updated' } })
|
||||
expect(mockOnDescriptionChange).toHaveBeenCalled()
|
||||
fireEvent.change(screen.getByRole('textbox'), { target: { value: 'Updated' } })
|
||||
expect(mockOnDescriptionChange).toHaveBeenCalledWith('Updated', expect.any(Object))
|
||||
})
|
||||
|
||||
it('should show ReadmeEntrance when provider has plugin_unique_identifier', () => {
|
||||
|
||||
@ -4,8 +4,8 @@ import type { FC } from 'react'
|
||||
import type { PluginDetail } from '@/app/components/plugins/types'
|
||||
import type { ToolDefaultValue, ToolValue } from '@/app/components/workflow/block-selector/types'
|
||||
import type { ToolWithProvider } from '@/app/components/workflow/types'
|
||||
import { Textarea } from '@langgenius/dify-ui/textarea'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Textarea from '@/app/components/base/textarea'
|
||||
import ToolPicker from '@/app/components/workflow/block-selector/tool-picker'
|
||||
import { ReadmeEntrance } from '../../../readme-panel/entrance'
|
||||
import ToolTrigger from './tool-trigger'
|
||||
@ -23,7 +23,7 @@ type ToolBaseFormProps = {
|
||||
onPanelShowStateChange?: (state: boolean) => void
|
||||
onSelectTool: (tool: ToolDefaultValue) => void
|
||||
onSelectMultipleTool: (tools: ToolDefaultValue[]) => void
|
||||
onDescriptionChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => void
|
||||
onDescriptionChange: (value: string) => void
|
||||
}
|
||||
|
||||
const ToolBaseForm: FC<ToolBaseFormProps> = ({
|
||||
@ -85,9 +85,10 @@ const ToolBaseForm: FC<ToolBaseFormProps> = ({
|
||||
</div>
|
||||
<Textarea
|
||||
className="resize-none"
|
||||
aria-label={t('detailPanel.toolSelector.descriptionLabel', { ns: 'plugin' })}
|
||||
placeholder={t('detailPanel.toolSelector.descriptionPlaceholder', { ns: 'plugin' })}
|
||||
value={value?.extra?.description || ''}
|
||||
onChange={onDescriptionChange}
|
||||
onValueChange={onDescriptionChange}
|
||||
disabled={!value?.provider_name}
|
||||
/>
|
||||
</div>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user