From dfe46b4dcbdbcf027e812a46a39436213622eb18 Mon Sep 17 00:00:00 2001 From: Rao Tao Date: Sun, 11 Oct 2026 03:34:14 +0000 Subject: [PATCH 1/7] refactor(api): enable strict typing for fix app site missing tests (#43840) Co-authored-by: Keviniscool-boy <238277696+Keviniscool-boy@users.noreply.github.com> --- .../unit_tests/commands/test_fix_app_site_missing.py | 11 +++++++++-- api/tests/unit_tests/pyrefly.toml | 1 - 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/api/tests/unit_tests/commands/test_fix_app_site_missing.py b/api/tests/unit_tests/commands/test_fix_app_site_missing.py index c69f4eb6463746..8666429fc9786f 100644 --- a/api/tests/unit_tests/commands/test_fix_app_site_missing.py +++ b/api/tests/unit_tests/commands/test_fix_app_site_missing.py @@ -66,6 +66,13 @@ def _bind_command_database( return command_sessions, command_session +def _run_fix_app_site_missing() -> None: + """Invoke the command callback through a narrowed, non-optional reference.""" + callback = system_commands.fix_app_site_missing.callback + assert callback is not None + callback() + + def test_fix_app_site_missing_passes_loaded_session_to_signal( monkeypatch: pytest.MonkeyPatch, sqlite_engine: Engine, @@ -89,7 +96,7 @@ def create_site(sender: App, *, account: Account, session: Session) -> None: monkeypatch.setattr(system_commands.app_was_created, "send", send) try: - system_commands.fix_app_site_missing.callback() + _run_fix_app_site_missing() finally: command_sessions.remove() @@ -123,7 +130,7 @@ def fail_signal(sender: App, **_kwargs: object) -> None: monkeypatch.setattr(system_commands.app_was_created, "send", MagicMock(side_effect=fail_signal)) try: - system_commands.fix_app_site_missing.callback() + _run_fix_app_site_missing() finally: command_sessions.remove() diff --git a/api/tests/unit_tests/pyrefly.toml b/api/tests/unit_tests/pyrefly.toml index ac608fef71b6a4..285312e71a411c 100644 --- a/api/tests/unit_tests/pyrefly.toml +++ b/api/tests/unit_tests/pyrefly.toml @@ -18,7 +18,6 @@ project-excludes = [ "commands/test_clean_expired_messages.py", "commands/test_data_migration_commands.py", "commands/test_data_migration_wizard.py", - "commands/test_fix_app_site_missing.py", "commands/test_generate_swagger_markdown_docs.py", "commands/test_generate_swagger_specs.py", "commands/test_lint_response_contracts.py", From b8ea5d4d28aa29779917a88b802cf9c67e42d06d Mon Sep 17 00:00:00 2001 From: Rao Tao Date: Sun, 11 Oct 2026 03:35:50 +0000 Subject: [PATCH 2/7] refactor(api): enable strict typing for reset encrypt key pair tests (#43846) Co-authored-by: Keviniscool-boy <238277696+Keviniscool-boy@users.noreply.github.com> --- .../commands/test_reset_encrypt_key_pair.py | 14 ++++++++++---- api/tests/unit_tests/pyrefly.toml | 1 - 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/api/tests/unit_tests/commands/test_reset_encrypt_key_pair.py b/api/tests/unit_tests/commands/test_reset_encrypt_key_pair.py index bb3201bbdaafb5..24afa1cb44a16e 100644 --- a/api/tests/unit_tests/commands/test_reset_encrypt_key_pair.py +++ b/api/tests/unit_tests/commands/test_reset_encrypt_key_pair.py @@ -26,8 +26,10 @@ def _invoke_reset() -> int: + callback = commands.reset_encrypt_key_pair.callback + assert callback is not None try: - commands.reset_encrypt_key_pair.callback() + callback() except SystemExit as e: return int(e.code or 0) return 0 @@ -87,7 +89,7 @@ def _bind_command_to_sqlite(monkeypatch: pytest.MonkeyPatch, session: Session) - monkeypatch.setattr(system_commands, "db", SimpleNamespace(engine=session.get_bind())) -def test_reset_aborts_when_not_self_hosted(monkeypatch, capsys): +def test_reset_aborts_when_not_self_hosted(monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]) -> None: apply_config_overrides(monkeypatch, DEPLOYMENT_EDITION=DeploymentEdition.CLOUD) exit_code = _invoke_reset() @@ -128,8 +130,12 @@ def test_reset_purges_provider_and_tool_tables_for_each_tenant( assert TENANT_ID in captured.out sqlite_session.expire_all() - assert sqlite_session.get(Tenant, TENANT_ID).encrypt_public_key == f"new-key-{TENANT_ID}" - assert sqlite_session.get(Tenant, OTHER_TENANT_ID).encrypt_public_key == f"new-key-{OTHER_TENANT_ID}" + tenant_after = sqlite_session.get(Tenant, TENANT_ID) + other_tenant_after = sqlite_session.get(Tenant, OTHER_TENANT_ID) + assert tenant_after is not None + assert other_tenant_after is not None + assert tenant_after.encrypt_public_key == f"new-key-{TENANT_ID}" + assert other_tenant_after.encrypt_public_key == f"new-key-{OTHER_TENANT_ID}" assert sqlite_session.scalars(select(Provider).where(Provider.provider_type == ProviderType.CUSTOM)).all() == [] assert sqlite_session.scalars(select(ProviderModel)).all() == [] assert sqlite_session.scalars(select(BuiltinToolProvider)).all() == [] diff --git a/api/tests/unit_tests/pyrefly.toml b/api/tests/unit_tests/pyrefly.toml index 285312e71a411c..62936434dd4f13 100644 --- a/api/tests/unit_tests/pyrefly.toml +++ b/api/tests/unit_tests/pyrefly.toml @@ -21,7 +21,6 @@ project-excludes = [ "commands/test_generate_swagger_markdown_docs.py", "commands/test_generate_swagger_specs.py", "commands/test_lint_response_contracts.py", - "commands/test_reset_encrypt_key_pair.py", "commands/test_upgrade_db.py", "configs/test_dify_config.py", "configs/test_env_consistency.py", From 60d4b862024988a08a06943a6de9700282a7ca04 Mon Sep 17 00:00:00 2001 From: Jason Wang Date: Sun, 11 Oct 2026 12:15:56 +0800 Subject: [PATCH 3/7] fix(agent): resolve workflow variables in knowledge metadata filters --- .../nodes/agent_v2/runtime_request_builder.py | 37 +- api/core/workflow/workflow_entry.py | 26 ++ api/libs/workflow_template_values.py | 52 +++ .../agent_v2/test_runtime_request_builder.py | 199 +++++++++- .../components/orchestrate/index.tsx | 4 +- .../knowledge/__tests__/index.spec.tsx | 369 +++++++++++++++++- .../orchestrate/knowledge/dialog.tsx | 55 ++- .../orchestrate/knowledge/index.tsx | 3 +- .../orchestrate/knowledge/metadata-filter.tsx | 80 ++++ 9 files changed, 808 insertions(+), 17 deletions(-) create mode 100644 api/libs/workflow_template_values.py create mode 100644 web/features/agent-v2/agent-detail/configure/components/orchestrate/knowledge/metadata-filter.tsx diff --git a/api/core/workflow/nodes/agent_v2/runtime_request_builder.py b/api/core/workflow/nodes/agent_v2/runtime_request_builder.py index 0dcdcb35b36532..b044e2b4cacc39 100644 --- a/api/core/workflow/nodes/agent_v2/runtime_request_builder.py +++ b/api/core/workflow/nodes/agent_v2/runtime_request_builder.py @@ -53,6 +53,7 @@ from graphon.file import File, FileTransferMethod from graphon.variables.segments import Segment from graphon.variables.template_resolution import convert_template +from libs.workflow_template_values import resolve_template_value from models.agent import Agent, AgentConfigSnapshot, WorkflowAgentNodeBinding from models.agent_config_entities import ( AgentKnowledgeMetadataFilteringConfig, @@ -216,7 +217,7 @@ def build(self, context: WorkflowAgentRuntimeBuildContext) -> WorkflowAgentRunti runtime_config_skills=runtime_config_skills, ) soul_prompt = expand_prompt_mentions(agent_soul.prompt.system_prompt, soul_prompt_resolver).strip() - knowledge_config = build_knowledge_layer_config(agent_soul) + knowledge_config = build_knowledge_layer_config(agent_soul, variable_pool=context.variable_pool) context_window_tokens = resolve_model_context_window( run_context=context.dify_context, provider_name=agent_soul.model.model_provider, @@ -743,7 +744,9 @@ def build_shell_layer_config(agent_soul: AgentSoulConfig) -> DifyShellLayerConfi ) -def build_knowledge_layer_config(agent_soul: AgentSoulConfig) -> DifyKnowledgeBaseLayerConfig | None: +def build_knowledge_layer_config( + agent_soul: AgentSoulConfig, *, variable_pool: VariablePoolReader | None = None +) -> DifyKnowledgeBaseLayerConfig | None: """Map Agent Soul knowledge sets into one Dify knowledge-base layer. Agent Soul DTO validation owns malformed set rejection. Runtime mapping is @@ -751,6 +754,8 @@ def build_knowledge_layer_config(agent_soul: AgentSoulConfig) -> DifyKnowledgeBa policy, dataset refs, retrieval controls, and metadata-filtering controls. ``score_threshold=None`` means disabled threshold filtering and maps to the inner retrieval request's ``0.0`` default through the Agent backend DTO. + Workflow callers supply the current run's variable pool; ``None`` preserves + literal filters for standalone Agent requests, which have no workflow scope. """ if not agent_soul.knowledge.sets: return None @@ -774,7 +779,9 @@ def build_knowledge_layer_config(agent_soul: AgentSoulConfig) -> DifyKnowledgeBa value=knowledge_set.query.value, ), retrieval=_knowledge_retrieval_config(knowledge_set.retrieval), - metadata_filtering=_knowledge_metadata_filtering_config(knowledge_set.metadata_filtering), + metadata_filtering=_knowledge_metadata_filtering_config( + knowledge_set.metadata_filtering, variable_pool=variable_pool + ), ) for knowledge_set in agent_soul.knowledge.sets ], @@ -821,17 +828,33 @@ def _knowledge_retrieval_config(retrieval: AgentKnowledgeRetrievalConfig) -> Dif def _knowledge_metadata_filtering_config( metadata_filtering: AgentKnowledgeMetadataFilteringConfig, + *, + variable_pool: VariablePoolReader | None, ) -> DifyKnowledgeMetadataFilteringConfig: + conditions = metadata_filtering.conditions + if conditions is not None and metadata_filtering.mode == "manual" and variable_pool is not None: + try: + conditions = conditions.model_copy( + update={ + "conditions": [ + condition.model_copy(update={"value": resolve_template_value(condition.value, variable_pool)}) + for condition in conditions.conditions + ] + } + ) + except ValueError as error: + raise WorkflowAgentRuntimeRequestBuildError( + "agent_metadata_filter_variable_not_found", str(error) + ) from error + return DifyKnowledgeMetadataFilteringConfig( mode=metadata_filtering.mode, model_config=_knowledge_model_config(metadata_filtering.metadata_model_config), conditions=cast( Any, - metadata_filtering.conditions.model_dump( - mode="json", exclude={"conditions": {"__all__": {"id", "metadata_id"}}} - ), + conditions.model_dump(mode="json", exclude={"conditions": {"__all__": {"id", "metadata_id"}}}), ) - if metadata_filtering.conditions is not None + if conditions is not None else None, ) diff --git a/api/core/workflow/workflow_entry.py b/api/core/workflow/workflow_entry.py index c0f532a6750c21..ed71946728f324 100644 --- a/api/core/workflow/workflow_entry.py +++ b/api/core/workflow/workflow_entry.py @@ -22,6 +22,8 @@ is_start_node_type, resolve_workflow_node_class, ) +from core.workflow.nodes.agent_v2.binding_resolver import WorkflowAgentBindingResolver +from core.workflow.nodes.agent_v2.discriminator import is_dify_agent_node_data from core.workflow.nodes.human_input.boundary import HumanInputFormEventFilter from core.workflow.system_variables import ( default_system_variables, @@ -47,6 +49,8 @@ from graphon.nodes.container_effects import ContainerAwaitRequest from graphon.runtime import GraphRuntimeState, ReadOnlyGraphRuntimeStateWrapper, VariablePool from graphon.variable_loader import DUMMY_VARIABLE_LOADER, VariableLoader, load_into_variable_pool +from libs.workflow_template_values import template_value_selectors +from models.agent_config_entities import AgentSoulConfig from models.workflow import Workflow logger = logging.getLogger(__name__) @@ -271,6 +275,28 @@ def single_step_run( ) except NotImplementedError: variable_mapping = {} + if is_dify_agent_node_data(node_config_data): + # Agent Soul lives in its owned snapshot, outside graph node data. + # Single-step execution must also load references used only by its + # retrieval filters, which are absent from the task prompt. + bundle = WorkflowAgentBindingResolver().resolve( + tenant_id=workflow.tenant_id, + app_id=workflow.app_id, + workflow_id=workflow.id, + node_id=node_id, + ) + agent_soul = AgentSoulConfig.model_validate(bundle.snapshot.config_snapshot_dict) + variable_mapping = { + **variable_mapping, + **{ + f"{node_id}.metadata_filter.{'.'.join(selector)}": selector + for knowledge_set in agent_soul.knowledge.sets + if knowledge_set.metadata_filtering.mode == "manual" + and knowledge_set.metadata_filtering.conditions is not None + for condition in knowledge_set.metadata_filtering.conditions.conditions + for selector in template_value_selectors(condition.value) + }, + } variable_mapping = inject_default_system_variable_mappings( node_id=node_id, node_type=node_type, diff --git a/api/libs/workflow_template_values.py b/api/libs/workflow_template_values.py new file mode 100644 index 00000000000000..86f37a571a517b --- /dev/null +++ b/api/libs/workflow_template_values.py @@ -0,0 +1,52 @@ +from collections.abc import Iterator, Sequence +from typing import Protocol + +from graphon.nodes.base.variable_template_parser import extract_selectors_from_template +from graphon.variables.segments import Segment +from graphon.variables.template_resolution import VARIABLE_PATTERN + +type TemplateValue = str | Sequence[str] | int | float | None + + +class TemplateVariablePool(Protocol): + def get(self, selector: Sequence[str], /) -> Segment | None: ... + + +def template_value_selectors(value: TemplateValue) -> Iterator[list[str]]: + """Extract workflow references from a scalar string or string sequence.""" + values = [value] if isinstance(value, str) else value + if isinstance(values, Sequence): + for template in values: + for reference in extract_selectors_from_template(template): + yield list(reference.value_selector) + + +def resolve_template_value(value: TemplateValue, variable_pool: TemplateVariablePool) -> TemplateValue: + """Resolve scalar values and string sequences against a workflow variable pool. + + A whole scalar reference retains its string or numeric type; + interpolated text and elements of string lists remain strings. Missing + references fail explicitly rather than becoming literal selector text. + """ + + def get_segment(selector: str) -> Segment: + segment = variable_pool.get(selector.split(".")) + if segment is None: + raise ValueError(f"Workflow variable {selector} is not available.") + return segment + + def resolve_template(template: str) -> str | int | float | None: + if match := VARIABLE_PATTERN.fullmatch(template): + resolved = get_segment(match.group(1)).to_object() + if resolved is None or isinstance(resolved, str | float): + return resolved + if isinstance(resolved, int) and not isinstance(resolved, bool): + return resolved + return str(resolved) + return VARIABLE_PATTERN.sub(lambda match: get_segment(match.group(1)).text, template) + + if isinstance(value, str): + return resolve_template(value) + if isinstance(value, Sequence): + return [str(resolve_template(item)) for item in value] + return value diff --git a/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_runtime_request_builder.py b/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_runtime_request_builder.py index 2678610cb80bf6..89f9511a44e150 100644 --- a/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_runtime_request_builder.py +++ b/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_runtime_request_builder.py @@ -10,6 +10,7 @@ from dify_agent.layers.prompt import Config as PromptConfig from dify_agent.protocol import DIFY_AGENT_HISTORY_LAYER_ID, DIFY_AGENT_MODEL_LAYER_ID, DIFY_AGENT_OUTPUT_LAYER_ID from dify_agent.protocol.snapshot import SessionSnapshot +from sqlalchemy.orm import Session from clients.agent_backend import ( DIFY_CONFIG_LAYER_ID, @@ -27,9 +28,18 @@ WorkflowAgentRuntimeRequestBuildError, build_shell_layer_config, ) +from core.workflow.workflow_entry import WorkflowEntry from graphon.file import File, FileTransferMethod, FileType +from graphon.runtime.variable_pool import VariablePool from graphon.variables.segments import ArrayFileSegment, FileSegment, StringSegment -from models.agent import Agent, AgentConfigSnapshot, WorkflowAgentNodeBinding +from models.agent import ( + Agent, + AgentConfigSnapshot, + AgentScope, + AgentSource, + WorkflowAgentBindingType, + WorkflowAgentNodeBinding, +) from models.agent_config_entities import ( AgentSoulConfig, AgentSoulModelConfig, @@ -40,6 +50,7 @@ DeclaredOutputType, WorkflowNodeJobConfig, ) +from models.workflow import Workflow from tests.unit_tests.config_override import apply_config_overrides @@ -228,6 +239,192 @@ def _request_layers(result) -> dict[str, dict[str, object]]: return {layer["name"]: layer for layer in dumped["composition"]["layers"]} +def _metadata_filter_context() -> WorkflowAgentRuntimeBuildContext: + context = _context() + soul = context.snapshot.config_snapshot_dict + soul["knowledge"] = { + "sets": [ + { + "id": "exhibits", + "name": "Exhibits", + "datasets": [{"id": "dataset-1"}], + "query": {"mode": "generated_query"}, + "retrieval": {"mode": "multiple", "top_k": 4}, + "metadata_filtering": { + "mode": "manual", + "conditions": { + "logical_operator": "and", + "conditions": [ + {"name": name, "comparison_operator": "=", "value": "{{#start." + name + "#}}"} + for name in ("guide_program_id", "guide_item_id") + ], + }, + }, + } + ], + } + context.snapshot.config_snapshot = soul + return context + + +def test_workflow_metadata_filters_resolve_current_run_values_without_mutating_snapshot(): + context = _metadata_filter_context() + saved_snapshot = context.snapshot.config_snapshot_dict + builder = WorkflowAgentRuntimeRequestBuilder() + + for program_id, item_id in [(123, 456), (789, 0), ("00123", "00456")]: + pool = VariablePool() + pool.add(["start", "guide_program_id"], program_id) + pool.add(["start", "guide_item_id"], item_id) + pool.add(["sys", "query"], "Describe this exhibit") + result = builder.build(replace(context, variable_pool=pool)) + layer = result.request.model_dump(mode="json")["composition"]["layers"] + knowledge = next(item for item in layer if item["name"] == "knowledge") + filtering = knowledge["config"]["sets"][0]["metadata_filtering"] + + assert filtering["conditions"] == { + "logical_operator": "and", + "conditions": [ + {"name": "guide_program_id", "comparison_operator": "=", "value": program_id}, + {"name": "guide_item_id", "comparison_operator": "=", "value": item_id}, + ], + } + assert context.snapshot.config_snapshot_dict == saved_snapshot + + +def test_workflow_metadata_filter_rejects_missing_variable_before_starting_agent(): + context = _metadata_filter_context() + with pytest.raises(WorkflowAgentRuntimeRequestBuildError, match="start.guide_program_id"): + WorkflowAgentRuntimeRequestBuilder().build(replace(context, variable_pool=VariablePool())) + + +def test_single_step_loads_variables_referenced_only_by_knowledge_filters(sqlite_session: Session, monkeypatch): + from configs import dify_config + + monkeypatch.setattr(dify_config, "AGENT_BACKEND_BASE_URL", "http://agent-backend.invalid") + context = _metadata_filter_context() + context.binding.workflow_version = "draft" + context.binding.binding_type = WorkflowAgentBindingType.INLINE_AGENT + context.agent.scope = AgentScope.WORKFLOW_ONLY + context.agent.source = AgentSource.WORKFLOW + sqlite_session.add_all([context.agent, context.snapshot, context.binding]) + sqlite_session.commit() + workflow = Workflow( + id=context.workflow_id, + tenant_id=context.dify_context.tenant_id, + app_id=context.dify_context.app_id, + graph=json.dumps( + { + "nodes": [ + { + "id": context.node_id, + "data": {"type": "agent", "version": "2", "agent_node_kind": "dify_agent", "title": "Agent"}, + } + ], + "edges": [], + } + ), + ) + source_pool = VariablePool() + source_pool.add(["start", "guide_program_id"], "00123") + source_pool.add(["start", "guide_item_id"], 456) + + class DraftVariableLoader: + def load_variables(self, selectors): + return [value for selector in selectors if (value := source_pool.get(selector)) is not None] + + pool = VariablePool() + WorkflowEntry.single_step_run( + workflow=workflow, + node_id=context.node_id, + user_id=context.dify_context.user_id, + user_inputs={}, + variable_pool=pool, + variable_loader=DraftVariableLoader(), + ) + result = WorkflowAgentRuntimeRequestBuilder().build(replace(context, variable_pool=pool)) + conditions = _request_layers(result)["knowledge"]["config"]["sets"][0]["metadata_filtering"]["conditions"][ + "conditions" + ] + assert [condition["value"] for condition in conditions] == ["00123", 456] + + +def test_single_step_legacy_agent_v2_does_not_resolve_new_agent_binding(monkeypatch): + legacy_node = SimpleNamespace(id="legacy-agent") + monkeypatch.setattr(WorkflowEntry, "_run_node_with_layers", lambda *_args, **_kwargs: iter(())) + monkeypatch.setattr( + "core.workflow.workflow_entry.DifyNodeFactory.from_graph_init_context", + lambda **_kwargs: SimpleNamespace(create_node=lambda _config: legacy_node), + ) + + def reject_new_agent_binding(*_args, **_kwargs): + raise AssertionError("Legacy Agent must not resolve an Agent v2 binding") + + monkeypatch.setattr("core.workflow.workflow_entry.WorkflowAgentBindingResolver.resolve", reject_new_agent_binding) + workflow = Workflow( + id="workflow-id", + tenant_id="tenant-id", + app_id="app-id", + graph=json.dumps( + { + "nodes": [{"id": "legacy-agent", "data": {"type": "agent", "version": "2", "title": "Legacy Agent"}}], + "edges": [], + } + ), + ) + node, _ = WorkflowEntry.single_step_run( + workflow=workflow, + node_id="legacy-agent", + user_id="user-id", + user_inputs={}, + variable_pool=VariablePool(), + ) + assert node is legacy_node + + +@pytest.mark.parametrize( + ("value", "expected"), + [ + ("program:{{#start.guide_program_id#}}/{{#start.guide_item_id#}}", "program:123/00456"), + (["{{#start.guide_program_id#}}", "{{#start.guide_item_id#}}", "constant"], ["123", "00456", "constant"]), + (0, 0), + (None, None), + ("", ""), + ], +) +def test_metadata_filters_resolve_templates_and_lists_preserving_constants(value, expected): + context = _metadata_filter_context() + soul = context.snapshot.config_snapshot_dict + condition = soul["knowledge"]["sets"][0]["metadata_filtering"]["conditions"]["conditions"][0] + condition.update(value=value, id="condition-1", metadata_id="program-metadata") + context.snapshot.config_snapshot = soul + pool = VariablePool() + pool.add(["start", "guide_program_id"], 123) + pool.add(["start", "guide_item_id"], "00456") + result = WorkflowAgentRuntimeRequestBuilder().build(replace(context, variable_pool=pool)) + conditions = _request_layers(result)["knowledge"]["config"]["sets"][0]["metadata_filtering"]["conditions"][ + "conditions" + ] + assert conditions[0] == {"name": "guide_program_id", "comparison_operator": "=", "value": expected} + + +@pytest.mark.parametrize("mode", ["disabled", "automatic"]) +def test_inactive_manual_filters_do_not_require_workflow_variables(mode): + context = _metadata_filter_context() + soul = context.snapshot.config_snapshot_dict + soul["knowledge"]["sets"][0]["metadata_filtering"]["mode"] = mode + if mode == "automatic": + soul["knowledge"]["sets"][0]["metadata_filtering"]["model_config"] = { + "provider": "openai", + "name": "gpt-test", + "mode": "chat", + "completion_params": {}, + } + context.snapshot.config_snapshot = soul + result = WorkflowAgentRuntimeRequestBuilder().build(replace(context, variable_pool=VariablePool())) + assert _request_layers(result)["knowledge"]["config"]["sets"][0]["metadata_filtering"]["mode"] == mode + + def _workflow_user_prompt(result) -> str: from clients.agent_backend import WORKFLOW_USER_PROMPT_LAYER_ID diff --git a/web/features/agent-v2/agent-detail/configure/components/orchestrate/index.tsx b/web/features/agent-v2/agent-detail/configure/components/orchestrate/index.tsx index 38df49edf68dd6..ee77591336cee0 100644 --- a/web/features/agent-v2/agent-detail/configure/components/orchestrate/index.tsx +++ b/web/features/agent-v2/agent-detail/configure/components/orchestrate/index.tsx @@ -186,7 +186,9 @@ export function AgentOrchestratePanel({ )} - {ENABLE_AGENT_KNOWLEDGE_RETRIEVAL && } + {ENABLE_AGENT_KNOWLEDGE_RETRIEVAL && ( + + )} {!isTemplatePreview && } diff --git a/web/features/agent-v2/agent-detail/configure/components/orchestrate/knowledge/__tests__/index.spec.tsx b/web/features/agent-v2/agent-detail/configure/components/orchestrate/knowledge/__tests__/index.spec.tsx index 28ae7bac53351b..7cc3daa0e1cc41 100644 --- a/web/features/agent-v2/agent-detail/configure/components/orchestrate/knowledge/__tests__/index.spec.tsx +++ b/web/features/agent-v2/agent-detail/configure/components/orchestrate/knowledge/__tests__/index.spec.tsx @@ -1,9 +1,18 @@ +import type { Node, Var } from '@/app/components/workflow/types' import type { AgentSoulConfigFormState } from '@/features/agent-v2/agent-composer/form-state' -import { fireEvent, screen, within } from '@testing-library/react' +import type { MetadataInDoc } from '@/models/datasets' +import { act, fireEvent, screen, waitFor, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { useAtomValue } from 'jotai' +import { ReactFlowProvider } from 'reactflow' import { beforeEach, describe, expect, it, vi } from 'vite-plus/test' -import { MetadataFilteringModeEnum } from '@/app/components/workflow/nodes/knowledge-retrieval/types' +import { + ComparisonOperator, + LogicalOperator, + MetadataFilteringModeEnum, + MetadataFilteringVariableType, +} from '@/app/components/workflow/nodes/knowledge-retrieval/types' +import { BlockEnum, VarType } from '@/app/components/workflow/types' import { formStateToAgentSoulConfig } from '@/features/agent-v2/agent-composer/conversions' import { defaultAgentSoulConfigFormState } from '@/features/agent-v2/agent-composer/form-state' import { AgentComposerProvider } from '@/features/agent-v2/agent-composer/provider' @@ -16,6 +25,66 @@ import { } from '../../read-only-context' import { AgentKnowledgeRetrieval } from '../index' +vi.mock('@/app/components/workflow/nodes/_base/hooks/use-available-var-list', () => ({ + default: (_nodeId: string, { filterVar }: { filterVar: (value: Var) => boolean }) => ({ + availableVars: [ + { + nodeId: 'start', + title: 'Start', + isStartNode: true, + vars: [ + { variable: 'program_input', type: VarType.number }, + { variable: 'item_input', type: VarType.string }, + ].filter(filterVar), + }, + ], + availableNodesWithParent: [ + { + id: 'start', + position: { x: 0, y: 0 }, + data: { type: BlockEnum.Start, title: 'Start', desc: '' }, + } satisfies Node, + ], + }), +})) + +const datasetQuery = vi.hoisted(() => ({ + pages: [] as Array<{ data: unknown[] }>, + notify: () => {}, + error: null as Error | null, +})) +vi.mock('@/service/console', async (importOriginal) => { + const actual = await importOriginal() + const datasetsQuery = new Proxy(actual.consoleQuery.datasets, { + get(target, property, receiver) { + if (property !== 'get') return Reflect.get(target, property, receiver) + return { + ...target.get, + queryOptions: (...args: Parameters) => ({ + ...target.get.queryOptions(...args), + retry: false, + initialData: datasetQuery.pages[0], + queryFn: () => { + if (datasetQuery.error) return Promise.reject(datasetQuery.error) + return new Promise((resolve) => { + datasetQuery.notify = () => resolve(datasetQuery.pages[0]) + }) + }, + }), + } + }, + }) + return { + ...actual, + consoleQuery: new Proxy(actual.consoleQuery, { + get(target, property, receiver) { + if (property === 'datasets') return datasetsQuery + return Reflect.get(target, property, receiver) + }, + }), + } +}) + vi.mock('@/context/workspace-state', async () => { const { createWorkspaceStateModuleMock } = await import('@/test/console/state-fixture') return createWorkspaceStateModuleMock(() => ({ @@ -39,6 +108,7 @@ vi.mock('@/app/components/workflow/nodes/knowledge-retrieval/components/add-data search_method: string } is_multimodal: boolean + doc_metadata: MetadataInDoc[] }>, ) => void }) { @@ -59,6 +129,20 @@ vi.mock('@/app/components/workflow/nodes/knowledge-retrieval/components/add-data search_method: 'semantic', }, is_multimodal: false, + doc_metadata: [ + { + id: 'program-metadata', + name: 'guide_program_id', + type: MetadataFilteringVariableType.number, + value: '', + }, + { + id: 'item-metadata', + name: 'guide_item_id', + type: MetadataFilteringVariableType.string, + value: '', + }, + ], }, ]) } @@ -112,17 +196,25 @@ function renderKnowledgeRetrieval({ readOnly = false, viewingVersion = false, showConfigSnapshot = false, + workflowNodeId, }: { initialDraft?: AgentSoulConfigFormState readOnly?: boolean viewingVersion?: boolean showConfigSnapshot?: boolean + workflowNodeId?: string } = {}) { return render( - + {workflowNodeId ? ( + + + + ) : ( + + )} {showConfigSnapshot && } @@ -147,6 +239,8 @@ vi.mock('@/context/permission-state', async () => { describe('AgentKnowledgeRetrieval', () => { beforeEach(() => { vi.clearAllMocks() + datasetQuery.pages = [] + datasetQuery.error = null }) describe('Rendering', () => { @@ -196,6 +290,275 @@ describe('AgentKnowledgeRetrieval', () => { }) describe('User Interactions', () => { + it.each(['failed', 'missing'])( + 'preserves saved filters and allows recovery when dataset metadata is %s', + async (result) => { + const user = userEvent.setup() + if (result === 'failed') datasetQuery.error = new Error('Dataset request failed') + else datasetQuery.pages = [{ data: [] }] + renderKnowledgeRetrieval({ + workflowNodeId: 'agent-node', + showConfigSnapshot: true, + initialDraft: { + ...agentKnowledgeDraft, + knowledgeRetrievals: [ + { + id: 'retrieval-1', + name: 'Guide retrieval', + datasetRefs: [{ id: 'dataset-1', name: 'Guide knowledge' }], + metadataFilterMode: MetadataFilteringModeEnum.manual, + metadataFilteringConditions: { + logical_operator: LogicalOperator.and, + conditions: [ + { + id: 'condition-1', + metadata_id: 'guide-metadata', + name: 'guide_item_id', + comparison_operator: ComparisonOperator.is, + value: '{{#start.item_input#}}', + }, + ], + }, + }, + ], + }, + }) + await user.click( + screen.getByRole('button', { + name: 'agentV2.agentDetail.configure.knowledgeRetrieval.edit:{"name":"Guide retrieval"}', + }), + ) + expect(await screen.findByRole('alert')).toHaveTextContent('common.dynamicSelect.error') + expect( + screen.queryByRole('progressbar', { name: 'appApi.loading' }), + ).not.toBeInTheDocument() + expect(screen.getByLabelText('config snapshot')).toHaveTextContent('{{#start.item_input#}}') + await user.click(screen.getByRole('button', { name: /metadata.options.manual.title/ })) + await user.click( + screen.getByRole('menuitemradio', { name: /metadata.options.disabled.title/ }), + ) + expect(screen.getByLabelText('config snapshot')).toHaveTextContent('"mode":"disabled"') + + datasetQuery.error = null + datasetQuery.pages = [ + { + data: [ + { + id: 'dataset-1', + name: 'Guide knowledge', + doc_metadata: [{ id: 'guide-metadata', name: 'guide_item_id', type: 'string' }], + }, + ], + }, + ] + await user.click(screen.getByRole('button', { name: 'common.operation.retry' })) + await act(async () => { + datasetQuery.notify() + }) + await waitFor(() => expect(screen.queryByRole('alert')).not.toBeInTheDocument()) + await user.click( + await screen.findByRole('button', { name: /metadata.options.disabled.title/ }), + ) + await user.click( + screen.getByRole('menuitemradio', { name: /metadata.options.manual.title/ }), + ) + await user.click(await screen.findByRole('button', { name: /metadata.panel.conditions/ })) + expect(screen.getByRole('button', { name: 'Variable' })).toBeInTheDocument() + expect(screen.getByText('item_input')).toBeInTheDocument() + expect(screen.getByLabelText('config snapshot')).toHaveTextContent('{{#start.item_input#}}') + expect(screen.queryByRole('alert')).not.toBeInTheDocument() + }, + ) + + it.each([false, true])( + 'preserves saved metadata conditions while dataset definitions are loaded: %s', + async (loaded) => { + const user = userEvent.setup() + if (loaded) + datasetQuery.pages = [ + { + data: [ + { + id: 'dataset-1', + name: 'Guide knowledge', + doc_metadata: [ + { id: 'program-metadata', name: 'guide_program_id', type: 'string' }, + ], + }, + ], + }, + ] + const draft: AgentSoulConfigFormState = { + ...agentKnowledgeDraft, + knowledgeRetrievals: [ + { + id: 'retrieval-1', + name: 'Guide retrieval', + datasetRefs: [{ id: 'dataset-1', name: 'Guide knowledge' }], + metadataFilterMode: MetadataFilteringModeEnum.manual, + metadataFilteringConditions: { + logical_operator: LogicalOperator.and, + conditions: [ + { + id: 'condition-1', + metadata_id: 'program-metadata', + name: 'guide_program_id', + comparison_operator: ComparisonOperator.is, + value: '{{#start.item_input#}}', + }, + ], + }, + }, + ], + } + renderKnowledgeRetrieval({ + initialDraft: draft, + showConfigSnapshot: true, + workflowNodeId: 'agent-node', + }) + await user.click( + screen.getByRole('button', { + name: 'agentV2.agentDetail.configure.knowledgeRetrieval.edit:{"name":"Guide retrieval"}', + }), + ) + expect(screen.getByLabelText('config snapshot')).toHaveTextContent('{{#start.item_input#}}') + if (loaded) { + await user.click(screen.getByRole('button', { name: /metadata.panel.conditions/ })) + expect(screen.getByText('guide_program_id')).toBeInTheDocument() + await user.click(screen.getByRole('button', { name: /metadata.panel.add/ })) + expect(screen.getAllByText('guide_program_id')).toHaveLength(2) + } else { + expect(screen.getByRole('progressbar', { name: 'appApi.loading' })).toBeInTheDocument() + expect( + screen.queryByRole('button', { name: /metadata.panel.conditions/ }), + ).not.toBeInTheDocument() + } + }, + ) + + it.each([ + ['string', 'item_input', ComparisonOperator.is], + ['number', 'program_input', ComparisonOperator.equal], + ] as const)( + 'waits for metadata before editing a saved %s variable', + async (metadataType, variableName, comparisonOperator) => { + const user = userEvent.setup() + const value = `{{#start.${variableName}#}}` + renderKnowledgeRetrieval({ + workflowNodeId: 'agent-node', + showConfigSnapshot: true, + initialDraft: { + ...agentKnowledgeDraft, + knowledgeRetrievals: [ + { + id: 'retrieval-1', + name: 'Guide retrieval', + datasetRefs: [{ id: 'dataset-1', name: 'Guide knowledge' }], + metadataFilterMode: MetadataFilteringModeEnum.manual, + metadataFilteringConditions: { + logical_operator: LogicalOperator.and, + conditions: [ + { + id: 'condition-1', + metadata_id: 'guide-metadata', + name: 'guide_item_id', + comparison_operator: comparisonOperator, + value, + }, + ], + }, + }, + ], + }, + }) + await user.click( + screen.getByRole('button', { + name: 'agentV2.agentDetail.configure.knowledgeRetrieval.edit:{"name":"Guide retrieval"}', + }), + ) + expect(screen.getByRole('progressbar', { name: 'appApi.loading' })).toBeInTheDocument() + expect( + screen.queryByRole('button', { name: /metadata.panel.conditions/ }), + ).not.toBeInTheDocument() + expect(screen.getByLabelText('config snapshot')).toHaveTextContent(value) + + await act(async () => { + datasetQuery.pages = [ + { + data: [ + { + id: 'dataset-1', + name: 'Guide knowledge', + doc_metadata: [ + { id: 'guide-metadata', name: 'guide_item_id', type: metadataType }, + ], + }, + ], + }, + ] + datasetQuery.notify() + }) + + await user.click(await screen.findByRole('button', { name: /metadata.panel.conditions/ })) + expect( + screen.queryByRole('progressbar', { name: 'appApi.loading' }), + ).not.toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Variable' })).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Constant' })).not.toBeInTheDocument() + expect(screen.getByText(variableName)).toBeInTheDocument() + expect(screen.getByLabelText('config snapshot')).toHaveTextContent(value) + }, + ) + + it.each([ + ['guide_program_id', 'program_input', 'item_input'], + ['guide_item_id', 'item_input', 'program_input'], + ])( + 'should save a matching workflow variable for %s', + async (metadataName, variableName, otherTypeVariable) => { + const user = userEvent.setup() + renderKnowledgeRetrieval({ workflowNodeId: 'agent-node', showConfigSnapshot: true }) + + await user.click( + screen.getByRole('button', { + name: 'agentV2.agentDetail.configure.knowledgeRetrieval.add', + }), + ) + await user.click( + screen.getByRole('button', { + name: 'common.operation.add workflow.nodes.knowledgeRetrieval.knowledge', + }), + ) + await user.click( + screen.getByRole('button', { + name: 'workflowModels.nodes.knowledgeRetrieval.metadata.options.disabled.title', + }), + ) + await user.click( + screen.getByRole('menuitemradio', { name: /metadata.options.manual.title/ }), + ) + await user.click(screen.getByRole('button', { name: /metadata.panel.conditions/ })) + await user.click( + screen.getByRole('button', { + name: 'workflowModels.nodes.knowledgeRetrieval.metadata.panel.add', + }), + ) + await user.click(screen.getByText(metadataName)) + await user.click(screen.getByRole('button', { name: 'Constant' })) + await user.click(screen.getByText('Variable', { exact: true })) + await user.click( + screen.getByText('workflowModels.nodes.knowledgeRetrieval.metadata.panel.select'), + ) + + expect(screen.queryByText(otherTypeVariable)).not.toBeInTheDocument() + await user.click(screen.getByText(variableName)) + expect(screen.getByLabelText('config snapshot')).toHaveTextContent( + `{{#start.${variableName}#}}`, + ) + expect(screen.getByLabelText('config snapshot')).toHaveTextContent(metadataName) + }, + ) + it('should open the knowledge retrieval dialog from the add button', async () => { const user = userEvent.setup() renderKnowledgeRetrieval() diff --git a/web/features/agent-v2/agent-detail/configure/components/orchestrate/knowledge/dialog.tsx b/web/features/agent-v2/agent-detail/configure/components/orchestrate/knowledge/dialog.tsx index c1ca4c1f9a80bd..d58bebeb6b1b74 100644 --- a/web/features/agent-v2/agent-detail/configure/components/orchestrate/knowledge/dialog.tsx +++ b/web/features/agent-v2/agent-detail/configure/components/orchestrate/knowledge/dialog.tsx @@ -16,6 +16,7 @@ import { IconButton } from '@langgenius/dify-ui/icon-button' import { Input } from '@langgenius/dify-ui/input' import { RadioGroup, RadioItem } from '@langgenius/dify-ui/radio-group' import { Textarea } from '@langgenius/dify-ui/textarea' +import { useQuery } from '@tanstack/react-query' import { intersectionBy } from 'es-toolkit/compat' import { useAtomValue } from 'jotai' import { useLayoutEffect, useMemo, useRef, useState } from 'react' @@ -29,7 +30,6 @@ import { import Field from '@/app/components/workflow/nodes/_base/components/field' import AddKnowledge from '@/app/components/workflow/nodes/knowledge-retrieval/components/add-dataset' import DatasetList from '@/app/components/workflow/nodes/knowledge-retrieval/components/dataset-list' -import MetadataFilter from '@/app/components/workflow/nodes/knowledge-retrieval/components/metadata/metadata-filter' import RetrievalConfig from '@/app/components/workflow/nodes/knowledge-retrieval/components/retrieval-config' import { ComparisonOperator, @@ -46,7 +46,9 @@ import { } from '@/features/agent-v2/agent-composer/knowledge-validation' import { agentComposerKnowledgeRetrievalsAtom } from '@/features/agent-v2/agent-composer/store-modules/knowledge' import { ChunkingMode, DatasetPermission, DataSourceType } from '@/models/datasets' +import { consoleQuery } from '@/service/console' import { AppModeEnum, RETRIEVE_METHOD, RETRIEVE_TYPE } from '@/types/app' +import { AgentMetadataFilter } from './metadata-filter' type KnowledgeRetrievalQueryMode = 'agent' | 'custom' type MetadataFilteringConditions = { @@ -68,6 +70,7 @@ type KnowledgeRetrievalDialogState = { } type AgentKnowledgeRetrievalDialogProps = { + workflowNodeId?: string item?: AgentKnowledgeRetrievalItem initialName?: string onItemCreate?: (item: AgentKnowledgeRetrievalItem) => void @@ -323,6 +326,7 @@ function EditableKnowledgeRetrievalName({ } function AgentKnowledgeRetrievalDialogContent({ + workflowNodeId, item, initialName, onItemCreate, @@ -347,9 +351,45 @@ function AgentKnowledgeRetrievalDialogContent({ name, queryMode, retrievalMode, - selectedDatasets, + selectedDatasets: storedDatasets, singleRetrievalConfig, } = dialogState + // Saved Agent snapshots contain dataset references, not metadata definitions. + // Load definitions before allowing MetadataTrigger to prune deleted fields. + const unresolvedDatasetIds = storedDatasets + .filter((dataset) => dataset.doc_metadata === undefined) + .map((dataset) => dataset.id) + const { + data: datasetPage, + isPending: metadataPending, + refetch: reloadMetadata, + } = useQuery( + consoleQuery.datasets.get.queryOptions({ + input: { query: { ids: unresolvedDatasetIds, page: 1, limit: 100 } }, + enabled: unresolvedDatasetIds.length > 0, + }), + ) + const selectedDatasets = useMemo(() => { + const fetchedDatasets = new Map( + datasetPage?.data.map((dataset) => [dataset.id, dataset] as const), + ) + return storedDatasets.map((dataset) => { + const definitions = fetchedDatasets.get(dataset.id)?.doc_metadata + if (dataset.doc_metadata !== undefined || !definitions) return dataset + return { + ...dataset, + doc_metadata: definitions.flatMap(({ id, name, type }) => { + const metadataType = Object.values(MetadataFilteringVariableType).find( + (supportedType) => supportedType === type, + ) + return metadataType ? [{ id, name, type: metadataType, value: '' }] : [] + }), + } + }) + }, [storedDatasets, datasetPage]) + const selectedDatasetsLoaded = selectedDatasets.every( + (dataset) => dataset.doc_metadata !== undefined, + ) const { modelList: rerankModelList, defaultModel: rerankDefaultModel } = useModelListAndDefaultModelAndCurrentProviderAndModel(ModelTypeEnum.rerank) const { currentModel: currentRerankModel, currentProvider: currentRerankProvider } = @@ -632,9 +672,14 @@ function AgentKnowledgeRetrievalDialogContent({
- { + void reloadMetadata() + }} metadataFilterMode={metadataFilterMode} metadataFilteringConditions={metadataFilteringConditions} handleMetadataFilterModeChange={(nextMode) => @@ -731,6 +776,7 @@ function AgentKnowledgeRetrievalDialogContent({ } export function AgentKnowledgeRetrievalDialog({ + workflowNodeId, item, initialName, onItemCreate, @@ -745,6 +791,7 @@ export function AgentKnowledgeRetrievalDialog({ className="flex h-130 max-h-[calc(100dvh-2rem)] w-100 flex-col overflow-hidden p-0" > & { + workflowNodeId?: string + metadataUnavailable?: boolean + onRetryMetadata?: () => void +} + +const filterStringVar = (variable: Var) => variable.type === VarType.string +const filterNumberVar = (variable: Var) => variable.type === VarType.number + +function WorkflowMetadataFilter({ workflowNodeId, ...props }: Props & { workflowNodeId: string }) { + const strings = useAvailableVarList(workflowNodeId, { + onlyLeafNodeVar: false, + filterVar: filterStringVar, + }) + const numbers = useAvailableVarList(workflowNodeId, { + onlyLeafNodeVar: false, + filterVar: filterNumberVar, + }) + + return ( + + ) +} + +export function AgentMetadataFilter({ + workflowNodeId, + metadataUnavailable = false, + onRetryMetadata, + ...props +}: Props) { + const { t } = useTranslation(['appApi', 'common']) + // Condition editors initialize their value mode from the metadata type. + // Mount them only once definitions are available so saved variables stay variables. + if (props.selectedDatasetsLoaded === false) { + if (metadataUnavailable) { + return ( +
+ + {t(($) => $['dynamicSelect.error'], { ns: 'common' })} + +
+ + +
+
+ ) + } + return ( +
+ $.loading, { ns: 'appApi' })} /> +
+ ) + } + + // Standalone Agent composers do not have a workflow canvas or variable scope. + if (workflowNodeId) return + + return +} From f518662f0ee9bdbd2b79e4252feec11c8e600c8b Mon Sep 17 00:00:00 2001 From: Jason Wang Date: Sun, 11 Oct 2026 12:46:06 +0800 Subject: [PATCH 4/7] test(agent): use validated config overrides in metadata regression --- .../nodes/agent_v2/test_runtime_request_builder.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_runtime_request_builder.py b/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_runtime_request_builder.py index 89f9511a44e150..76162384b102c8 100644 --- a/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_runtime_request_builder.py +++ b/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_runtime_request_builder.py @@ -298,10 +298,10 @@ def test_workflow_metadata_filter_rejects_missing_variable_before_starting_agent WorkflowAgentRuntimeRequestBuilder().build(replace(context, variable_pool=VariablePool())) -def test_single_step_loads_variables_referenced_only_by_knowledge_filters(sqlite_session: Session, monkeypatch): - from configs import dify_config - - monkeypatch.setattr(dify_config, "AGENT_BACKEND_BASE_URL", "http://agent-backend.invalid") +def test_single_step_loads_variables_referenced_only_by_knowledge_filters( + sqlite_session: Session, monkeypatch: pytest.MonkeyPatch +): + apply_config_overrides(monkeypatch, AGENT_BACKEND_BASE_URL="http://agent-backend.invalid") context = _metadata_filter_context() context.binding.workflow_version = "draft" context.binding.binding_type = WorkflowAgentBindingType.INLINE_AGENT From 0dc7d58d52d3ea516697292594f1c5a7c10c0f5f Mon Sep 17 00:00:00 2001 From: Jason Wang Date: Sun, 11 Oct 2026 13:03:15 +0800 Subject: [PATCH 5/7] ci: retry checks after npm registry connection timeout From 2683c0b14c44a6ff7e47e2d8df44de4d6f9827a2 Mon Sep 17 00:00:00 2001 From: yyh <92089059+lyzno1@users.noreply.github.com> Date: Sun, 11 Oct 2026 05:04:19 +0000 Subject: [PATCH 6/7] refactor: let Dify UI list primitives own the search box and option layout (#43859) --- packages/dify-ui/docs/selection.md | 7 + .../src/autocomplete/index.stories.tsx | 47 ++-- packages/dify-ui/src/autocomplete/index.tsx | 42 +-- .../dify-ui/src/combobox/index.stories.tsx | 94 +++---- packages/dify-ui/src/combobox/index.tsx | 48 ++-- packages/dify-ui/src/context-menu/index.tsx | 4 +- packages/dify-ui/src/dropdown-menu/index.tsx | 4 +- packages/dify-ui/src/overlay-shared.ts | 4 +- packages/dify-ui/src/select/index.tsx | 18 +- .../time-range-picker/range-selector.tsx | 2 +- .../config-var/config-modal/type-select.tsx | 2 +- .../credential-field.tsx | 4 +- .../apps/__tests__/creators-filter.spec.tsx | 21 +- web/app/components/apps/creators-filter.tsx | 24 +- .../base/form/components/base/base-field.tsx | 2 +- .../field/input-type-select/index.tsx | 2 +- .../base/form/components/field/select.tsx | 2 +- .../base/icon-picker/emoji-picker.tsx | 3 +- .../common/document-picker/document-list.tsx | 4 +- .../dataset-metadata-picker.spec.tsx | 21 -- .../dataset-metadata-picker.tsx | 37 +-- .../embedded-marketplace-search.spec.tsx | 18 +- ...place-search-autocomplete.browser.spec.tsx | 55 +++- .../marketplace-search-autocomplete.spec.tsx | 51 ++-- .../home/marketplace-search-autocomplete.tsx | 239 ++++++++---------- .../__tests__/app-picker.spec.tsx | 39 --- .../app-selector/app-picker.tsx | 29 +-- .../components/variable/var-type-picker.tsx | 4 +- .../condition-list/condition-item.tsx | 4 +- .../components/sub-variable-picker.tsx | 2 +- .../condition-list/condition-item.tsx | 4 +- .../components/variable-type-select.tsx | 4 +- .../logs/components/source-picker.tsx | 2 +- web/features/skills/detail/skill-metadata.tsx | 2 - .../__tests__/tag-filter.spec.tsx | 24 -- .../__tests__/tag-search-content.spec.tsx | 25 -- .../tag-management/components/tag-filter.tsx | 2 - .../components/tag-search-content.tsx | 26 +- .../components/tag-selector.tsx | 2 - 39 files changed, 357 insertions(+), 567 deletions(-) diff --git a/packages/dify-ui/docs/selection.md b/packages/dify-ui/docs/selection.md index 167622ceadf36b..5fdf3989e6039b 100644 --- a/packages/dify-ui/docs/selection.md +++ b/packages/dify-ui/docs/selection.md @@ -29,6 +29,13 @@ Autocomplete, Combobox, and Select popups use Base UI's `--anchor-width` and `-- variables to follow their trigger while clamping to the viewport. Do not replace that sizing with a fixed width or an unclamped minimum width. +When a Combobox renders its input inside the popup, do not add a control that clears the typed +query. The query is per-open state that Base UI resets: choosing an item clears it in `multiple` +mode and closes the popup in single mode, and closing the popup (Escape, outside press) clears it. +A clear control there adds a second tab stop to the popup's focus loop and a second x that means +something other than `ComboboxClear`, which clears the selected value. Outside the popup, use +`ComboboxClear` or `AutocompleteClear`. + Use `Radio` for the default radio appearance. Use `RadioItem` when custom content itself is a radio item, and put `RadioControl` inside it for Dify UI's visual indicator. `RadioControl` is a visual part, not a standalone radio. diff --git a/packages/dify-ui/src/autocomplete/index.stories.tsx b/packages/dify-ui/src/autocomplete/index.stories.tsx index b47cd73a9cbc71..7aabbd20741597 100644 --- a/packages/dify-ui/src/autocomplete/index.stories.tsx +++ b/packages/dify-ui/src/autocomplete/index.stories.tsx @@ -300,7 +300,7 @@ function SuggestionItem({ item, dense }: { item: Suggestion; dense?: boolean })