mirror of https://github.com/langgenius/dify.git
Merge 26aa02e452 into 09be869f58
This commit is contained in:
commit
e1b27e2cf9
|
|
@ -9,10 +9,11 @@ from sqlalchemy.orm import Session
|
|||
|
||||
from core.agent.entities import AgentToolEntity
|
||||
from core.agent.plugin_entities import AgentStrategyParameter
|
||||
from core.file import File, FileTransferMethod
|
||||
from core.file import File, FileTransferMethod, FileType, file_manager
|
||||
from core.memory.token_buffer_memory import TokenBufferMemory
|
||||
from core.model_manager import ModelInstance, ModelManager
|
||||
from core.model_runtime.entities.llm_entities import LLMUsage, LLMUsageMetadata
|
||||
from core.model_runtime.entities.message_entities import TextPromptMessageContent
|
||||
from core.model_runtime.entities.model_entities import AIModelEntity, ModelType
|
||||
from core.model_runtime.utils.encoders import jsonable_encoder
|
||||
from core.provider_manager import ProviderManager
|
||||
|
|
@ -24,7 +25,7 @@ from core.tools.entities.tool_entities import (
|
|||
)
|
||||
from core.tools.tool_manager import ToolManager
|
||||
from core.tools.utils.message_transformer import ToolFileMessageTransformer
|
||||
from core.variables.segments import ArrayFileSegment, StringSegment
|
||||
from core.variables.segments import ArrayFileSegment, FileSegment, StringSegment
|
||||
from core.workflow.enums import (
|
||||
NodeType,
|
||||
SystemVariableKey,
|
||||
|
|
@ -160,6 +161,22 @@ class AgentNode(Node[AgentNodeData]):
|
|||
)
|
||||
)
|
||||
|
||||
def _fetch_files_from_variable_selector(
|
||||
self,
|
||||
*,
|
||||
variable_pool: VariablePool,
|
||||
selector: Sequence[str],
|
||||
) -> Sequence[File]:
|
||||
"""Fetch files from a variable selector."""
|
||||
variable = variable_pool.get(list(selector))
|
||||
if variable is None:
|
||||
return []
|
||||
elif isinstance(variable, FileSegment):
|
||||
return [variable.value]
|
||||
elif isinstance(variable, ArrayFileSegment):
|
||||
return variable.value
|
||||
return []
|
||||
|
||||
def _generate_agent_parameters(
|
||||
self,
|
||||
*,
|
||||
|
|
@ -206,11 +223,52 @@ class AgentNode(Node[AgentNodeData]):
|
|||
except TypeError:
|
||||
parameter_value = str(agent_input.value)
|
||||
segment_group = variable_pool.convert_template(parameter_value)
|
||||
parameter_value = segment_group.log if for_log else segment_group.text
|
||||
|
||||
if parameter_name in ("query", "instruction") and not for_log:
|
||||
contents: list[dict[str, Any]] = []
|
||||
has_file = False
|
||||
vision_detail = node_data.vision.configs.detail if node_data.vision.enabled else None
|
||||
|
||||
for segment in segment_group.value:
|
||||
if isinstance(segment, (ArrayFileSegment, FileSegment)):
|
||||
files = segment.value if isinstance(segment, ArrayFileSegment) else [segment.value]
|
||||
for file in files:
|
||||
if file.type in {FileType.IMAGE, FileType.VIDEO, FileType.AUDIO, FileType.DOCUMENT}:
|
||||
file_content = file_manager.to_prompt_message_content(
|
||||
file, image_detail_config=vision_detail
|
||||
)
|
||||
contents.append(file_content.model_dump())
|
||||
has_file = True
|
||||
else:
|
||||
text = segment.text
|
||||
if text:
|
||||
contents.append(TextPromptMessageContent(data=text).model_dump())
|
||||
|
||||
if parameter_name == "query":
|
||||
if node_data.vision.enabled and node_data.vision.configs.variable_selector:
|
||||
vision_files = self._fetch_files_from_variable_selector(
|
||||
variable_pool=variable_pool,
|
||||
selector=node_data.vision.configs.variable_selector,
|
||||
)
|
||||
for file in vision_files:
|
||||
if file.type in {FileType.IMAGE, FileType.VIDEO, FileType.AUDIO, FileType.DOCUMENT}:
|
||||
file_content = file_manager.to_prompt_message_content(
|
||||
file, image_detail_config=vision_detail
|
||||
)
|
||||
contents.append(file_content.model_dump())
|
||||
has_file = True
|
||||
|
||||
if has_file:
|
||||
parameter_value = contents
|
||||
else:
|
||||
parameter_value = segment_group.text
|
||||
else:
|
||||
parameter_value = segment_group.log if for_log else segment_group.text
|
||||
|
||||
# variable_pool.convert_template returns a string,
|
||||
# so we need to convert it back to a dictionary
|
||||
try:
|
||||
if not isinstance(agent_input.value, str):
|
||||
if not isinstance(agent_input.value, str) and isinstance(parameter_value, str):
|
||||
parameter_value = json.loads(parameter_value)
|
||||
except json.JSONDecodeError:
|
||||
parameter_value = parameter_value
|
||||
|
|
|
|||
|
|
@ -1,18 +1,38 @@
|
|||
from collections.abc import Sequence
|
||||
from enum import IntEnum, StrEnum, auto
|
||||
from typing import Any, Literal, Union
|
||||
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
from core.model_runtime.entities import ImagePromptMessageContent
|
||||
from core.prompt.entities.advanced_prompt_entities import MemoryConfig
|
||||
from core.tools.entities.tool_entities import ToolSelector
|
||||
from core.workflow.nodes.base.entities import BaseNodeData
|
||||
|
||||
|
||||
class VisionConfigOptions(BaseModel):
|
||||
variable_selector: Sequence[str] = Field(default_factory=lambda: ["sys", "files"])
|
||||
detail: ImagePromptMessageContent.DETAIL = ImagePromptMessageContent.DETAIL.HIGH
|
||||
|
||||
|
||||
class VisionConfig(BaseModel):
|
||||
enabled: bool = False
|
||||
configs: VisionConfigOptions = Field(default_factory=VisionConfigOptions)
|
||||
|
||||
@field_validator("configs", mode="before")
|
||||
@classmethod
|
||||
def convert_none_configs(cls, v: Any):
|
||||
if v is None:
|
||||
return VisionConfigOptions()
|
||||
return v
|
||||
|
||||
|
||||
class AgentNodeData(BaseNodeData):
|
||||
agent_strategy_provider_name: str # redundancy
|
||||
agent_strategy_name: str
|
||||
agent_strategy_label: str # redundancy
|
||||
memory: MemoryConfig | None = None
|
||||
vision: VisionConfig = Field(default_factory=VisionConfig)
|
||||
# The version of the tool parameter.
|
||||
# If this value is None, it indicates this is a previous version
|
||||
# and requires using the legacy parameter parsing rules.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,467 @@
|
|||
"""Unit tests for AgentNode file handling."""
|
||||
|
||||
import sys
|
||||
import types
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from core.workflow.nodes.agent.agent_node import AgentNode
|
||||
|
||||
|
||||
def _setup_stubs(monkeypatch):
|
||||
"""Set up stubs for circular import issues."""
|
||||
module_name = "core.ops.ops_trace_manager"
|
||||
if module_name not in sys.modules:
|
||||
ops_stub = types.ModuleType(module_name)
|
||||
ops_stub.TraceQueueManager = object
|
||||
ops_stub.TraceTask = object
|
||||
monkeypatch.setitem(sys.modules, module_name, ops_stub)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def agent_imports(monkeypatch) -> dict[str, Any]:
|
||||
"""Set up stubs and return imported modules."""
|
||||
_setup_stubs(monkeypatch)
|
||||
|
||||
from core.agent.plugin_entities import AgentStrategyParameter
|
||||
from core.file import File, FileTransferMethod, FileType, file_manager
|
||||
from core.model_runtime.entities.message_entities import ImagePromptMessageContent
|
||||
from core.variables import FileSegment, StringSegment
|
||||
from core.workflow.nodes.agent.agent_node import AgentNode
|
||||
from core.workflow.nodes.agent.entities import AgentNodeData, VisionConfig, VisionConfigOptions
|
||||
from core.workflow.runtime.variable_pool import VariablePool
|
||||
|
||||
return {
|
||||
"AgentNode": AgentNode,
|
||||
"AgentNodeData": AgentNodeData,
|
||||
"VisionConfig": VisionConfig,
|
||||
"VisionConfigOptions": VisionConfigOptions,
|
||||
"VariablePool": VariablePool,
|
||||
"AgentStrategyParameter": AgentStrategyParameter,
|
||||
"File": File,
|
||||
"FileTransferMethod": FileTransferMethod,
|
||||
"FileType": FileType,
|
||||
"file_manager": file_manager,
|
||||
"ImagePromptMessageContent": ImagePromptMessageContent,
|
||||
"FileSegment": FileSegment,
|
||||
"StringSegment": StringSegment,
|
||||
}
|
||||
|
||||
|
||||
class TestAgentNodeFileHandling:
|
||||
"""Tests for file handling in query, instruction, and vision variable selector."""
|
||||
|
||||
@pytest.fixture
|
||||
def mock_file(self, agent_imports):
|
||||
"""Create a mock file."""
|
||||
File = agent_imports["File"]
|
||||
FileType = agent_imports["FileType"]
|
||||
FileTransferMethod = agent_imports["FileTransferMethod"]
|
||||
return File(
|
||||
id="test-file-id",
|
||||
tenant_id="test-tenant",
|
||||
type=FileType.IMAGE,
|
||||
transfer_method=FileTransferMethod.LOCAL_FILE,
|
||||
related_id="test-related-id",
|
||||
filename="test.png",
|
||||
extension=".png",
|
||||
mime_type="image/png",
|
||||
size=1024,
|
||||
)
|
||||
|
||||
@pytest.fixture
|
||||
def mock_custom_file(self, agent_imports):
|
||||
"""Create a mock custom (unsupported) file."""
|
||||
File = agent_imports["File"]
|
||||
FileType = agent_imports["FileType"]
|
||||
FileTransferMethod = agent_imports["FileTransferMethod"]
|
||||
return File(
|
||||
id="test-custom-id",
|
||||
tenant_id="test-tenant",
|
||||
type=FileType.CUSTOM,
|
||||
transfer_method=FileTransferMethod.LOCAL_FILE,
|
||||
related_id="test-related-id",
|
||||
filename="test.zip",
|
||||
extension=".zip",
|
||||
mime_type="application/zip",
|
||||
size=4096,
|
||||
)
|
||||
|
||||
@pytest.fixture
|
||||
def mock_strategy(self, agent_imports) -> MagicMock:
|
||||
"""Create a mock agent strategy."""
|
||||
AgentStrategyParameter = agent_imports["AgentStrategyParameter"]
|
||||
strategy = MagicMock()
|
||||
strategy.get_parameters.return_value = [
|
||||
AgentStrategyParameter(
|
||||
name="query",
|
||||
type=AgentStrategyParameter.AgentStrategyParameterType.STRING,
|
||||
required=True,
|
||||
label={"en_US": "Query"},
|
||||
),
|
||||
AgentStrategyParameter(
|
||||
name="instruction",
|
||||
type=AgentStrategyParameter.AgentStrategyParameterType.STRING,
|
||||
required=False,
|
||||
label={"en_US": "Instruction"},
|
||||
),
|
||||
]
|
||||
return strategy
|
||||
|
||||
@pytest.fixture
|
||||
def base_node_data(self, agent_imports) -> dict:
|
||||
"""Create base node data for tests."""
|
||||
VisionConfig = agent_imports["VisionConfig"]
|
||||
return {
|
||||
"title": "Test Agent",
|
||||
"agent_strategy_provider_name": "test-provider",
|
||||
"agent_strategy_name": "test-strategy",
|
||||
"agent_strategy_label": "Test Strategy",
|
||||
"agent_parameters": {},
|
||||
"vision": VisionConfig(enabled=False),
|
||||
}
|
||||
|
||||
def _create_agent_node(self, agent_imports) -> "AgentNode":
|
||||
"""Create an AgentNode instance for testing."""
|
||||
AgentNode = agent_imports["AgentNode"]
|
||||
node = object.__new__(AgentNode)
|
||||
node.tenant_id = "test-tenant"
|
||||
node.app_id = "test-app"
|
||||
return node
|
||||
|
||||
def test_query_with_text_only_returns_string(self, agent_imports, mock_strategy, base_node_data):
|
||||
"""When query contains only text, it should return a string."""
|
||||
# Arrange
|
||||
VariablePool = agent_imports["VariablePool"]
|
||||
AgentNodeData = agent_imports["AgentNodeData"]
|
||||
StringSegment = agent_imports["StringSegment"]
|
||||
|
||||
variable_pool = VariablePool()
|
||||
variable_pool.add(["node1", "text_var"], StringSegment(value="Hello, world!"))
|
||||
|
||||
base_node_data["agent_parameters"] = {
|
||||
"query": AgentNodeData.AgentInput(
|
||||
type="mixed",
|
||||
value="{{#node1.text_var#}}",
|
||||
),
|
||||
}
|
||||
node_data = AgentNodeData.model_validate(base_node_data)
|
||||
agent_parameters = mock_strategy.get_parameters()
|
||||
|
||||
agent_node = self._create_agent_node(agent_imports)
|
||||
|
||||
# Act
|
||||
result = agent_node._generate_agent_parameters(
|
||||
agent_parameters=agent_parameters,
|
||||
variable_pool=variable_pool,
|
||||
node_data=node_data,
|
||||
for_log=False,
|
||||
strategy=mock_strategy,
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert result["query"] == "Hello, world!"
|
||||
assert isinstance(result["query"], str)
|
||||
|
||||
def test_query_with_file_returns_list(self, agent_imports, mock_file, mock_strategy, base_node_data):
|
||||
"""When query contains a file, it should return a list."""
|
||||
# Arrange
|
||||
VariablePool = agent_imports["VariablePool"]
|
||||
AgentNodeData = agent_imports["AgentNodeData"]
|
||||
FileSegment = agent_imports["FileSegment"]
|
||||
file_manager = agent_imports["file_manager"]
|
||||
ImagePromptMessageContent = agent_imports["ImagePromptMessageContent"]
|
||||
|
||||
variable_pool = VariablePool()
|
||||
variable_pool.add(["node1", "file_var"], FileSegment(value=mock_file))
|
||||
|
||||
base_node_data["agent_parameters"] = {
|
||||
"query": AgentNodeData.AgentInput(
|
||||
type="mixed",
|
||||
value="{{#node1.file_var#}}",
|
||||
),
|
||||
}
|
||||
node_data = AgentNodeData.model_validate(base_node_data)
|
||||
agent_parameters = mock_strategy.get_parameters()
|
||||
|
||||
agent_node = self._create_agent_node(agent_imports)
|
||||
|
||||
with patch.object(file_manager, "to_prompt_message_content") as mock_to_content:
|
||||
mock_to_content.return_value = ImagePromptMessageContent(
|
||||
url="http://example.com/test.png", mime_type="image/png", format="png"
|
||||
)
|
||||
|
||||
# Act
|
||||
result = agent_node._generate_agent_parameters(
|
||||
agent_parameters=agent_parameters,
|
||||
variable_pool=variable_pool,
|
||||
node_data=node_data,
|
||||
for_log=False,
|
||||
strategy=mock_strategy,
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert isinstance(result["query"], list)
|
||||
assert len(result["query"]) == 1
|
||||
assert result["query"][0]["type"] == "image"
|
||||
|
||||
def test_query_with_text_and_file_returns_list_with_both(
|
||||
self, agent_imports, mock_file, mock_strategy, base_node_data
|
||||
):
|
||||
"""When query contains both text and file, it should return a list with both."""
|
||||
# Arrange
|
||||
VariablePool = agent_imports["VariablePool"]
|
||||
AgentNodeData = agent_imports["AgentNodeData"]
|
||||
FileSegment = agent_imports["FileSegment"]
|
||||
file_manager = agent_imports["file_manager"]
|
||||
ImagePromptMessageContent = agent_imports["ImagePromptMessageContent"]
|
||||
|
||||
variable_pool = VariablePool()
|
||||
variable_pool.add(["node1", "file_var"], FileSegment(value=mock_file))
|
||||
|
||||
base_node_data["agent_parameters"] = {
|
||||
"query": AgentNodeData.AgentInput(
|
||||
type="mixed",
|
||||
value="Describe this: {{#node1.file_var#}}",
|
||||
),
|
||||
}
|
||||
node_data = AgentNodeData.model_validate(base_node_data)
|
||||
agent_parameters = mock_strategy.get_parameters()
|
||||
|
||||
agent_node = self._create_agent_node(agent_imports)
|
||||
|
||||
with patch.object(file_manager, "to_prompt_message_content") as mock_to_content:
|
||||
mock_to_content.return_value = ImagePromptMessageContent(
|
||||
url="http://example.com/test.png", mime_type="image/png", format="png"
|
||||
)
|
||||
|
||||
# Act
|
||||
result = agent_node._generate_agent_parameters(
|
||||
agent_parameters=agent_parameters,
|
||||
variable_pool=variable_pool,
|
||||
node_data=node_data,
|
||||
for_log=False,
|
||||
strategy=mock_strategy,
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert isinstance(result["query"], list)
|
||||
assert len(result["query"]) == 2
|
||||
assert result["query"][0]["type"] == "text"
|
||||
assert result["query"][1]["type"] == "image"
|
||||
|
||||
def test_custom_file_type_is_ignored(self, agent_imports, mock_custom_file, mock_strategy, base_node_data):
|
||||
"""Custom file types should be ignored and return text only."""
|
||||
# Arrange
|
||||
VariablePool = agent_imports["VariablePool"]
|
||||
AgentNodeData = agent_imports["AgentNodeData"]
|
||||
FileSegment = agent_imports["FileSegment"]
|
||||
|
||||
variable_pool = VariablePool()
|
||||
variable_pool.add(["node1", "file_var"], FileSegment(value=mock_custom_file))
|
||||
|
||||
base_node_data["agent_parameters"] = {
|
||||
"query": AgentNodeData.AgentInput(
|
||||
type="mixed",
|
||||
value="{{#node1.file_var#}}",
|
||||
),
|
||||
}
|
||||
node_data = AgentNodeData.model_validate(base_node_data)
|
||||
agent_parameters = mock_strategy.get_parameters()
|
||||
|
||||
agent_node = self._create_agent_node(agent_imports)
|
||||
|
||||
# Act
|
||||
result = agent_node._generate_agent_parameters(
|
||||
agent_parameters=agent_parameters,
|
||||
variable_pool=variable_pool,
|
||||
node_data=node_data,
|
||||
for_log=False,
|
||||
strategy=mock_strategy,
|
||||
)
|
||||
|
||||
# Assert
|
||||
# Custom file types are ignored, so result should be the text representation
|
||||
assert isinstance(result["query"], str)
|
||||
|
||||
def test_instruction_with_file_returns_list(self, agent_imports, mock_file, mock_strategy, base_node_data):
|
||||
"""When instruction contains a file, it should return a list (same as query)."""
|
||||
# Arrange
|
||||
VariablePool = agent_imports["VariablePool"]
|
||||
AgentNodeData = agent_imports["AgentNodeData"]
|
||||
FileSegment = agent_imports["FileSegment"]
|
||||
file_manager = agent_imports["file_manager"]
|
||||
ImagePromptMessageContent = agent_imports["ImagePromptMessageContent"]
|
||||
|
||||
variable_pool = VariablePool()
|
||||
variable_pool.add(["node1", "file_var"], FileSegment(value=mock_file))
|
||||
|
||||
base_node_data["agent_parameters"] = {
|
||||
"instruction": AgentNodeData.AgentInput(
|
||||
type="mixed",
|
||||
value="You are a helpful assistant. {{#node1.file_var#}}",
|
||||
),
|
||||
}
|
||||
node_data = AgentNodeData.model_validate(base_node_data)
|
||||
agent_parameters = mock_strategy.get_parameters()
|
||||
|
||||
agent_node = self._create_agent_node(agent_imports)
|
||||
|
||||
with patch.object(file_manager, "to_prompt_message_content") as mock_to_content:
|
||||
mock_to_content.return_value = ImagePromptMessageContent(
|
||||
url="http://example.com/test.png", mime_type="image/png", format="png"
|
||||
)
|
||||
|
||||
# Act
|
||||
result = agent_node._generate_agent_parameters(
|
||||
agent_parameters=agent_parameters,
|
||||
variable_pool=variable_pool,
|
||||
node_data=node_data,
|
||||
for_log=False,
|
||||
strategy=mock_strategy,
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert isinstance(result["instruction"], list)
|
||||
assert len(result["instruction"]) == 2
|
||||
assert result["instruction"][0]["type"] == "text"
|
||||
assert result["instruction"][1]["type"] == "image"
|
||||
|
||||
def test_vision_variable_selector_files_added_to_query(
|
||||
self, agent_imports, mock_file, mock_strategy, base_node_data
|
||||
):
|
||||
"""Vision variable selector files should be added to query only."""
|
||||
# Arrange
|
||||
VariablePool = agent_imports["VariablePool"]
|
||||
AgentNodeData = agent_imports["AgentNodeData"]
|
||||
StringSegment = agent_imports["StringSegment"]
|
||||
FileSegment = agent_imports["FileSegment"]
|
||||
VisionConfig = agent_imports["VisionConfig"]
|
||||
VisionConfigOptions = agent_imports["VisionConfigOptions"]
|
||||
file_manager = agent_imports["file_manager"]
|
||||
ImagePromptMessageContent = agent_imports["ImagePromptMessageContent"]
|
||||
|
||||
variable_pool = VariablePool()
|
||||
variable_pool.add(["node1", "text_var"], StringSegment(value="Describe this image"))
|
||||
variable_pool.add(["sys", "files"], FileSegment(value=mock_file))
|
||||
|
||||
base_node_data["agent_parameters"] = {
|
||||
"query": AgentNodeData.AgentInput(
|
||||
type="mixed",
|
||||
value="{{#node1.text_var#}}",
|
||||
),
|
||||
}
|
||||
base_node_data["vision"] = VisionConfig(
|
||||
enabled=True,
|
||||
configs=VisionConfigOptions(
|
||||
variable_selector=["sys", "files"],
|
||||
),
|
||||
)
|
||||
node_data = AgentNodeData.model_validate(base_node_data)
|
||||
agent_parameters = mock_strategy.get_parameters()
|
||||
|
||||
agent_node = self._create_agent_node(agent_imports)
|
||||
|
||||
with patch.object(file_manager, "to_prompt_message_content") as mock_to_content:
|
||||
mock_to_content.return_value = ImagePromptMessageContent(
|
||||
url="http://example.com/test.png", mime_type="image/png", format="png"
|
||||
)
|
||||
|
||||
# Act
|
||||
result = agent_node._generate_agent_parameters(
|
||||
agent_parameters=agent_parameters,
|
||||
variable_pool=variable_pool,
|
||||
node_data=node_data,
|
||||
for_log=False,
|
||||
strategy=mock_strategy,
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert isinstance(result["query"], list)
|
||||
assert len(result["query"]) == 2
|
||||
assert result["query"][0]["type"] == "text"
|
||||
assert result["query"][0]["data"] == "Describe this image"
|
||||
assert result["query"][1]["type"] == "image"
|
||||
|
||||
def test_for_log_returns_text_representation(self, agent_imports, mock_file, mock_strategy, base_node_data):
|
||||
"""When for_log is True, files should be represented as log text."""
|
||||
# Arrange
|
||||
VariablePool = agent_imports["VariablePool"]
|
||||
AgentNodeData = agent_imports["AgentNodeData"]
|
||||
FileSegment = agent_imports["FileSegment"]
|
||||
|
||||
variable_pool = VariablePool()
|
||||
variable_pool.add(["node1", "file_var"], FileSegment(value=mock_file))
|
||||
|
||||
base_node_data["agent_parameters"] = {
|
||||
"query": AgentNodeData.AgentInput(
|
||||
type="mixed",
|
||||
value="{{#node1.file_var#}}",
|
||||
),
|
||||
}
|
||||
node_data = AgentNodeData.model_validate(base_node_data)
|
||||
agent_parameters = mock_strategy.get_parameters()
|
||||
|
||||
agent_node = self._create_agent_node(agent_imports)
|
||||
|
||||
# Act
|
||||
result = agent_node._generate_agent_parameters(
|
||||
agent_parameters=agent_parameters,
|
||||
variable_pool=variable_pool,
|
||||
node_data=node_data,
|
||||
for_log=True,
|
||||
strategy=mock_strategy,
|
||||
)
|
||||
|
||||
# Assert
|
||||
# for_log=True should return log representation, not a list
|
||||
assert isinstance(result["query"], str)
|
||||
|
||||
def test_non_query_instruction_parameter_returns_text(
|
||||
self, agent_imports, mock_file, mock_strategy, base_node_data
|
||||
):
|
||||
"""Parameters other than query/instruction should return text even with files."""
|
||||
# Arrange
|
||||
AgentStrategyParameter = agent_imports["AgentStrategyParameter"]
|
||||
VariablePool = agent_imports["VariablePool"]
|
||||
AgentNodeData = agent_imports["AgentNodeData"]
|
||||
FileSegment = agent_imports["FileSegment"]
|
||||
|
||||
mock_strategy.get_parameters.return_value = [
|
||||
AgentStrategyParameter(
|
||||
name="other_param",
|
||||
type=AgentStrategyParameter.AgentStrategyParameterType.STRING,
|
||||
required=False,
|
||||
label={"en_US": "Other"},
|
||||
),
|
||||
]
|
||||
|
||||
variable_pool = VariablePool()
|
||||
variable_pool.add(["node1", "file_var"], FileSegment(value=mock_file))
|
||||
|
||||
base_node_data["agent_parameters"] = {
|
||||
"other_param": AgentNodeData.AgentInput(
|
||||
type="mixed",
|
||||
value="{{#node1.file_var#}}",
|
||||
),
|
||||
}
|
||||
node_data = AgentNodeData.model_validate(base_node_data)
|
||||
agent_parameters = mock_strategy.get_parameters()
|
||||
|
||||
agent_node = self._create_agent_node(agent_imports)
|
||||
|
||||
# Act
|
||||
result = agent_node._generate_agent_parameters(
|
||||
agent_parameters=agent_parameters,
|
||||
variable_pool=variable_pool,
|
||||
node_data=node_data,
|
||||
for_log=False,
|
||||
strategy=mock_strategy,
|
||||
)
|
||||
|
||||
# Assert
|
||||
# Non-query/instruction parameters should return text representation
|
||||
assert isinstance(result["other_param"], str)
|
||||
|
|
@ -6,8 +6,10 @@ import type { StrategyParamItem } from '@/app/components/plugins/types'
|
|||
import { memo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toType } from '@/app/components/tools/utils/to-form-schema'
|
||||
import { Resolution } from '@/types/app'
|
||||
import { useStore } from '../../store'
|
||||
import { AgentStrategy } from '../_base/components/agent-strategy'
|
||||
import ConfigVision from '../_base/components/config-vision'
|
||||
import Field from '../_base/components/field'
|
||||
import MemoryConfig from '../_base/components/memory-config'
|
||||
import OutputVars, { VarItem } from '../_base/components/output-vars'
|
||||
|
|
@ -40,6 +42,8 @@ const AgentPanel: FC<NodePanelProps<AgentNodeType>> = (props) => {
|
|||
readOnly,
|
||||
outputSchema,
|
||||
handleMemoryChange,
|
||||
handleVisionEnabledChange,
|
||||
handleVisionConfigChange,
|
||||
canChooseMCPTool,
|
||||
} = useConfig(props.id, props.data)
|
||||
const { t } = useTranslation()
|
||||
|
|
@ -85,12 +89,11 @@ const AgentPanel: FC<NodePanelProps<AgentNodeType>> = (props) => {
|
|||
canChooseMCPTool={canChooseMCPTool}
|
||||
/>
|
||||
</Field>
|
||||
<div className="px-4 py-2">
|
||||
<div className="space-y-4 px-4 py-2">
|
||||
{isChatMode && currentStrategy?.features?.includes(AgentFeature.HISTORY_MESSAGES) && (
|
||||
<>
|
||||
<Split />
|
||||
<MemoryConfig
|
||||
className="mt-4"
|
||||
readonly={readOnly}
|
||||
config={{ data: inputs.memory }}
|
||||
onChange={handleMemoryChange}
|
||||
|
|
@ -98,6 +101,15 @@ const AgentPanel: FC<NodePanelProps<AgentNodeType>> = (props) => {
|
|||
/>
|
||||
</>
|
||||
)}
|
||||
<ConfigVision
|
||||
nodeId={props.id}
|
||||
readOnly={readOnly}
|
||||
isVisionModel={true}
|
||||
enabled={inputs.vision?.enabled || false}
|
||||
onEnabledChange={handleVisionEnabledChange}
|
||||
config={inputs.vision?.configs || { detail: Resolution.high, variable_selector: [] }}
|
||||
onConfigChange={handleVisionConfigChange}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<OutputVars>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,11 @@
|
|||
import type { ToolVarInputs } from '../tool/types'
|
||||
import type { PluginMeta } from '@/app/components/plugins/types'
|
||||
import type { CommonNodeType, Memory } from '@/app/components/workflow/types'
|
||||
import type { CommonNodeType, Memory, VisionSetting } from '@/app/components/workflow/types'
|
||||
|
||||
export type AgentVisionConfig = {
|
||||
enabled: boolean
|
||||
configs?: VisionSetting
|
||||
}
|
||||
|
||||
export type AgentNodeType = CommonNodeType & {
|
||||
agent_strategy_provider_name?: string
|
||||
|
|
@ -11,6 +16,7 @@ export type AgentNodeType = CommonNodeType & {
|
|||
output_schema: Record<string, any>
|
||||
plugin_unique_identifier?: string
|
||||
memory?: Memory
|
||||
vision?: AgentVisionConfig
|
||||
version?: string
|
||||
tool_node_version?: string
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import type { Memory, Var } from '../../types'
|
||||
import type { Memory, Var, VisionSetting } from '../../types'
|
||||
import type { ToolVarInputs } from '../tool/types'
|
||||
import type { AgentNodeType } from './types'
|
||||
import { produce } from 'immer'
|
||||
|
|
@ -11,6 +11,7 @@ import {
|
|||
} from '@/app/components/workflow/hooks'
|
||||
import { useCheckInstalled, useFetchPluginsInMarketPlaceByIds } from '@/service/use-plugins'
|
||||
import { useStrategyProviderDetail } from '@/service/use-strategy'
|
||||
import { Resolution } from '@/types/app'
|
||||
import { isSupportMCP } from '@/utils/plugin-version-feature'
|
||||
import { VarType as VarKindType } from '../../types'
|
||||
import useAvailableVarList from '../_base/hooks/use-available-var-list'
|
||||
|
|
@ -204,7 +205,34 @@ const useConfig = (id: string, payload: AgentNodeType) => {
|
|||
})
|
||||
setInputs(newInputs)
|
||||
}, [inputs, setInputs])
|
||||
|
||||
const isChatMode = useIsChatMode()
|
||||
|
||||
const handleVisionEnabledChange = useCallback((enabled: boolean) => {
|
||||
const newInputs = produce(inputs, (draft) => {
|
||||
if (!draft.vision) {
|
||||
draft.vision = { enabled: false }
|
||||
}
|
||||
draft.vision.enabled = enabled
|
||||
if (enabled && isChatMode) {
|
||||
draft.vision.configs = {
|
||||
detail: Resolution.high,
|
||||
variable_selector: ['sys', 'files'],
|
||||
}
|
||||
}
|
||||
})
|
||||
setInputs(newInputs)
|
||||
}, [inputs, setInputs, isChatMode])
|
||||
|
||||
const handleVisionConfigChange = useCallback((config: VisionSetting) => {
|
||||
const newInputs = produce(inputs, (draft) => {
|
||||
if (!draft.vision) {
|
||||
draft.vision = { enabled: true }
|
||||
}
|
||||
draft.vision.configs = config
|
||||
})
|
||||
setInputs(newInputs)
|
||||
}, [inputs, setInputs])
|
||||
return {
|
||||
readOnly,
|
||||
inputs,
|
||||
|
|
@ -221,6 +249,8 @@ const useConfig = (id: string, payload: AgentNodeType) => {
|
|||
availableNodesWithParent,
|
||||
outputSchema,
|
||||
handleMemoryChange,
|
||||
handleVisionEnabledChange,
|
||||
handleVisionConfigChange,
|
||||
isChatMode,
|
||||
canChooseMCPTool: isSupportMCP(inputs.meta?.version),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1024,6 +1024,7 @@ const translation = {
|
|||
modelSelectorTooltips: {
|
||||
deprecated: 'تم إهمال هذا النموذج',
|
||||
},
|
||||
vision: 'الرؤية',
|
||||
outputVars: {
|
||||
text: 'محتوى تم إنشاؤه بواسطة الوكيل',
|
||||
usage: 'معلومات استخدام النموذج',
|
||||
|
|
|
|||
|
|
@ -901,6 +901,7 @@ const translation = {
|
|||
modelSelectorTooltips: {
|
||||
deprecated: 'Dieses Modell ist veraltet',
|
||||
},
|
||||
vision: 'Vision',
|
||||
outputVars: {
|
||||
files: {
|
||||
type: 'Art der Unterstützung. Jetzt nur noch Image unterstützen',
|
||||
|
|
|
|||
|
|
@ -1024,6 +1024,7 @@ const translation = {
|
|||
modelSelectorTooltips: {
|
||||
deprecated: 'This model is deprecated',
|
||||
},
|
||||
vision: 'vision',
|
||||
outputVars: {
|
||||
text: 'agent generated content',
|
||||
usage: 'Model Usage Information',
|
||||
|
|
|
|||
|
|
@ -901,6 +901,7 @@ const translation = {
|
|||
modelSelectorTooltips: {
|
||||
deprecated: 'Este modelo está en desuso',
|
||||
},
|
||||
vision: 'visión',
|
||||
outputVars: {
|
||||
files: {
|
||||
url: 'URL de la imagen',
|
||||
|
|
|
|||
|
|
@ -901,6 +901,7 @@ const translation = {
|
|||
modelSelectorTooltips: {
|
||||
deprecated: 'این مدل منسوخ شده است',
|
||||
},
|
||||
vision: 'بینایی',
|
||||
outputVars: {
|
||||
files: {
|
||||
transfer_method: 'روش انتقال. ارزش remote_url یا local_file',
|
||||
|
|
|
|||
|
|
@ -901,6 +901,7 @@ const translation = {
|
|||
modelSelectorTooltips: {
|
||||
deprecated: 'Ce modèle est obsolète',
|
||||
},
|
||||
vision: 'vision',
|
||||
outputVars: {
|
||||
files: {
|
||||
title: 'Fichiers générés par l’agent',
|
||||
|
|
|
|||
|
|
@ -921,6 +921,7 @@ const translation = {
|
|||
modelSelectorTooltips: {
|
||||
deprecated: 'यह मॉडल अप्रचलित है।',
|
||||
},
|
||||
vision: 'दृष्टि',
|
||||
outputVars: {
|
||||
files: {
|
||||
transfer_method: 'स्थानांतरण विधि। मान या तो remote_url है या local_file।',
|
||||
|
|
|
|||
|
|
@ -940,6 +940,7 @@ const translation = {
|
|||
modelSelectorTooltips: {
|
||||
deprecated: 'Model ini tidak digunakan lagi',
|
||||
},
|
||||
vision: 'penglihatan',
|
||||
outputVars: {
|
||||
files: {
|
||||
transfer_method: 'Metode transfer. Nilai adalah remote_url atau local_file',
|
||||
|
|
|
|||
|
|
@ -927,6 +927,7 @@ const translation = {
|
|||
modelSelectorTooltips: {
|
||||
deprecated: 'Questo modello è deprecato',
|
||||
},
|
||||
vision: 'vision',
|
||||
outputVars: {
|
||||
files: {
|
||||
type: 'Tipo di supporto. Ora supporta solo l\'immagine',
|
||||
|
|
|
|||
|
|
@ -960,6 +960,7 @@ const translation = {
|
|||
modelSelectorTooltips: {
|
||||
deprecated: 'このモデルは廃止されました',
|
||||
},
|
||||
vision: 'ビジョン',
|
||||
outputVars: {
|
||||
files: {
|
||||
url: '画像の URL',
|
||||
|
|
|
|||
|
|
@ -943,6 +943,7 @@ const translation = {
|
|||
modelSelectorTooltips: {
|
||||
deprecated: '이 모델은 더 이상 사용되지 않습니다.',
|
||||
},
|
||||
vision: '비전',
|
||||
outputVars: {
|
||||
files: {
|
||||
url: '이미지 URL',
|
||||
|
|
|
|||
|
|
@ -901,6 +901,7 @@ const translation = {
|
|||
modelSelectorTooltips: {
|
||||
deprecated: 'Ten model jest przestarzały',
|
||||
},
|
||||
vision: 'wizja',
|
||||
outputVars: {
|
||||
files: {
|
||||
title: 'Pliki generowane przez agenta',
|
||||
|
|
|
|||
|
|
@ -901,6 +901,7 @@ const translation = {
|
|||
modelSelectorTooltips: {
|
||||
deprecated: 'Este modelo está obsoleto',
|
||||
},
|
||||
vision: 'visão',
|
||||
outputVars: {
|
||||
files: {
|
||||
type: 'Tipo de suporte. Agora suporta apenas imagem',
|
||||
|
|
|
|||
|
|
@ -901,6 +901,7 @@ const translation = {
|
|||
modelSelectorTooltips: {
|
||||
deprecated: 'Acest model este învechit',
|
||||
},
|
||||
vision: 'viziune',
|
||||
outputVars: {
|
||||
files: {
|
||||
upload_file_id: 'Încărcați ID-ul fișierului',
|
||||
|
|
|
|||
|
|
@ -901,6 +901,7 @@ const translation = {
|
|||
modelSelectorTooltips: {
|
||||
deprecated: 'Эта модель устарела',
|
||||
},
|
||||
vision: 'зрение',
|
||||
outputVars: {
|
||||
files: {
|
||||
transfer_method: 'Способ переноса. Ценность составляет remote_url или local_file',
|
||||
|
|
|
|||
|
|
@ -940,6 +940,7 @@ const translation = {
|
|||
modelSelectorTooltips: {
|
||||
deprecated: 'Ta model je zastarelo',
|
||||
},
|
||||
vision: 'vizija',
|
||||
outputVars: {
|
||||
files: {
|
||||
type: 'Vrsta podpore. Zdaj podpiramo samo slike.',
|
||||
|
|
|
|||
|
|
@ -901,6 +901,7 @@ const translation = {
|
|||
modelSelectorTooltips: {
|
||||
deprecated: 'โมเดลนี้เลิกใช้แล้ว',
|
||||
},
|
||||
vision: 'การมองเห็น',
|
||||
outputVars: {
|
||||
files: {
|
||||
transfer_method: 'วิธีการโอน ค่าเป็น remote_url หรือ local_file',
|
||||
|
|
|
|||
|
|
@ -901,6 +901,7 @@ const translation = {
|
|||
modelSelectorTooltips: {
|
||||
deprecated: 'Bu model kullanım dışıdır',
|
||||
},
|
||||
vision: 'görsel',
|
||||
outputVars: {
|
||||
files: {
|
||||
upload_file_id: 'Dosya kimliğini karşıya yükle',
|
||||
|
|
|
|||
|
|
@ -901,6 +901,7 @@ const translation = {
|
|||
modelSelectorTooltips: {
|
||||
deprecated: 'Ця модель вважається застарілою',
|
||||
},
|
||||
vision: 'бачення',
|
||||
outputVars: {
|
||||
files: {
|
||||
upload_file_id: 'Завантажити ідентифікатор файлу',
|
||||
|
|
|
|||
|
|
@ -901,6 +901,7 @@ const translation = {
|
|||
modelSelectorTooltips: {
|
||||
deprecated: 'Mô hình này không còn được dùng nữa',
|
||||
},
|
||||
vision: 'tầm nhìn',
|
||||
outputVars: {
|
||||
files: {
|
||||
title: 'Tệp do tác nhân tạo',
|
||||
|
|
|
|||
|
|
@ -980,6 +980,7 @@ const translation = {
|
|||
modelSelectorTooltips: {
|
||||
deprecated: '此模型已弃用',
|
||||
},
|
||||
vision: '视觉',
|
||||
outputVars: {
|
||||
text: 'agent 生成的内容',
|
||||
usage: '模型用量信息',
|
||||
|
|
|
|||
|
|
@ -906,6 +906,7 @@ const translation = {
|
|||
modelSelectorTooltips: {
|
||||
deprecated: '此模型已棄用',
|
||||
},
|
||||
vision: '視覺',
|
||||
outputVars: {
|
||||
files: {
|
||||
type: '支撐類型。現在僅支援鏡像',
|
||||
|
|
|
|||
Loading…
Reference in New Issue