Compare commits

..

1 Commits

Author SHA1 Message Date
a16817c27e chore(deps): bump the storage group in /api with 3 updates
Bumps the storage group in /api with 3 updates: [boto3](https://github.com/boto/boto3), [azure-storage-blob](https://github.com/Azure/azure-sdk-for-python) and [cos-python-sdk-v5](https://github.com/tencentyun/cos-python-sdk-v5).


Updates `boto3` from 1.43.6 to 1.43.9
- [Release notes](https://github.com/boto/boto3/releases)
- [Commits](https://github.com/boto/boto3/compare/1.43.6...1.43.9)

Updates `azure-storage-blob` from 12.28.0 to 12.29.0
- [Release notes](https://github.com/Azure/azure-sdk-for-python/releases)
- [Commits](https://github.com/Azure/azure-sdk-for-python/compare/azure-storage-blob_12.28.0...azure-storage-blob_12.29.0)

Updates `cos-python-sdk-v5` from 1.9.42 to 1.9.43
- [Release notes](https://github.com/tencentyun/cos-python-sdk-v5/releases)
- [Changelog](https://github.com/tencentyun/cos-python-sdk-v5/blob/master/CHANGELOG.md)
- [Commits](https://github.com/tencentyun/cos-python-sdk-v5/compare/V1.9.42...V1.9.43)

---
updated-dependencies:
- dependency-name: boto3
  dependency-version: 1.43.9
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: storage
- dependency-name: azure-storage-blob
  dependency-version: 12.29.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: storage
- dependency-name: cos-python-sdk-v5
  dependency-version: 1.9.43
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: storage
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-18 02:15:03 +00:00
50 changed files with 376 additions and 3046 deletions

View File

@ -120,11 +120,7 @@ jobs:
if: github.event_name != 'merge_group' && steps.api-changes.outputs.any_changed == 'true'
run: |
cd api
uv run dev/generate_swagger_markdown_docs.py --swagger-dir ../packages/contracts/openapi --markdown-dir openapi/markdown --keep-swagger-json
- name: Generate frontend contracts
if: github.event_name != 'merge_group' && steps.api-changes.outputs.any_changed == 'true'
run: pnpm --dir packages/contracts gen-api-contract-from-openapi
uv run dev/generate_swagger_markdown_docs.py --swagger-dir openapi --markdown-dir openapi/markdown
- name: ESLint autofix
if: github.event_name != 'merge_group' && steps.web-changes.outputs.any_changed == 'true'

View File

@ -1 +0,0 @@
"""External service client packages."""

View File

@ -1,74 +0,0 @@
"""API-side integration boundary for the Dify Agent backend.
Public wire DTOs come from ``dify_agent.protocol``. This package only contains
API adapters: request building from Dify product concepts, a thin client wrapper,
event adaptation for future workflow integration, and deterministic fakes.
"""
from clients.agent_backend.client import AgentBackendRunClient, DifyAgentBackendRunClient
from clients.agent_backend.errors import (
AgentBackendError,
AgentBackendHTTPError,
AgentBackendRequestBuildError,
AgentBackendRunFailedError,
AgentBackendStreamError,
AgentBackendTransportError,
AgentBackendValidationError,
)
from clients.agent_backend.event_adapter import (
AgentBackendInternalEvent,
AgentBackendInternalEventType,
AgentBackendRunCancelledInternalEvent,
AgentBackendRunEventAdapter,
AgentBackendRunFailedInternalEvent,
AgentBackendRunPausedInternalEvent,
AgentBackendRunStartedInternalEvent,
AgentBackendRunSucceededInternalEvent,
AgentBackendStreamInternalEvent,
)
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,
WORKFLOW_NODE_JOB_PROMPT_LAYER_ID,
WORKFLOW_USER_PROMPT_LAYER_ID,
AgentBackendModelConfig,
AgentBackendOutputConfig,
AgentBackendRunRequestBuilder,
AgentBackendWorkflowNodeRunInput,
redact_for_agent_backend_log,
)
__all__ = [
"AGENT_SOUL_PROMPT_LAYER_ID",
"DIFY_PLUGIN_CONTEXT_LAYER_ID",
"WORKFLOW_NODE_JOB_PROMPT_LAYER_ID",
"WORKFLOW_USER_PROMPT_LAYER_ID",
"AgentBackendError",
"AgentBackendHTTPError",
"AgentBackendInternalEvent",
"AgentBackendInternalEventType",
"AgentBackendModelConfig",
"AgentBackendOutputConfig",
"AgentBackendRequestBuildError",
"AgentBackendRunCancelledInternalEvent",
"AgentBackendRunClient",
"AgentBackendRunEventAdapter",
"AgentBackendRunFailedError",
"AgentBackendRunFailedInternalEvent",
"AgentBackendRunPausedInternalEvent",
"AgentBackendRunRequestBuilder",
"AgentBackendRunStartedInternalEvent",
"AgentBackendRunSucceededInternalEvent",
"AgentBackendStreamError",
"AgentBackendStreamInternalEvent",
"AgentBackendTransportError",
"AgentBackendValidationError",
"AgentBackendWorkflowNodeRunInput",
"DifyAgentBackendRunClient",
"FakeAgentBackendRunClient",
"FakeAgentBackendScenario",
"create_agent_backend_run_client",
"redact_for_agent_backend_log",
]

View File

@ -1,126 +0,0 @@
"""Synchronous API-side wrapper around the public ``dify-agent`` client.
``dify-agent`` owns the cross-service DTOs and HTTP/SSE implementation. The API
backend keeps this thin wrapper so workflow code depends on a local protocol,
gets API-native errors, and can use a deterministic fake in tests without
creating another wire contract.
"""
from __future__ import annotations
from collections.abc import Iterator
from typing import Protocol
from dify_agent.client import (
DifyAgentClientError,
DifyAgentHTTPError,
DifyAgentStreamError,
DifyAgentTimeoutError,
DifyAgentValidationError,
)
from dify_agent.protocol import (
CancelRunRequest,
CancelRunResponse,
CreateRunRequest,
CreateRunResponse,
RunEvent,
RunStatusResponse,
)
from clients.agent_backend.errors import (
AgentBackendError,
AgentBackendHTTPError,
AgentBackendStreamError,
AgentBackendTransportError,
AgentBackendValidationError,
)
class AgentBackendRunClient(Protocol):
"""Local boundary used by API workflow integrations to run Agent backend jobs."""
def create_run(self, request: CreateRunRequest) -> CreateRunResponse:
"""Create one Agent backend run and return its accepted status."""
def cancel_run(self, run_id: str, request: CancelRunRequest | None = None) -> CancelRunResponse:
"""Request explicit cancellation for one Agent backend run."""
def stream_events(self, run_id: str, *, after: str | None = None) -> Iterator[RunEvent]:
"""Yield public ``dify-agent`` run events in stream order."""
def wait_run(self, run_id: str, *, timeout_seconds: float | None = None) -> RunStatusResponse:
"""Wait for a run to reach a terminal status and return that status."""
class _DifyAgentSyncClient(Protocol):
"""Subset of ``dify_agent.client.Client`` used by the API wrapper."""
def create_run_sync(self, request: CreateRunRequest) -> CreateRunResponse:
"""Create one run synchronously."""
def cancel_run_sync(self, run_id: str, request: CancelRunRequest | None = None) -> CancelRunResponse:
"""Cancel one run synchronously."""
def stream_events_sync(self, run_id: str, *, after: str | None = None) -> Iterator[RunEvent]:
"""Stream run events synchronously."""
def wait_run_sync(self, run_id: str, *, timeout_seconds: float | None = None) -> RunStatusResponse:
"""Wait for terminal run status synchronously."""
class DifyAgentBackendRunClient:
"""Adapter from API sync call sites to ``dify_agent.client.Client`` sync methods."""
client: _DifyAgentSyncClient
def __init__(self, client: _DifyAgentSyncClient) -> None:
self.client = client
def create_run(self, request: CreateRunRequest) -> CreateRunResponse:
"""Create one run through ``POST /runs`` and normalize client exceptions."""
try:
return self.client.create_run_sync(request)
except Exception as exc:
raise _normalize_dify_agent_error(exc) from exc
def cancel_run(self, run_id: str, request: CancelRunRequest | None = None) -> CancelRunResponse:
"""Cancel one run through ``POST /runs/{run_id}/cancel`` and normalize exceptions."""
try:
return self.client.cancel_run_sync(run_id, request=request)
except Exception as exc:
raise _normalize_dify_agent_error(exc) from exc
def stream_events(self, run_id: str, *, after: str | None = None) -> Iterator[RunEvent]:
"""Stream run events from ``/events/sse`` with the wrapped client's reconnect policy."""
try:
yield from self.client.stream_events_sync(run_id, after=after)
except Exception as exc:
raise _normalize_dify_agent_error(exc) from exc
def wait_run(self, run_id: str, *, timeout_seconds: float | None = None) -> RunStatusResponse:
"""Poll run status until terminal state and normalize client exceptions."""
try:
return self.client.wait_run_sync(run_id, timeout_seconds=timeout_seconds)
except Exception as exc:
raise _normalize_dify_agent_error(exc) from exc
def _normalize_dify_agent_error(exc: Exception) -> AgentBackendError:
"""Map public ``dify-agent`` client errors to API-side integration errors."""
if isinstance(exc, DifyAgentValidationError):
return AgentBackendValidationError("Agent backend request or response validation failed", detail=exc.detail)
if isinstance(exc, DifyAgentHTTPError):
return AgentBackendHTTPError(
f"Agent backend HTTP {exc.status_code}",
status_code=exc.status_code,
detail=exc.detail,
)
if isinstance(exc, DifyAgentTimeoutError):
return AgentBackendTransportError(str(exc))
if isinstance(exc, DifyAgentStreamError):
return AgentBackendStreamError(str(exc))
if isinstance(exc, DifyAgentClientError):
return AgentBackendTransportError(str(exc))
if isinstance(exc, AgentBackendError):
return exc
return AgentBackendTransportError(str(exc) or type(exc).__name__)

View File

@ -1,61 +0,0 @@
"""API-side errors for the Dify Agent backend integration.
The wire protocol and low-level HTTP behaviour are owned by ``dify-agent``.
This module only normalizes those client errors into the API backend's boundary
so workflow/node code does not depend directly on transport-specific exception
classes.
"""
from __future__ import annotations
from typing import Any
class AgentBackendError(Exception):
"""Base error for API-side Agent backend integration failures."""
class AgentBackendRequestBuildError(AgentBackendError):
"""Raised when Dify product/workflow state cannot be mapped to a run request."""
class AgentBackendTransportError(AgentBackendError):
"""Raised for timeout or request-level failures talking to Agent backend."""
class AgentBackendHTTPError(AgentBackendTransportError):
"""Raised for Agent backend HTTP errors after status/detail normalization."""
status_code: int
detail: object
def __init__(self, message: str, *, status_code: int, detail: object) -> None:
self.status_code = status_code
self.detail = detail
super().__init__(message)
class AgentBackendValidationError(AgentBackendError):
"""Raised for local request validation or Agent backend 422 responses."""
detail: object
def __init__(self, message: str, *, detail: object) -> None:
self.detail = detail
super().__init__(message)
class AgentBackendStreamError(AgentBackendError):
"""Raised when an Agent backend event stream is malformed or exhausted."""
class AgentBackendRunFailedError(AgentBackendError):
"""Raised by callers that choose to translate a terminal failed run into an exception."""
run_id: str
detail: Any
def __init__(self, run_id: str, detail: Any) -> None:
self.run_id = run_id
self.detail = detail
super().__init__(f"Agent backend run failed: {run_id}")

View File

@ -1,167 +0,0 @@
"""Adapt public ``dify-agent`` run events into API-internal event semantics.
The adapter does not define a new cross-service event contract. It consumes
``dify_agent.protocol.RunEvent`` and produces small API-internal models that the
future workflow Agent Node can map to Graphon/AppQueue events in phase 3.
"""
from __future__ import annotations
from enum import StrEnum
from typing import Annotated, Literal, cast
from agenton.compositor import CompositorSessionSnapshot
from dify_agent.protocol import (
PydanticAIStreamRunEvent,
RunCancelledEvent,
RunEvent,
RunFailedEvent,
RunPausedEvent,
RunStartedEvent,
RunSucceededEvent,
)
from pydantic import BaseModel, ConfigDict, Field, JsonValue, TypeAdapter
_EVENT_DATA_ADAPTER = TypeAdapter(object)
class AgentBackendInternalEventType(StrEnum):
"""API-only event labels used before Graphon/AppQueue integration."""
RUN_STARTED = "run_started"
STREAM_EVENT = "stream_event"
RUN_PAUSED = "run_paused"
RUN_SUCCEEDED = "run_succeeded"
RUN_FAILED = "run_failed"
RUN_CANCELLED = "run_cancelled"
class AgentBackendInternalEventBase(BaseModel):
"""Common fields preserved from public Dify Agent run events."""
run_id: str
source_event_id: str | None = None
model_config = ConfigDict(extra="forbid", arbitrary_types_allowed=True)
class AgentBackendRunStartedInternalEvent(AgentBackendInternalEventBase):
"""API-internal marker for a started Agent backend run."""
type: Literal[AgentBackendInternalEventType.RUN_STARTED] = AgentBackendInternalEventType.RUN_STARTED
class AgentBackendStreamInternalEvent(AgentBackendInternalEventBase):
"""API-internal wrapper for one pydantic-ai stream event payload."""
type: Literal[AgentBackendInternalEventType.STREAM_EVENT] = AgentBackendInternalEventType.STREAM_EVENT
event_kind: str | None = None
data: JsonValue
class AgentBackendRunSucceededInternalEvent(AgentBackendInternalEventBase):
"""API-internal terminal success event carrying final output and session state."""
type: Literal[AgentBackendInternalEventType.RUN_SUCCEEDED] = AgentBackendInternalEventType.RUN_SUCCEEDED
output: JsonValue
session_snapshot: CompositorSessionSnapshot
class AgentBackendRunPausedInternalEvent(AgentBackendInternalEventBase):
"""API-internal resumable pause event for human handoff and Babysit flows."""
type: Literal[AgentBackendInternalEventType.RUN_PAUSED] = AgentBackendInternalEventType.RUN_PAUSED
reason: str
message: str | None = None
session_snapshot: CompositorSessionSnapshot | None = None
class AgentBackendRunFailedInternalEvent(AgentBackendInternalEventBase):
"""API-internal terminal failure event carrying the backend-safe error text."""
type: Literal[AgentBackendInternalEventType.RUN_FAILED] = AgentBackendInternalEventType.RUN_FAILED
error: str
reason: str | None = None
class AgentBackendRunCancelledInternalEvent(AgentBackendInternalEventBase):
"""API-internal terminal cancellation event."""
type: Literal[AgentBackendInternalEventType.RUN_CANCELLED] = AgentBackendInternalEventType.RUN_CANCELLED
reason: str | None = None
message: str | None = None
type AgentBackendInternalEvent = Annotated[
AgentBackendRunStartedInternalEvent
| AgentBackendStreamInternalEvent
| AgentBackendRunPausedInternalEvent
| AgentBackendRunSucceededInternalEvent
| AgentBackendRunFailedInternalEvent
| AgentBackendRunCancelledInternalEvent,
Field(discriminator="type"),
]
class AgentBackendRunEventAdapter:
"""Maps public ``dify-agent`` event variants to API-internal event variants."""
def adapt(self, event: RunEvent) -> list[AgentBackendInternalEvent]:
"""Return zero or more API-internal events derived from one public run event."""
match event:
case RunStartedEvent():
return [
AgentBackendRunStartedInternalEvent(
run_id=event.run_id,
source_event_id=event.id,
)
]
case PydanticAIStreamRunEvent():
data = cast(JsonValue, _EVENT_DATA_ADAPTER.dump_python(event.data, mode="json"))
event_kind = data.get("event_kind") if isinstance(data, dict) else None
return [
AgentBackendStreamInternalEvent(
run_id=event.run_id,
source_event_id=event.id,
event_kind=event_kind if isinstance(event_kind, str) else None,
data=data,
)
]
case RunSucceededEvent():
return [
AgentBackendRunSucceededInternalEvent(
run_id=event.run_id,
source_event_id=event.id,
output=event.data.output,
session_snapshot=event.data.session_snapshot,
)
]
case RunPausedEvent():
return [
AgentBackendRunPausedInternalEvent(
run_id=event.run_id,
source_event_id=event.id,
reason=event.data.reason,
message=event.data.message,
session_snapshot=event.data.session_snapshot,
)
]
case RunFailedEvent():
return [
AgentBackendRunFailedInternalEvent(
run_id=event.run_id,
source_event_id=event.id,
error=event.data.error,
reason=event.data.reason,
)
]
case RunCancelledEvent():
return [
AgentBackendRunCancelledInternalEvent(
run_id=event.run_id,
source_event_id=event.id,
reason=event.data.reason,
message=event.data.message,
)
]
raise TypeError(f"unsupported agent backend run event: {type(event).__name__}")

View File

@ -1,22 +0,0 @@
"""Factories for API-side Agent backend clients."""
from __future__ import annotations
from dify_agent.client import Client
from clients.agent_backend.client import AgentBackendRunClient, DifyAgentBackendRunClient
from clients.agent_backend.fake_client import FakeAgentBackendRunClient, FakeAgentBackendScenario
def create_agent_backend_run_client(
*,
base_url: str | None = None,
use_fake: bool = False,
fake_scenario: str | FakeAgentBackendScenario = FakeAgentBackendScenario.SUCCESS,
) -> AgentBackendRunClient:
"""Create the API-side run client without hiding the ``dify-agent`` protocol."""
if use_fake:
return FakeAgentBackendRunClient(scenario=FakeAgentBackendScenario(fake_scenario))
if base_url is None:
raise ValueError("base_url is required when creating a real Agent backend client")
return DifyAgentBackendRunClient(Client(base_url=base_url))

View File

@ -1,117 +0,0 @@
"""Deterministic fake Agent backend client using public ``dify-agent`` events.
Tests should exercise the same ``RunEvent`` DTOs as the real HTTP client. This
fake therefore replaces the previous custom mock protocol instead of emulating a
separate ``agent-backend.v1`` event stream.
"""
from __future__ import annotations
from collections.abc import Iterator
from datetime import UTC, datetime
from enum import StrEnum
from agenton.compositor import CompositorSessionSnapshot
from dify_agent.protocol import (
CancelRunRequest,
CancelRunResponse,
CreateRunRequest,
CreateRunResponse,
RunEvent,
RunFailedEvent,
RunFailedEventData,
RunStartedEvent,
RunStatusResponse,
RunSucceededEvent,
RunSucceededEventData,
)
_FIXED_TIME = datetime(2026, 1, 1, tzinfo=UTC)
class FakeAgentBackendScenario(StrEnum):
"""Deterministic fake scenarios for API-side integration tests."""
SUCCESS = "success"
FAILED = "failed"
class FakeAgentBackendRunClient:
"""In-memory implementation of ``AgentBackendRunClient`` for unit tests."""
scenario: FakeAgentBackendScenario
run_id: str
request: CreateRunRequest | None
def __init__(
self,
*,
scenario: FakeAgentBackendScenario = FakeAgentBackendScenario.SUCCESS,
run_id: str = "fake-run-1",
) -> None:
self.scenario = scenario
self.run_id = run_id
self.request = None
def create_run(self, request: CreateRunRequest) -> CreateRunResponse:
"""Record the request and return a deterministic accepted response."""
self.request = request
return CreateRunResponse(run_id=self.run_id, status="running")
def cancel_run(self, run_id: str, request: CancelRunRequest | None = None) -> CancelRunResponse:
"""Return a deterministic cancellation response."""
del request
return CancelRunResponse(run_id=run_id, status="cancelled")
def stream_events(self, run_id: str, *, after: str | None = None) -> Iterator[RunEvent]:
"""Yield the deterministic public ``RunEvent`` sequence for ``run_id``."""
for event in self._events(run_id):
if after is not None and event.id is not None and event.id <= after:
continue
yield event
def wait_run(self, run_id: str, *, timeout_seconds: float | None = None) -> RunStatusResponse:
"""Return a deterministic terminal status; timeout is accepted for protocol parity."""
del timeout_seconds
match self.scenario:
case FakeAgentBackendScenario.SUCCESS:
return RunStatusResponse(
run_id=run_id,
status="succeeded",
created_at=_FIXED_TIME,
updated_at=_FIXED_TIME,
)
case FakeAgentBackendScenario.FAILED:
return RunStatusResponse(
run_id=run_id,
status="failed",
created_at=_FIXED_TIME,
updated_at=_FIXED_TIME,
error="fake failure",
)
def _events(self, run_id: str) -> tuple[RunEvent, ...]:
match self.scenario:
case FakeAgentBackendScenario.SUCCESS:
return (
RunStartedEvent(id="1-0", run_id=run_id, created_at=_FIXED_TIME),
RunSucceededEvent(
id="2-0",
run_id=run_id,
created_at=_FIXED_TIME,
data=RunSucceededEventData(
output={"text": "hello agent"},
session_snapshot=CompositorSessionSnapshot(layers=[]),
),
),
)
case FakeAgentBackendScenario.FAILED:
return (
RunStartedEvent(id="1-0", run_id=run_id, created_at=_FIXED_TIME),
RunFailedEvent(
id="2-0",
run_id=run_id,
created_at=_FIXED_TIME,
data=RunFailedEventData(error="fake failure", reason="unit_test"),
),
)

View File

@ -1,192 +0,0 @@
"""Build ``dify-agent`` run requests from API-side product concepts.
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.
"""
from __future__ import annotations
from typing import ClassVar
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.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,
RunPurpose,
)
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"
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_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
class AgentBackendOutputConfig(BaseModel):
"""API-side structured output declaration for the conventional output layer."""
json_schema: dict[str, JsonValue]
name: str = "final_result"
description: str | None = None
strict: bool | None = None
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
class AgentBackendWorkflowNodeRunInput(BaseModel):
"""Inputs needed to build the first workflow-node-oriented Agent backend run request."""
model: AgentBackendModelConfig
execution_context: ExecutionContext
workflow_node_job_prompt: str
user_prompt: str
agent_soul_prompt: str | None = None
purpose: RunPurpose = "workflow_node"
idempotency_key: str | None = None
output: AgentBackendOutputConfig | None = None
session_snapshot: CompositorSessionSnapshot | None = None
suspend_on_exit: bool = False
metadata: dict[str, JsonValue] = Field(default_factory=dict)
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid", arbitrary_types_allowed=True)
@field_validator("workflow_node_job_prompt", "user_prompt")
@classmethod
def _reject_blank_prompt(cls, value: str) -> str:
if not value.strip():
raise ValueError("prompt must not be blank")
return value
class AgentBackendRunRequestBuilder:
"""Converts API product state into the public ``dify-agent`` run protocol."""
def build_for_workflow_node(self, run_input: AgentBackendWorkflowNodeRunInput) -> CreateRunRequest:
"""Build a workflow Agent Node run request without defining another wire schema."""
layers: list[RunLayerSpec] = []
if run_input.agent_soul_prompt:
layers.append(
RunLayerSpec(
name=AGENT_SOUL_PROMPT_LAYER_ID,
type=PLAIN_PROMPT_LAYER_TYPE_ID,
metadata={**run_input.metadata, "origin": "agent_soul"},
config=PromptLayerConfig(prefix=run_input.agent_soul_prompt),
)
)
layers.extend(
[
RunLayerSpec(
name=WORKFLOW_NODE_JOB_PROMPT_LAYER_ID,
type=PLAIN_PROMPT_LAYER_TYPE_ID,
metadata={**run_input.metadata, "origin": "workflow_node_job"},
config=PromptLayerConfig(prefix=run_input.workflow_node_job_prompt),
),
RunLayerSpec(
name=WORKFLOW_USER_PROMPT_LAYER_ID,
type=PLAIN_PROMPT_LAYER_TYPE_ID,
metadata={**run_input.metadata, "origin": "workflow_user_prompt"},
config=PromptLayerConfig(user=run_input.user_prompt),
),
RunLayerSpec(
name=DIFY_PLUGIN_CONTEXT_LAYER_ID,
type=DIFY_PLUGIN_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,
),
),
RunLayerSpec(
name=DIFY_AGENT_MODEL_LAYER_ID,
type=DIFY_PLUGIN_LLM_LAYER_TYPE_ID,
deps={"plugin": DIFY_PLUGIN_CONTEXT_LAYER_ID},
metadata=run_input.metadata,
config=DifyPluginLLMLayerConfig(
model_provider=run_input.model.model_provider,
model=run_input.model.model,
credentials=run_input.model.credentials,
),
),
]
)
if run_input.output is not None:
layers.append(
RunLayerSpec(
name=DIFY_AGENT_OUTPUT_LAYER_ID,
type=DIFY_OUTPUT_LAYER_TYPE_ID,
metadata=run_input.metadata,
config=DifyOutputLayerConfig(
json_schema=run_input.output.json_schema,
name=run_input.output.name,
description=run_input.output.description,
strict=run_input.output.strict,
),
)
)
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,
session_snapshot=run_input.session_snapshot,
on_exit=LayerExitSignals(
default=ExitIntent.SUSPEND if run_input.suspend_on_exit else ExitIntent.DELETE,
),
)
_SENSITIVE_KEY_PARTS = ("secret", "credential", "token", "password", "api_key")
def redact_for_agent_backend_log(value: object) -> object:
"""Return a JSON-like copy with credential-bearing keys redacted for logs/tests."""
if isinstance(value, BaseModel):
return redact_for_agent_backend_log(value.model_dump(mode="json", warnings=False))
if isinstance(value, dict):
redacted: dict[object, object] = {}
for key, item in value.items():
key_text = str(key).lower()
if any(part in key_text for part in _SENSITIVE_KEY_PARTS):
redacted[key] = "[REDACTED]"
else:
redacted[key] = redact_for_agent_backend_log(item)
return redacted
if isinstance(value, list):
return [redact_for_agent_backend_log(item) for item in value]
return value

View File

@ -1,159 +0,0 @@
"""add agent domain models
Revision ID: c6a9f4b12d3e
Revises: a4f2d8c9b731
Create Date: 2026-05-18 13:30:00.000000
"""
import sqlalchemy as sa
from alembic import op
import models
# revision identifiers, used by Alembic.
revision = "c6a9f4b12d3e"
down_revision = "a4f2d8c9b731"
branch_labels = None
depends_on = None
def _is_pg(conn) -> bool:
return conn.dialect.name == "postgresql"
def _uuid_column(name: str, *, nullable: bool = False, primary_key: bool = False) -> sa.Column:
kwargs = {"nullable": nullable, "primary_key": primary_key}
if primary_key and _is_pg(op.get_bind()):
kwargs["server_default"] = sa.text("uuidv7()")
return sa.Column(name, models.types.StringUUID(), **kwargs)
def upgrade():
op.create_table(
"agents",
_uuid_column("id", primary_key=True),
sa.Column("tenant_id", models.types.StringUUID(), nullable=False),
sa.Column("name", sa.String(length=255), nullable=False),
sa.Column("description", models.types.LongText(), server_default=sa.text("''"), nullable=False),
sa.Column("icon_type", sa.String(length=255), nullable=True),
sa.Column("icon", sa.String(length=255), nullable=True),
sa.Column("icon_background", sa.String(length=255), nullable=True),
sa.Column("agent_kind", sa.String(length=32), server_default=sa.text("'dify_agent'"), nullable=False),
sa.Column("scope", sa.String(length=32), nullable=False),
sa.Column("source", sa.String(length=32), nullable=False),
sa.Column("app_id", models.types.StringUUID(), nullable=True),
sa.Column("workflow_id", models.types.StringUUID(), nullable=True),
sa.Column("workflow_node_id", sa.String(length=255), nullable=True),
sa.Column("active_config_version_id", models.types.StringUUID(), nullable=True),
sa.Column("status", sa.String(length=32), server_default=sa.text("'active'"), nullable=False),
sa.Column(
"roster_unique_name",
sa.String(length=255),
sa.Computed("CASE WHEN scope = 'roster' AND status = 'active' THEN name ELSE NULL END"),
nullable=True,
),
sa.Column("created_by", models.types.StringUUID(), nullable=True),
sa.Column("updated_by", models.types.StringUUID(), nullable=True),
sa.Column("archived_by", models.types.StringUUID(), nullable=True),
sa.Column("archived_at", sa.DateTime(), nullable=True),
sa.Column("created_at", sa.DateTime(), server_default=sa.func.current_timestamp(), nullable=False),
sa.Column("updated_at", sa.DateTime(), server_default=sa.func.current_timestamp(), nullable=False),
sa.PrimaryKeyConstraint("id", name=op.f("agent_pkey")),
sa.UniqueConstraint("tenant_id", "roster_unique_name", name=op.f("agent_tenant_roster_name_unique")),
)
op.create_index("agent_tenant_status_updated_at_idx", "agents", ["tenant_id", "status", "updated_at"])
op.create_index("agent_tenant_scope_status_idx", "agents", ["tenant_id", "scope", "status"])
op.create_index("agent_tenant_workflow_id_idx", "agents", ["tenant_id", "workflow_id"])
op.create_index("agent_tenant_app_id_idx", "agents", ["tenant_id", "app_id"])
op.create_index("agent_active_config_version_id_idx", "agents", ["active_config_version_id"])
op.create_table(
"agent_config_versions",
_uuid_column("id", primary_key=True),
sa.Column("tenant_id", models.types.StringUUID(), nullable=False),
sa.Column("agent_id", models.types.StringUUID(), nullable=False),
sa.Column("version", sa.Integer(), nullable=False),
sa.Column("config_snapshot", models.types.LongText(), server_default=sa.text("'{}'"), nullable=False),
sa.Column("summary", models.types.LongText(), nullable=True),
sa.Column("version_note", models.types.LongText(), nullable=True),
sa.Column("created_by", models.types.StringUUID(), nullable=True),
sa.Column("created_at", sa.DateTime(), server_default=sa.func.current_timestamp(), nullable=False),
sa.PrimaryKeyConstraint("id", name=op.f("agent_config_version_pkey")),
sa.UniqueConstraint("agent_id", "version", name=op.f("agent_config_version_agent_version_unique")),
)
op.create_index(
"agent_config_version_tenant_agent_created_at_idx",
"agent_config_versions",
["tenant_id", "agent_id", "created_at"],
)
op.create_index(
"agent_config_version_tenant_created_at_idx",
"agent_config_versions",
["tenant_id", "created_at"],
)
op.create_table(
"workflow_agent_node_bindings",
_uuid_column("id", primary_key=True),
sa.Column("tenant_id", models.types.StringUUID(), nullable=False),
sa.Column("app_id", models.types.StringUUID(), nullable=False),
sa.Column("workflow_id", models.types.StringUUID(), nullable=False),
sa.Column("workflow_version", sa.String(length=255), nullable=False),
sa.Column("node_id", sa.String(length=255), nullable=False),
sa.Column("binding_type", sa.String(length=32), nullable=False),
sa.Column("agent_id", models.types.StringUUID(), nullable=True),
sa.Column("agent_config_version_id", models.types.StringUUID(), nullable=True),
sa.Column("node_job_config", models.types.LongText(), server_default=sa.text("'{}'"), nullable=False),
sa.Column("created_by", models.types.StringUUID(), nullable=True),
sa.Column("updated_by", models.types.StringUUID(), nullable=True),
sa.Column("created_at", sa.DateTime(), server_default=sa.func.current_timestamp(), nullable=False),
sa.Column("updated_at", sa.DateTime(), server_default=sa.func.current_timestamp(), nullable=False),
sa.PrimaryKeyConstraint("id", name=op.f("workflow_agent_node_binding_pkey")),
sa.UniqueConstraint(
"tenant_id",
"workflow_id",
"workflow_version",
"node_id",
name=op.f("workflow_agent_node_binding_node_unique"),
),
)
op.create_index(
"workflow_agent_node_binding_workflow_idx",
"workflow_agent_node_bindings",
["tenant_id", "workflow_id", "workflow_version"],
)
op.create_index(
"workflow_agent_node_binding_agent_idx",
"workflow_agent_node_bindings",
["tenant_id", "agent_id"],
)
op.create_index(
"workflow_agent_node_binding_config_version_idx",
"workflow_agent_node_bindings",
["tenant_id", "agent_config_version_id"],
)
op.create_index(
"workflow_agent_node_binding_app_idx",
"workflow_agent_node_bindings",
["tenant_id", "app_id"],
)
def downgrade():
op.drop_index("workflow_agent_node_binding_app_idx", table_name="workflow_agent_node_bindings")
op.drop_index("workflow_agent_node_binding_config_version_idx", table_name="workflow_agent_node_bindings")
op.drop_index("workflow_agent_node_binding_agent_idx", table_name="workflow_agent_node_bindings")
op.drop_index("workflow_agent_node_binding_workflow_idx", table_name="workflow_agent_node_bindings")
op.drop_table("workflow_agent_node_bindings")
op.drop_index("agent_config_version_tenant_created_at_idx", table_name="agent_config_versions")
op.drop_index("agent_config_version_tenant_agent_created_at_idx", table_name="agent_config_versions")
op.drop_table("agent_config_versions")
op.drop_index("agent_active_config_version_id_idx", table_name="agents")
op.drop_index("agent_tenant_app_id_idx", table_name="agents")
op.drop_index("agent_tenant_workflow_id_idx", table_name="agents")
op.drop_index("agent_tenant_scope_status_idx", table_name="agents")
op.drop_index("agent_tenant_status_updated_at_idx", table_name="agents")
op.drop_table("agents")

View File

@ -8,16 +8,6 @@ from .account import (
TenantAccountRole,
TenantStatus,
)
from .agent import (
Agent,
AgentConfigVersion,
AgentKind,
AgentScope,
AgentSource,
AgentStatus,
WorkflowAgentBindingType,
WorkflowAgentNodeBinding,
)
from .api_based_extension import APIBasedExtension, APIBasedExtensionPoint
from .comment import (
WorkflowComment,
@ -135,12 +125,6 @@ __all__ = [
"AccountIntegrate",
"AccountStatus",
"AccountTrialAppRecord",
"Agent",
"AgentConfigVersion",
"AgentKind",
"AgentScope",
"AgentSource",
"AgentStatus",
"ApiRequest",
"ApiToken",
"ApiToolProvider",
@ -226,8 +210,6 @@ __all__ = [
"UploadFile",
"Whitelist",
"Workflow",
"WorkflowAgentBindingType",
"WorkflowAgentNodeBinding",
"WorkflowAppLog",
"WorkflowAppLogCreatedFrom",
"WorkflowArchiveLog",

View File

@ -1,160 +0,0 @@
import json
from datetime import datetime
from enum import StrEnum
from typing import Any
import sqlalchemy as sa
from sqlalchemy import DateTime, Index, String, UniqueConstraint, func
from sqlalchemy.orm import Mapped, mapped_column
from libs.datetime_utils import naive_utc_now
from libs.uuid_utils import uuidv7
from .base import Base, DefaultFieldsMixin
from .types import EnumText, LongText, StringUUID
class AgentKind(StrEnum):
DIFY_AGENT = "dify_agent"
class AgentScope(StrEnum):
ROSTER = "roster"
WORKFLOW_ONLY = "workflow_only"
class AgentSource(StrEnum):
AGENT_APP = "agent_app"
WORKFLOW = "workflow"
IMPORTED = "imported"
SYSTEM = "system"
class AgentStatus(StrEnum):
ACTIVE = "active"
ARCHIVED = "archived"
class WorkflowAgentBindingType(StrEnum):
ROSTER_AGENT = "roster_agent"
INLINE_AGENT = "inline_agent"
class Agent(DefaultFieldsMixin, Base):
"""Workspace-scoped Agent identity used by Agent Roster and workflow-only agents."""
__tablename__ = "agents"
__table_args__ = (
sa.PrimaryKeyConstraint("id", name="agent_pkey"),
UniqueConstraint("tenant_id", "roster_unique_name", name="agent_tenant_roster_name_unique"),
Index("agent_tenant_status_updated_at_idx", "tenant_id", "status", "updated_at"),
Index("agent_tenant_scope_status_idx", "tenant_id", "scope", "status"),
Index("agent_tenant_workflow_id_idx", "tenant_id", "workflow_id"),
Index("agent_tenant_app_id_idx", "tenant_id", "app_id"),
Index("agent_active_config_version_id_idx", "active_config_version_id"),
)
tenant_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
name: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[str] = mapped_column(LongText, nullable=False, default="")
icon_type: Mapped[str | None] = mapped_column(String(255), nullable=True)
icon: Mapped[str | None] = mapped_column(String(255), nullable=True)
icon_background: Mapped[str | None] = mapped_column(String(255), nullable=True)
agent_kind: Mapped[AgentKind] = mapped_column(
EnumText(AgentKind, length=32), nullable=False, default=AgentKind.DIFY_AGENT
)
scope: Mapped[AgentScope] = mapped_column(EnumText(AgentScope, length=32), nullable=False)
source: Mapped[AgentSource] = mapped_column(EnumText(AgentSource, length=32), nullable=False)
app_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True)
workflow_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True)
workflow_node_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
active_config_version_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True)
status: Mapped[AgentStatus] = mapped_column(
EnumText(AgentStatus, length=32), nullable=False, default=AgentStatus.ACTIVE
)
roster_unique_name: Mapped[str | None] = mapped_column(
String(255),
sa.Computed("CASE WHEN scope = 'roster' AND status = 'active' THEN name ELSE NULL END"),
nullable=True,
)
created_by: Mapped[str | None] = mapped_column(StringUUID, nullable=True)
updated_by: Mapped[str | None] = mapped_column(StringUUID, nullable=True)
archived_by: Mapped[str | None] = mapped_column(StringUUID, nullable=True)
archived_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
class AgentConfigVersion(Base):
"""Immutable Agent Soul snapshot version.
``config_snapshot`` is a JSON string stored as ``LongText``. It may contain
credential or secret references, but must never contain plaintext secrets.
"""
__tablename__ = "agent_config_versions"
__table_args__ = (
sa.PrimaryKeyConstraint("id", name="agent_config_version_pkey"),
UniqueConstraint("agent_id", "version", name="agent_config_version_agent_version_unique"),
Index("agent_config_version_tenant_agent_created_at_idx", "tenant_id", "agent_id", "created_at"),
Index("agent_config_version_tenant_created_at_idx", "tenant_id", "created_at"),
)
id: Mapped[str] = mapped_column(StringUUID, primary_key=True, default=lambda: str(uuidv7()))
tenant_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
agent_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
version: Mapped[int] = mapped_column(sa.Integer, nullable=False)
config_snapshot: Mapped[str] = mapped_column(LongText, nullable=False, default="{}")
summary: Mapped[str | None] = mapped_column(LongText, nullable=True)
version_note: Mapped[str | None] = mapped_column(LongText, nullable=True)
created_by: Mapped[str | None] = mapped_column(StringUUID, nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime,
nullable=False,
default=naive_utc_now,
server_default=func.current_timestamp(),
)
@property
def config_snapshot_dict(self) -> dict[str, Any]:
return json.loads(self.config_snapshot) if self.config_snapshot else {}
class WorkflowAgentNodeBinding(DefaultFieldsMixin, Base):
"""Binding between one workflow node and one Agent config version.
``node_job_config`` stores Workflow Node Job JSON only. Agent Soul belongs
to ``AgentConfigVersion.config_snapshot`` and must not be duplicated here.
"""
__tablename__ = "workflow_agent_node_bindings"
__table_args__ = (
sa.PrimaryKeyConstraint("id", name="workflow_agent_node_binding_pkey"),
UniqueConstraint(
"tenant_id",
"workflow_id",
"workflow_version",
"node_id",
name="workflow_agent_node_binding_node_unique",
),
Index("workflow_agent_node_binding_workflow_idx", "tenant_id", "workflow_id", "workflow_version"),
Index("workflow_agent_node_binding_agent_idx", "tenant_id", "agent_id"),
Index("workflow_agent_node_binding_config_version_idx", "tenant_id", "agent_config_version_id"),
Index("workflow_agent_node_binding_app_idx", "tenant_id", "app_id"),
)
tenant_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
app_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
workflow_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
workflow_version: Mapped[str] = mapped_column(String(255), nullable=False)
node_id: Mapped[str] = mapped_column(String(255), nullable=False)
binding_type: Mapped[WorkflowAgentBindingType] = mapped_column(
EnumText(WorkflowAgentBindingType, length=32), nullable=False
)
agent_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True)
agent_config_version_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True)
node_job_config: Mapped[str] = mapped_column(LongText, nullable=False, default="{}")
created_by: Mapped[str | None] = mapped_column(StringUUID, nullable=True)
updated_by: Mapped[str | None] = mapped_column(StringUUID, nullable=True)
@property
def node_job_config_dict(self) -> dict[str, Any]:
return json.loads(self.node_job_config) if self.node_job_config else {}

View File

@ -6,10 +6,9 @@ requires-python = "~=3.12.0"
dependencies = [
# Legacy: mature and widely deployed
"bleach>=6.3.0",
"boto3>=1.43.6",
"boto3>=1.43.9",
"celery>=5.6.3",
"croniter>=6.2.2",
"dify-agent",
"flask>=3.1.3,<4.0.0",
"flask-cors>=6.0.2",
"gevent>=26.4.0",
@ -115,6 +114,7 @@ override-dependencies = [
############################################################
dev = [
"coverage>=7.13.4",
"dify-agent",
"dotenv-linter>=0.7.0",
"faker>=40.15.0",
"lxml-stubs>=0.5.1",
@ -183,9 +183,9 @@ dev = [
# Required for storage clients
############################################################
storage = [
"azure-storage-blob>=12.28.0",
"azure-storage-blob>=12.29.0",
"bce-python-sdk>=0.9.71",
"cos-python-sdk-v5>=1.9.42",
"cos-python-sdk-v5>=1.9.43",
"esdk-obs-python>=3.22.2",
"google-cloud-storage>=3.10.1",
"opendal>=0.46.0",

View File

@ -1 +0,0 @@
"""Client unit tests."""

View File

@ -1 +0,0 @@
"""Agent backend client contract tests."""

View File

@ -1,126 +0,0 @@
from collections.abc import Iterator
import pytest
from dify_agent.client import DifyAgentHTTPError, DifyAgentStreamError, DifyAgentTimeoutError, DifyAgentValidationError
from dify_agent.protocol import (
CancelRunRequest,
CancelRunResponse,
CreateRunRequest,
CreateRunResponse,
ExecutionContext,
RunEvent,
RunStartedEvent,
RunStatusResponse,
)
from clients.agent_backend import (
AgentBackendHTTPError,
AgentBackendModelConfig,
AgentBackendRunRequestBuilder,
AgentBackendStreamError,
AgentBackendTransportError,
AgentBackendValidationError,
AgentBackendWorkflowNodeRunInput,
DifyAgentBackendRunClient,
)
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"),
workflow_node_job_prompt="Do the task.",
user_prompt="hello",
)
)
class _SuccessfulClient:
def create_run_sync(self, request: CreateRunRequest) -> CreateRunResponse:
assert isinstance(request, CreateRunRequest)
return CreateRunResponse(run_id="run-1", status="running")
def cancel_run_sync(self, run_id: str, request: CancelRunRequest | None = None) -> CancelRunResponse:
del request
return CancelRunResponse(run_id=run_id, status="cancelled")
def stream_events_sync(self, run_id: str, *, after: str | None = None) -> Iterator[RunEvent]:
del after
yield RunStartedEvent(id="1-0", run_id=run_id)
def wait_run_sync(self, run_id: str, *, timeout_seconds: float | None = None) -> RunStatusResponse:
del timeout_seconds
return RunStatusResponse.model_validate(
{
"run_id": run_id,
"status": "succeeded",
"created_at": "2026-01-01T00:00:00+00:00",
"updated_at": "2026-01-01T00:00:00+00:00",
}
)
def test_dify_agent_backend_run_client_delegates_sync_methods():
client = DifyAgentBackendRunClient(_SuccessfulClient())
created = client.create_run(_request())
cancelled = client.cancel_run(created.run_id)
events = list(client.stream_events(created.run_id))
status = client.wait_run(created.run_id)
assert created.run_id == "run-1"
assert cancelled.status == "cancelled"
assert events[0].type == "run_started"
assert status.status == "succeeded"
def test_dify_agent_backend_run_client_maps_validation_error():
class InvalidClient(_SuccessfulClient):
def create_run_sync(self, request: CreateRunRequest) -> CreateRunResponse:
raise DifyAgentValidationError(detail={"field": "bad"})
with pytest.raises(AgentBackendValidationError) as exc_info:
DifyAgentBackendRunClient(InvalidClient()).create_run(_request())
assert exc_info.value.detail == {"field": "bad"}
def test_dify_agent_backend_run_client_maps_http_error():
class HTTPErrorClient(_SuccessfulClient):
def create_run_sync(self, request: CreateRunRequest) -> CreateRunResponse:
raise DifyAgentHTTPError(status_code=503, detail="unavailable")
with pytest.raises(AgentBackendHTTPError) as exc_info:
DifyAgentBackendRunClient(HTTPErrorClient()).create_run(_request())
assert exc_info.value.status_code == 503
assert exc_info.value.detail == "unavailable"
def test_dify_agent_backend_run_client_maps_timeout_error():
class TimeoutClient(_SuccessfulClient):
def wait_run_sync(self, run_id: str, *, timeout_seconds: float | None = None) -> RunStatusResponse:
raise DifyAgentTimeoutError("timeout")
with pytest.raises(AgentBackendTransportError) as exc_info:
DifyAgentBackendRunClient(TimeoutClient()).wait_run("run-1")
assert str(exc_info.value) == "timeout"
def test_dify_agent_backend_run_client_maps_stream_error():
class StreamClient(_SuccessfulClient):
def stream_events_sync(self, run_id: str, *, after: str | None = None) -> Iterator[RunEvent]:
raise DifyAgentStreamError("bad stream")
yield
with pytest.raises(AgentBackendStreamError) as exc_info:
list(DifyAgentBackendRunClient(StreamClient()).stream_events("run-1"))
assert str(exc_info.value) == "bad stream"

View File

@ -1,132 +0,0 @@
from agenton.compositor import CompositorSessionSnapshot
from dify_agent.protocol import (
PydanticAIStreamRunEvent,
RunCancelledEvent,
RunCancelledEventData,
RunFailedEvent,
RunFailedEventData,
RunPausedEvent,
RunPausedEventData,
RunStartedEvent,
RunSucceededEvent,
RunSucceededEventData,
)
from pydantic_ai.messages import FinalResultEvent
from clients.agent_backend import (
AgentBackendInternalEventType,
AgentBackendRunCancelledInternalEvent,
AgentBackendRunEventAdapter,
AgentBackendRunFailedInternalEvent,
AgentBackendRunPausedInternalEvent,
AgentBackendRunStartedInternalEvent,
AgentBackendRunSucceededInternalEvent,
AgentBackendStreamInternalEvent,
)
def test_event_adapter_maps_run_started():
adapted = AgentBackendRunEventAdapter().adapt(RunStartedEvent(id="1-0", run_id="run-1"))
assert adapted == [
AgentBackendRunStartedInternalEvent(
run_id="run-1",
source_event_id="1-0",
)
]
def test_event_adapter_maps_pydantic_ai_stream_event():
adapted = AgentBackendRunEventAdapter().adapt(
PydanticAIStreamRunEvent(
id="2-0",
run_id="run-1",
data=FinalResultEvent(tool_name=None, tool_call_id=None),
)
)
assert len(adapted) == 1
event = adapted[0]
assert isinstance(event, AgentBackendStreamInternalEvent)
assert event.type == AgentBackendInternalEventType.STREAM_EVENT
assert event.event_kind == "final_result"
assert event.data["event_kind"] == "final_result"
def test_event_adapter_maps_run_succeeded_to_final_output():
snapshot = CompositorSessionSnapshot(layers=[])
adapted = AgentBackendRunEventAdapter().adapt(
RunSucceededEvent(
id="3-0",
run_id="run-1",
data=RunSucceededEventData(output={"summary": "done"}, session_snapshot=snapshot),
)
)
assert adapted == [
AgentBackendRunSucceededInternalEvent(
run_id="run-1",
source_event_id="3-0",
output={"summary": "done"},
session_snapshot=snapshot,
)
]
def test_event_adapter_maps_run_failed_to_failed_result():
adapted = AgentBackendRunEventAdapter().adapt(
RunFailedEvent(
id="4-0",
run_id="run-1",
data=RunFailedEventData(error="boom", reason="runtime"),
)
)
assert adapted == [
AgentBackendRunFailedInternalEvent(
run_id="run-1",
source_event_id="4-0",
error="boom",
reason="runtime",
)
]
def test_event_adapter_maps_run_paused_to_resumable_pause():
snapshot = CompositorSessionSnapshot(layers=[])
adapted = AgentBackendRunEventAdapter().adapt(
RunPausedEvent(
id="5-0",
run_id="run-1",
data=RunPausedEventData(reason="human_handoff", message="Need review", session_snapshot=snapshot),
)
)
assert adapted == [
AgentBackendRunPausedInternalEvent(
run_id="run-1",
source_event_id="5-0",
reason="human_handoff",
message="Need review",
session_snapshot=snapshot,
)
]
def test_event_adapter_maps_run_cancelled_to_terminal_cancelled():
adapted = AgentBackendRunEventAdapter().adapt(
RunCancelledEvent(
id="6-0",
run_id="run-1",
data=RunCancelledEventData(reason="user_cancelled", message="Stopped by user"),
)
)
assert adapted == [
AgentBackendRunCancelledInternalEvent(
run_id="run-1",
source_event_id="6-0",
reason="user_cancelled",
message="Stopped by user",
)
]

View File

@ -1,66 +0,0 @@
from dify_agent.protocol import ExecutionContext
from clients.agent_backend import (
AgentBackendModelConfig,
AgentBackendRunRequestBuilder,
AgentBackendWorkflowNodeRunInput,
FakeAgentBackendRunClient,
FakeAgentBackendScenario,
)
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"),
workflow_node_job_prompt="Do the task.",
user_prompt="hello",
)
)
def test_fake_client_stream_is_deterministic():
client = FakeAgentBackendRunClient()
request = _request()
created = client.create_run(request)
first = [event.model_dump(mode="json") for event in client.stream_events(created.run_id)]
second = [event.model_dump(mode="json") for event in client.stream_events(created.run_id)]
assert created.run_id == "fake-run-1"
assert client.request is request
assert first == second
assert [event["type"] for event in first] == ["run_started", "run_succeeded"]
assert first[-1]["data"]["output"] == {"text": "hello agent"}
def test_fake_client_stream_honors_cursor():
events = list(FakeAgentBackendRunClient().stream_events("fake-run-1", after="1-0"))
assert len(events) == 1
assert events[0].type == "run_succeeded"
def test_fake_client_failed_scenario_returns_failed_status_and_event():
client = FakeAgentBackendRunClient(scenario=FakeAgentBackendScenario.FAILED)
status = client.wait_run("fake-run-1")
events = list(client.stream_events("fake-run-1"))
assert status.status == "failed"
assert status.error == "fake failure"
assert events[-1].type == "run_failed"
assert events[-1].data.error == "fake failure"
def test_fake_client_cancel_run_returns_cancelled_status():
cancelled = FakeAgentBackendRunClient().cancel_run("fake-run-1")
assert cancelled.run_id == "fake-run-1"
assert cancelled.status == "cancelled"

View File

@ -1,132 +0,0 @@
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.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,
WORKFLOW_NODE_JOB_PROMPT_LAYER_ID,
WORKFLOW_USER_PROMPT_LAYER_ID,
AgentBackendModelConfig,
AgentBackendOutputConfig,
AgentBackendRunRequestBuilder,
AgentBackendWorkflowNodeRunInput,
redact_for_agent_backend_log,
)
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(
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",
),
idempotency_key="workflow-run-1:node-execution-1",
agent_soul_prompt="You are a careful reviewer.",
workflow_node_job_prompt="Review the previous node output.",
user_prompt="Summarize the report.",
output=AgentBackendOutputConfig(
json_schema={
"type": "object",
"properties": {"summary": {"type": "string"}},
"required": ["summary"],
}
),
metadata={"workflow_id": "workflow-1", "node_id": "node-1"},
)
def test_request_builder_outputs_dify_agent_create_run_request():
request = AgentBackendRunRequestBuilder().build_for_workflow_node(_run_input())
assert isinstance(request, CreateRunRequest)
assert [layer.name for layer in request.composition.layers] == [
AGENT_SOUL_PROMPT_LAYER_ID,
WORKFLOW_NODE_JOB_PROMPT_LAYER_ID,
WORKFLOW_USER_PROMPT_LAYER_ID,
"plugin",
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"}
def test_request_builder_separates_agent_soul_and_workflow_job_prompt():
request = AgentBackendRunRequestBuilder().build_for_workflow_node(_run_input())
layers = {layer.name: layer for layer in request.composition.layers}
assert layers[AGENT_SOUL_PROMPT_LAYER_ID].type == PLAIN_PROMPT_LAYER_TYPE_ID
assert layers[AGENT_SOUL_PROMPT_LAYER_ID].metadata["origin"] == "agent_soul"
assert layers[WORKFLOW_NODE_JOB_PROMPT_LAYER_ID].metadata["origin"] == "workflow_node_job"
assert layers[WORKFLOW_USER_PROMPT_LAYER_ID].metadata["origin"] == "workflow_user_prompt"
dumped = request.model_dump(mode="json")
assert dumped["composition"]["layers"][0]["config"]["prefix"] == "You are a careful reviewer."
assert dumped["composition"]["layers"][1]["config"]["prefix"] == "Review the previous node output."
assert dumped["composition"]["layers"][2]["config"]["user"] == "Summarize the report."
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_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_OUTPUT_LAYER_ID].type == DIFY_OUTPUT_LAYER_TYPE_ID
def test_request_builder_can_suspend_on_exit_for_resume_or_babysit_paths():
run_input = _run_input()
run_input.suspend_on_exit = True
request = AgentBackendRunRequestBuilder().build_for_workflow_node(run_input)
assert request.on_exit.default is ExitIntent.SUSPEND
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"),
workflow_node_job_prompt=" ",
user_prompt="hello",
)
def test_redact_for_agent_backend_log_hides_credentials():
request = AgentBackendRunRequestBuilder().build_for_workflow_node(_run_input())
redacted = redact_for_agent_backend_log(request)
assert redacted["composition"]["layers"][4]["config"]["credentials"] == "[REDACTED]"

View File

@ -1,141 +0,0 @@
import json
import pytest
import sqlalchemy as sa
from sqlalchemy.exc import IntegrityError
from models.agent import (
Agent,
AgentConfigVersion,
AgentKind,
AgentScope,
AgentSource,
AgentStatus,
WorkflowAgentBindingType,
WorkflowAgentNodeBinding,
)
from models.types import LongText
def test_agent_enums_match_prd_boundaries():
assert AgentKind.DIFY_AGENT.value == "dify_agent"
assert AgentScope.ROSTER.value == "roster"
assert AgentScope.WORKFLOW_ONLY.value == "workflow_only"
assert AgentSource.AGENT_APP.value == "agent_app"
assert AgentSource.WORKFLOW.value == "workflow"
assert AgentStatus.ACTIVE.value == "active"
assert AgentStatus.ARCHIVED.value == "archived"
assert WorkflowAgentBindingType.ROSTER_AGENT.value == "roster_agent"
assert WorkflowAgentBindingType.INLINE_AGENT.value == "inline_agent"
def test_agent_table_uses_db_unique_constraint_for_active_roster_names():
unique_constraints = {
constraint.name: tuple(column.name for column in constraint.columns)
for constraint in Agent.__table__.constraints
if constraint.__class__.__name__ == "UniqueConstraint"
}
assert unique_constraints["agent_tenant_roster_name_unique"] == ("tenant_id", "roster_unique_name")
roster_unique_name = Agent.__table__.c.roster_unique_name
assert roster_unique_name.computed is not None
computed_sql = str(roster_unique_name.computed.sqltext)
assert "scope = 'roster'" in computed_sql
assert "status = 'active'" in computed_sql
def test_active_roster_agent_name_unique_constraint_allows_archived_and_workflow_only_duplicates():
engine = sa.create_engine("sqlite:///:memory:")
Agent.__table__.create(engine)
insert_agent = Agent.__table__.insert()
with engine.begin() as conn:
conn.execute(
insert_agent,
{
"id": "agent-1",
"tenant_id": "tenant-1",
"name": "Analyst",
"scope": AgentScope.ROSTER.value,
"source": AgentSource.WORKFLOW.value,
"status": AgentStatus.ACTIVE.value,
},
)
conn.execute(
insert_agent,
{
"id": "agent-2",
"tenant_id": "tenant-1",
"name": "Analyst",
"scope": AgentScope.ROSTER.value,
"source": AgentSource.WORKFLOW.value,
"status": AgentStatus.ARCHIVED.value,
},
)
conn.execute(
insert_agent,
{
"id": "agent-3",
"tenant_id": "tenant-1",
"name": "Analyst",
"scope": AgentScope.WORKFLOW_ONLY.value,
"source": AgentSource.WORKFLOW.value,
"status": AgentStatus.ACTIVE.value,
},
)
with pytest.raises(IntegrityError):
conn.execute(
insert_agent,
{
"id": "agent-4",
"tenant_id": "tenant-1",
"name": "Analyst",
"scope": AgentScope.ROSTER.value,
"source": AgentSource.WORKFLOW.value,
"status": AgentStatus.ACTIVE.value,
},
)
def test_agent_config_version_stores_agent_soul_snapshot_as_long_text_json():
config_snapshot = {
"schema_version": 1,
"prompt": {"system_prompt": "You are a proposal analysis agent."},
"env": {"secret_refs": [{"provider_credential_id": "cred-1"}]},
}
version = AgentConfigVersion(
tenant_id="tenant-1",
agent_id="agent-1",
version=1,
config_snapshot=json.dumps(config_snapshot),
)
assert isinstance(AgentConfigVersion.__table__.c.config_snapshot.type, LongText)
assert version.config_snapshot_dict == config_snapshot
assert version.config_snapshot_dict["env"]["secret_refs"][0]["provider_credential_id"] == "cred-1"
def test_workflow_binding_stores_node_job_config_separately_from_agent_soul():
node_job_config = {
"schema_version": 1,
"workflow_prompt": "Review the bid and identify clarification questions.",
"previous_node_output_refs": [{"node_id": "start", "output": "rfp"}],
"declared_outputs": [{"name": "questions", "type": "array"}],
}
binding = WorkflowAgentNodeBinding(
tenant_id="tenant-1",
app_id="app-1",
workflow_id="workflow-1",
workflow_version="draft",
node_id="agent-node-1",
binding_type=WorkflowAgentBindingType.ROSTER_AGENT,
agent_id="agent-1",
agent_config_version_id="version-1",
node_job_config=json.dumps(node_job_config),
)
assert isinstance(WorkflowAgentNodeBinding.__table__.c.node_job_config.type, LongText)
assert binding.node_job_config_dict == node_job_config
assert "prompt" not in binding.node_job_config_dict

34
api/uv.lock generated
View File

@ -423,7 +423,7 @@ wheels = [
[[package]]
name = "azure-storage-blob"
version = "12.28.0"
version = "12.29.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "azure-core" },
@ -431,9 +431,9 @@ dependencies = [
{ name = "isodate" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/71/24/072ba8e27b0e2d8fec401e9969b429d4f5fc4c8d4f0f05f4661e11f7234a/azure_storage_blob-12.28.0.tar.gz", hash = "sha256:e7d98ea108258d29aa0efbfd591b2e2075fa1722a2fae8699f0b3c9de11eff41", size = 604225, upload-time = "2026-01-06T23:48:57.282Z" }
sdist = { url = "https://files.pythonhosted.org/packages/59/25/fdcf1e381922dbab8ba23d6fd78d397fe6cbac6b480310218834b7bc91fe/azure_storage_blob-12.29.0.tar.gz", hash = "sha256:2824ddd7ebc9056034ebc76b17971a38e9aa5835abb0d565b9700493f2a6c657", size = 611359, upload-time = "2026-05-15T03:34:59.865Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d8/3a/6ef2047a072e54e1142718d433d50e9514c999a58f51abfff7902f3a72f8/azure_storage_blob-12.28.0-py3-none-any.whl", hash = "sha256:00fb1db28bf6a7b7ecaa48e3b1d5c83bfadacc5a678b77826081304bd87d6461", size = 431499, upload-time = "2026-01-06T23:48:58.995Z" },
{ url = "https://files.pythonhosted.org/packages/c2/2c/6ddee6a3e42d0236ba9259e4df7fa97fdc415ff0802b736c634baaf4b285/azure_storage_blob-12.29.0-py3-none-any.whl", hash = "sha256:ccf8a1bcd5e49df83ab85aab793b579e5ba2eeea2ad8900b2f62ca3a37dc391f", size = 434823, upload-time = "2026-05-15T03:35:01.837Z" },
]
[[package]]
@ -595,16 +595,16 @@ wheels = [
[[package]]
name = "boto3"
version = "1.43.6"
version = "1.43.9"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "botocore" },
{ name = "jmespath" },
{ name = "s3transfer" },
]
sdist = { url = "https://files.pythonhosted.org/packages/0a/37/78c630d1308964aa9abf44951d9c4df776546ff37251ec2434944e205c4e/boto3-1.43.6.tar.gz", hash = "sha256:e6315effaf12b890b99956e6f8e2c3000a3f64e4ee91943cec3895ce9a836afb", size = 113153, upload-time = "2026-05-07T20:49:59.694Z" }
sdist = { url = "https://files.pythonhosted.org/packages/b4/cc/42d798fc5305e4636170b50cdfb305ff0a81f470e35131f4a0d2641976ae/boto3-1.43.9.tar.gz", hash = "sha256:37dac72f2921095378c0200caf07918d5e10a82b7c1f611abb70e44f69d0b962", size = 113135, upload-time = "2026-05-15T19:28:31.167Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c8/e2/3c2eef44f55eafab256836d1d9479bd6a74f70c26cbfdc0639a0e23e4327/boto3-1.43.6-py3-none-any.whl", hash = "sha256:179601ec2992726a718053bf41e43c223ceba397d31ceab11f64d9c910d9fc3a", size = 140502, upload-time = "2026-05-07T20:49:57.8Z" },
{ url = "https://files.pythonhosted.org/packages/f4/dc/51286e9551f7852a79ce5d2a57468d9d905c30d32bcace55204551db202d/boto3-1.43.9-py3-none-any.whl", hash = "sha256:5e967292d361482793471bd80fad1e714515b7401f65a0d5b4aa6ef9d009c030", size = 140523, upload-time = "2026-05-15T19:28:28.948Z" },
]
[[package]]
@ -627,16 +627,16 @@ bedrock-runtime = [
[[package]]
name = "botocore"
version = "1.43.6"
version = "1.43.9"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "jmespath" },
{ name = "python-dateutil" },
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/79/a7/23d0f5028011455096a1eeac0ddf3cbe147b3e855e127342f8202552194d/botocore-1.43.6.tar.gz", hash = "sha256:b1e395b347356860398da42e61c808cf1e34b6fa7180cf2b9d87d986e1a06ba0", size = 15336070, upload-time = "2026-05-07T20:49:48.14Z" }
sdist = { url = "https://files.pythonhosted.org/packages/ca/e8/f696c80982685a4cdb3df5f0781919afa50262f40e1aac7066c9c2520deb/botocore-1.43.9.tar.gz", hash = "sha256:93e91c7160678182860f5902ee4cfe6d643cac0d9ee84d3eb65becc9f4c00228", size = 15357963, upload-time = "2026-05-15T19:28:19.342Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e5/c8/6f47223840e8d8cfa8c9f7c0ec1b77970417f257fc885169ff4f6326ce09/botocore-1.43.6-py3-none-any.whl", hash = "sha256:b6d1fdbc6f65a5fe0b7e947823aa37535d3f39f3ba4d21110fab1f55bbbcc04b", size = 15017094, upload-time = "2026-05-07T20:49:44.964Z" },
{ url = "https://files.pythonhosted.org/packages/77/c9/a1b51a74d476f5cb2f555ce8274f0f6b9fb21d75cc3f57b87dd0632ee17a/botocore-1.43.9-py3-none-any.whl", hash = "sha256:b9bdcd9c87fc552aad30006f00167d9ebb3480e1b06f1902bac5b2c41014fdab", size = 15039827, upload-time = "2026-05-15T19:28:14.543Z" },
]
[[package]]
@ -1049,7 +1049,7 @@ wheels = [
[[package]]
name = "cos-python-sdk-v5"
version = "1.9.42"
version = "1.9.43"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "crcmod" },
@ -1058,9 +1058,9 @@ dependencies = [
{ name = "six" },
{ name = "xmltodict" },
]
sdist = { url = "https://files.pythonhosted.org/packages/40/e3/b903b4acde334510f481d126a686bc4013710c00e2af34bff369511329ac/cos_python_sdk_v5-1.9.42.tar.gz", hash = "sha256:2a01d1868f50c5a70771f2b67da868f1dc6c6f3890f8009715313834404decc4", size = 102670, upload-time = "2026-04-23T11:08:27.949Z" }
sdist = { url = "https://files.pythonhosted.org/packages/40/73/3d5321fa6c0fe14ababd5e4a8d02941785b54a9b1ba4e99336b227cba223/cos_python_sdk_v5-1.9.43.tar.gz", hash = "sha256:ff661561686356f4cff02af03a63eca27607edef2edd233f9cdcd1ca2125357b", size = 103216, upload-time = "2026-05-13T12:01:53.765Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ee/bf/4ea660bb79d91fd41ba394605eccffd3d0943ed547b3fe2bdc6c7a52d2d1/cos_python_sdk_v5-1.9.42-py3-none-any.whl", hash = "sha256:02e583a1094e1794e6c0f56618d5190eb9eb7bfe75909f1dfac41bbee46e46c5", size = 98375, upload-time = "2026-04-23T11:05:14.519Z" },
{ url = "https://files.pythonhosted.org/packages/c6/dd/b6cbe0ddd04c0543195e089bd962f5e890218e621dfb652781997860eda5/cos_python_sdk_v5-1.9.43-py3-none-any.whl", hash = "sha256:2623db720d9d1aac01faf5ad5a422008a4a0475a852c8413a56b0a8415f647aa", size = 98826, upload-time = "2026-05-13T12:01:51.383Z" },
]
[[package]]
@ -1332,7 +1332,6 @@ dependencies = [
{ name = "boto3" },
{ name = "celery" },
{ name = "croniter" },
{ name = "dify-agent" },
{ name = "fastopenapi", extra = ["flask"] },
{ name = "flask" },
{ name = "flask-compress" },
@ -1373,6 +1372,7 @@ dev = [
{ name = "boto3-stubs" },
{ name = "celery-types" },
{ name = "coverage" },
{ name = "dify-agent" },
{ name = "dotenv-linter" },
{ name = "faker" },
{ name = "hypothesis" },
@ -1612,10 +1612,9 @@ requires-dist = [
{ name = "aliyun-log-python-sdk", specifier = ">=0.9.44,<1.0.0" },
{ name = "azure-identity", specifier = ">=1.25.3,<2.0.0" },
{ name = "bleach", specifier = ">=6.3.0" },
{ name = "boto3", specifier = ">=1.43.6" },
{ name = "boto3", specifier = ">=1.43.9" },
{ name = "celery", specifier = ">=5.6.3" },
{ name = "croniter", specifier = ">=6.2.2" },
{ name = "dify-agent", directory = "../dify-agent" },
{ name = "fastopenapi", extras = ["flask"], specifier = "~=0.7.0" },
{ name = "flask", specifier = ">=3.1.3,<4.0.0" },
{ name = "flask-compress", specifier = ">=1.24,<2.0.0" },
@ -1656,6 +1655,7 @@ dev = [
{ name = "boto3-stubs", specifier = ">=1.43.2" },
{ name = "celery-types", specifier = ">=0.23.0" },
{ name = "coverage", specifier = ">=7.13.4" },
{ name = "dify-agent", directory = "../dify-agent" },
{ name = "dotenv-linter", specifier = ">=0.7.0" },
{ name = "faker", specifier = ">=40.15.0" },
{ name = "hypothesis", specifier = ">=6.152.4" },
@ -1716,9 +1716,9 @@ dev = [
{ name = "xinference-client", specifier = ">=2.7.0" },
]
storage = [
{ name = "azure-storage-blob", specifier = ">=12.28.0" },
{ name = "azure-storage-blob", specifier = ">=12.29.0" },
{ name = "bce-python-sdk", specifier = ">=0.9.71" },
{ name = "cos-python-sdk-v5", specifier = ">=1.9.42" },
{ name = "cos-python-sdk-v5", specifier = ">=1.9.43" },
{ name = "esdk-obs-python", specifier = ">=3.22.2" },
{ name = "google-cloud-storage", specifier = ">=3.10.1" },
{ name = "opendal", specifier = ">=0.46.0" },

View File

@ -23,8 +23,6 @@ import httpx
from pydantic import BaseModel, ValidationError
from dify_agent.protocol.schemas import (
CancelRunRequest,
CancelRunResponse,
CreateRunRequest,
CreateRunResponse,
RUN_EVENT_ADAPTER,
@ -34,8 +32,8 @@ from dify_agent.protocol.schemas import (
)
_ResponseModelT = TypeVar("_ResponseModelT", bound=BaseModel)
_TERMINAL_EVENT_TYPES = {"run_succeeded", "run_failed", "run_cancelled"}
_TERMINAL_RUN_STATUSES = {"succeeded", "failed", "cancelled"}
_TERMINAL_EVENT_TYPES = {"run_succeeded", "run_failed"}
_TERMINAL_RUN_STATUSES = {"succeeded", "failed"}
class DifyAgentClientError(RuntimeError):
@ -281,42 +279,6 @@ class Client:
raise DifyAgentClientError(f"create_run_sync request failed: {exc}") from exc
return _parse_model_response(response, CreateRunResponse)
async def cancel_run(self, run_id: str, request: CancelRunRequest | None = None) -> CancelRunResponse:
"""Request explicit cancellation for ``run_id``.
The server may accept cancellation only for active runs; unsupported
deployments return an HTTP error rather than overloading ``run_failed``.
"""
request_model = request or CancelRunRequest()
try:
response = await self._get_async_http_client().post(
self._url(f"/runs/{quote(run_id, safe='')}/cancel"),
content=request_model.model_dump_json(),
headers=self._merged_headers({"Content-Type": "application/json"}),
timeout=self._timeout,
)
except httpx.TimeoutException as exc:
raise DifyAgentTimeoutError("cancel_run timed out") from exc
except httpx.RequestError as exc:
raise DifyAgentClientError(f"cancel_run request failed: {exc}") from exc
return _parse_model_response(response, CancelRunResponse)
def cancel_run_sync(self, run_id: str, request: CancelRunRequest | None = None) -> CancelRunResponse:
"""Synchronous variant of ``cancel_run``."""
request_model = request or CancelRunRequest()
try:
response = self._get_sync_http_client().post(
self._url(f"/runs/{quote(run_id, safe='')}/cancel"),
content=request_model.model_dump_json(),
headers=self._merged_headers({"Content-Type": "application/json"}),
timeout=self._timeout,
)
except httpx.TimeoutException as exc:
raise DifyAgentTimeoutError("cancel_run_sync timed out") from exc
except httpx.RequestError as exc:
raise DifyAgentClientError(f"cancel_run_sync request failed: {exc}") from exc
return _parse_model_response(response, CancelRunResponse)
async def get_run(self, run_id: str) -> RunStatusResponse:
"""Return the current status for ``run_id`` or raise a mapped client error."""
try:

View File

@ -5,26 +5,17 @@ from .schemas import (
DIFY_AGENT_OUTPUT_LAYER_ID,
RUN_EVENT_ADAPTER,
BaseRunEvent,
CancelRunRequest,
CancelRunResponse,
CreateRunRequest,
CreateRunResponse,
EmptyRunEventData,
ExecutionContext,
InvokeFrom,
LayerExitSignals,
PydanticAIStreamRunEvent,
RunCancelledEvent,
RunCancelledEventData,
RunEvent,
RunComposition,
RunEventType,
RunEventsResponse,
RunFailedEvent,
RunFailedEventData,
RunPausedEvent,
RunPausedEventData,
RunPurpose,
RunLayerSpec,
RunStartedEvent,
RunStatus,
@ -37,29 +28,20 @@ from .schemas import (
__all__ = [
"BaseRunEvent",
"CancelRunRequest",
"CancelRunResponse",
"CreateRunRequest",
"CreateRunResponse",
"DIFY_AGENT_MODEL_LAYER_ID",
"DIFY_AGENT_OUTPUT_LAYER_ID",
"EmptyRunEventData",
"ExecutionContext",
"InvokeFrom",
"LayerExitSignals",
"PydanticAIStreamRunEvent",
"RUN_EVENT_ADAPTER",
"RunCancelledEvent",
"RunCancelledEventData",
"RunComposition",
"RunEvent",
"RunEventType",
"RunEventsResponse",
"RunFailedEvent",
"RunFailedEventData",
"RunPausedEvent",
"RunPausedEventData",
"RunPurpose",
"RunLayerSpec",
"RunStartedEvent",
"RunStatus",

View File

@ -43,16 +43,12 @@ from agenton.layers import ExitIntent
DIFY_AGENT_MODEL_LAYER_ID: Final[str] = "llm"
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"]
RunStatus = Literal["running", "succeeded", "failed"]
RunEventType = Literal[
"run_started",
"pydantic_ai_event",
"run_paused",
"run_succeeded",
"run_failed",
"run_cancelled",
]
@ -104,29 +100,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,30 +115,12 @@ class CreateRunRequest(BaseModel):
"""
composition: RunComposition
execution_context: ExecutionContext | None = None
purpose: RunPurpose = "workflow_node"
idempotency_key: str | None = None
metadata: dict[str, JsonValue] = Field(default_factory=dict)
session_snapshot: CompositorSessionSnapshot | None = None
on_exit: LayerExitSignals = Field(default_factory=LayerExitSignals)
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
class CancelRunRequest(BaseModel):
"""Request body for cancelling a run.
Runtime cancellation is intentionally a separate protocol operation from
failed execution so API callers can distinguish user/operator cancellation
from model, tool, or infrastructure failures.
"""
reason: str | None = None
message: str | None = None
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
def normalize_composition(composition: RunComposition) -> tuple[CompositorConfig, dict[str, LayerConfigInput]]:
"""Split public Dify composition into Agenton's graph config and layer configs.
@ -204,15 +159,6 @@ class CreateRunResponse(BaseModel):
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
class CancelRunResponse(BaseModel):
"""Response returned after a cancel request is accepted."""
run_id: str
status: Literal["cancelled"]
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
class RunStatusResponse(BaseModel):
"""Current server-side status for one run."""
@ -249,25 +195,6 @@ class RunFailedEventData(BaseModel):
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
class RunPausedEventData(BaseModel):
"""Pause payload used for human handoff or other resumable waits."""
reason: str
message: str | None = None
session_snapshot: CompositorSessionSnapshot | None = None
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
class RunCancelledEventData(BaseModel):
"""Terminal cancellation payload for explicit user/operator cancellation."""
reason: str | None = None
message: str | None = None
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
class BaseRunEvent(BaseModel):
"""Shared append-only event envelope visible through polling and SSE."""
@ -306,27 +233,8 @@ class RunFailedEvent(BaseRunEvent):
data: RunFailedEventData
class RunPausedEvent(BaseRunEvent):
"""Resumable pause event emitted when a run waits for outside input."""
type: Literal["run_paused"] = "run_paused"
data: RunPausedEventData
class RunCancelledEvent(BaseRunEvent):
"""Terminal cancellation event emitted after an explicit cancel request."""
type: Literal["run_cancelled"] = "run_cancelled"
data: RunCancelledEventData = Field(default_factory=RunCancelledEventData)
RunEvent: TypeAlias = Annotated[
RunStartedEvent
| PydanticAIStreamRunEvent
| RunPausedEvent
| RunSucceededEvent
| RunFailedEvent
| RunCancelledEvent,
RunStartedEvent | PydanticAIStreamRunEvent | RunSucceededEvent | RunFailedEvent,
Field(discriminator="type"),
]
RUN_EVENT_ADAPTER: TypeAdapter[RunEvent] = TypeAdapter(RunEvent)
@ -344,29 +252,20 @@ class RunEventsResponse(BaseModel):
__all__ = [
"BaseRunEvent",
"CancelRunRequest",
"CancelRunResponse",
"CreateRunRequest",
"CreateRunResponse",
"DIFY_AGENT_MODEL_LAYER_ID",
"DIFY_AGENT_OUTPUT_LAYER_ID",
"EmptyRunEventData",
"ExecutionContext",
"InvokeFrom",
"LayerExitSignals",
"PydanticAIStreamRunEvent",
"RUN_EVENT_ADAPTER",
"RunCancelledEvent",
"RunCancelledEventData",
"RunComposition",
"RunEvent",
"RunEventType",
"RunEventsResponse",
"RunFailedEvent",
"RunFailedEventData",
"RunPausedEvent",
"RunPausedEventData",
"RunPurpose",
"RunStartedEvent",
"RunStatus",
"RunStatusResponse",

View File

@ -13,14 +13,7 @@ from typing import Annotated
from fastapi import APIRouter, Depends, Header, HTTPException, Query
from fastapi.responses import StreamingResponse
from dify_agent.protocol.schemas import (
CancelRunRequest,
CancelRunResponse,
CreateRunRequest,
CreateRunResponse,
RunEventsResponse,
RunStatusResponse,
)
from dify_agent.protocol.schemas import CreateRunRequest, CreateRunResponse, RunEventsResponse, RunStatusResponse
from dify_agent.runtime.run_scheduler import RunRequestValidationError, RunScheduler, SchedulerStoppingError
from dify_agent.server.sse import sse_event_stream
from dify_agent.storage.redis_run_store import RedisRunStore, RunNotFoundError
@ -66,18 +59,6 @@ def create_runs_router(
error=record.error,
)
@router.post("/{run_id}/cancel", response_model=CancelRunResponse)
async def cancel_run(run_id: str, request: CancelRunRequest) -> CancelRunResponse:
"""Reserve the cancellation endpoint in the public protocol.
Runtime cancellation requires scheduler task lookup and persistence
semantics that are outside the current server implementation. Exposing a
typed endpoint now lets clients bind to the final route while receiving
an explicit 501 until execution support lands.
"""
del run_id, request
raise HTTPException(status_code=501, detail="run cancellation is not implemented")
@router.get("/{run_id}/events", response_model=RunEventsResponse)
async def get_run_events(
run_id: str,

View File

@ -20,11 +20,8 @@ from dify_agent.client import (
DifyAgentValidationError,
)
from dify_agent.protocol.schemas import (
CancelRunRequest,
CancelRunResponse,
CreateRunRequest,
RUN_EVENT_ADAPTER,
RunCancelledEvent,
RunEvent,
RunEventsResponse,
RunStartedEvent,
@ -100,10 +97,6 @@ def test_sync_methods_parse_protocol_dtos_and_send_create_request_dto() -> None:
"next_cursor": "1-0",
},
)
if request.method == "POST" and request.url.path == "/runs/run-1/cancel":
payload = cast(dict[str, object], json.loads(request.content))
assert payload == {"reason": "user_cancelled", "message": None}
return httpx.Response(202, json={"run_id": "run-1", "status": "cancelled"})
raise AssertionError(f"unexpected request: {request.method} {request.url}")
http_client = httpx.Client(transport=httpx.MockTransport(handler))
@ -112,14 +105,11 @@ def test_sync_methods_parse_protocol_dtos_and_send_create_request_dto() -> None:
created = client.create_run_sync(CreateRunRequest.model_validate(_create_run_payload()))
status = client.get_run_sync(created.run_id)
events = client.get_events_sync(created.run_id, after="0-0", limit=10)
cancelled = client.cancel_run_sync(created.run_id, CancelRunRequest(reason="user_cancelled"))
assert created.status == "running"
assert status.status == "running"
assert isinstance(events, RunEventsResponse)
assert [event.type for event in events.events] == ["run_started"]
assert isinstance(cancelled, CancelRunResponse)
assert cancelled.status == "cancelled"
def test_async_methods_and_wait_run_parse_protocol_dtos() -> None:
@ -261,31 +251,6 @@ def test_stream_events_stops_after_terminal_event() -> None:
assert calls == 1
def test_stream_events_stops_after_cancelled_terminal_event() -> None:
calls = 0
body = "".join(
[
_event_frame(RunStartedEvent(id="1-0", run_id="run-1")),
_event_frame(RunCancelledEvent(id="2-0", run_id="run-1")),
]
)
def handler(_request: httpx.Request) -> httpx.Response:
nonlocal calls
calls += 1
return httpx.Response(200, content=body)
client = Client(
base_url="http://testserver",
sync_http_client=httpx.Client(transport=httpx.MockTransport(handler)),
)
events = list(client.stream_events_sync("run-1", reconnect_delay_seconds=0))
assert [event.type for event in events] == ["run_started", "run_cancelled"]
assert calls == 1
def test_stream_events_reconnects_from_latest_event_id() -> None:
seen_after: list[str] = []

View File

@ -12,17 +12,12 @@ from dify_agent.protocol import DIFY_AGENT_MODEL_LAYER_ID, DIFY_AGENT_OUTPUT_LAY
from dify_agent.protocol.schemas import (
RUN_EVENT_ADAPTER,
CreateRunRequest,
ExecutionContext,
LayerExitSignals,
PydanticAIStreamRunEvent,
RunCancelledEvent,
RunCancelledEventData,
RunComposition,
RunFailedEvent,
RunFailedEventData,
RunLayerSpec,
RunPausedEvent,
RunPausedEventData,
RunStartedEvent,
RunSucceededEvent,
RunSucceededEventData,
@ -43,15 +38,6 @@ def test_run_event_adapter_round_trips_typed_variants() -> None:
),
),
RunFailedEvent(run_id="run-1", data=RunFailedEventData(error="boom", reason="shutdown")),
RunPausedEvent(
run_id="run-1",
data=RunPausedEventData(
reason="human_handoff",
message="Need review",
session_snapshot=CompositorSessionSnapshot(layers=[]),
),
),
RunCancelledEvent(run_id="run-1", data=RunCancelledEventData(reason="user_cancelled")),
]
for event in events:
@ -103,18 +89,6 @@ 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),
@ -131,28 +105,12 @@ def test_create_run_request_accepts_dto_first_public_composition_and_normalizes_
config=output_config,
),
]
),
)
)
graph_config, layer_configs = normalize_composition(request.composition)
payload = request.model_dump(mode="json")
assert payload["execution_context"] == {
"tenant_id": "tenant-1",
"app_id": None,
"workflow_id": "workflow-1",
"workflow_run_id": "workflow-run-1",
"node_id": "node-1",
"node_execution_id": "node-execution-1",
"conversation_id": None,
"agent_id": None,
"agent_config_version_id": None,
"invoke_from": "workflow_run",
"trace_id": "trace-1",
}
assert payload["purpose"] == "workflow_node"
assert payload["idempotency_key"] == "workflow-run-1:node-execution-1"
assert payload["metadata"] == {"source": "unit_test"}
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": {}},
@ -205,17 +163,6 @@ 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:
with pytest.raises(ValidationError):
_ = ExecutionContext.model_validate(
{
"tenant_id": "tenant-1",
"invoke_from": "workflow_run",
"unknown": "value",
}
)
def test_layer_exit_signals_reject_extra_fields() -> None:
with pytest.raises(ValidationError):
_ = LayerExitSignals.model_validate({"default": "suspend", "unknown": "value"})

View File

@ -67,21 +67,6 @@ def test_create_run_returns_running_from_scheduler() -> None:
assert response.json() == {"run_id": "run-1", "status": "running"}
def test_cancel_run_endpoint_is_reserved_but_not_implemented() -> None:
from fastapi import FastAPI
app = FastAPI()
app.include_router(
create_runs_router(lambda: FakeStore(), lambda: FakeScheduler()) # pyright: ignore[reportArgumentType]
)
client = TestClient(app)
response = client.post("/runs/run-1/cancel", json={"reason": "user_cancelled"})
assert response.status_code == 501
assert response.json()["detail"] == "run cancellation is not implemented"
def test_create_run_accepts_valid_full_plugin_graph() -> None:
from fastapi import FastAPI

View File

@ -556,8 +556,28 @@ export type WorkflowRunNodeExecutionListResponse = {
data: Array<WorkflowRunNodeExecutionResponse>
}
export type WorkflowCommentBasicList = {
data: Array<WorkflowCommentBasic>
export type WorkflowCommentBasic = {
content?: string
created_at?: {
[key: string]: unknown
}
created_by?: string
created_by_account?: AnonymousInlineModel6Fec07Cd0D85
id?: string
mention_count?: number
participants?: Array<AnonymousInlineModel6Fec07Cd0D85>
position_x?: number
position_y?: number
reply_count?: number
resolved?: boolean
resolved_at?: {
[key: string]: unknown
}
resolved_by?: string
resolved_by_account?: AnonymousInlineModel6Fec07Cd0D85
updated_at?: {
[key: string]: unknown
}
}
export type WorkflowCommentCreatePayload = {
@ -568,8 +588,10 @@ export type WorkflowCommentCreatePayload = {
}
export type WorkflowCommentCreate = {
created_at?: number | null
id: string
created_at?: {
[key: string]: unknown
}
id?: string
}
export type WorkflowCommentMentionUsersPayload = {
@ -577,20 +599,26 @@ export type WorkflowCommentMentionUsersPayload = {
}
export type WorkflowCommentDetail = {
content: string
created_at?: number | null
created_by: string
created_by_account?: WorkflowCommentAccount
id: string
mentions: Array<WorkflowCommentMention>
position_x: number
position_y: number
replies: Array<WorkflowCommentReply>
resolved: boolean
resolved_at?: number | null
resolved_by?: string | null
resolved_by_account?: WorkflowCommentAccount
updated_at?: number | null
content?: string
created_at?: {
[key: string]: unknown
}
created_by?: string
created_by_account?: AnonymousInlineModel6Fec07Cd0D85
id?: string
mentions?: Array<AnonymousInlineModelF7Ff64Cce858>
position_x?: number
position_y?: number
replies?: Array<AnonymousInlineModel55C39C6A4B9e>
resolved?: boolean
resolved_at?: {
[key: string]: unknown
}
resolved_by?: string
resolved_by_account?: AnonymousInlineModel6Fec07Cd0D85
updated_at?: {
[key: string]: unknown
}
}
export type WorkflowCommentUpdatePayload = {
@ -601,8 +629,10 @@ export type WorkflowCommentUpdatePayload = {
}
export type WorkflowCommentUpdate = {
id: string
updated_at?: number | null
id?: string
updated_at?: {
[key: string]: unknown
}
}
export type WorkflowCommentReplyPayload = {
@ -611,20 +641,26 @@ export type WorkflowCommentReplyPayload = {
}
export type WorkflowCommentReplyCreate = {
created_at?: number | null
id: string
created_at?: {
[key: string]: unknown
}
id?: string
}
export type WorkflowCommentReplyUpdate = {
id: string
updated_at?: number | null
id?: string
updated_at?: {
[key: string]: unknown
}
}
export type WorkflowCommentResolve = {
id: string
resolved: boolean
resolved_at?: number | null
resolved_by?: string | null
id?: string
resolved?: boolean
resolved_at?: {
[key: string]: unknown
}
resolved_by?: string
}
export type WorkflowPagination = {
@ -1131,22 +1167,13 @@ export type SimpleEndUser = {
type: string
}
export type WorkflowCommentBasic = {
content: string
created_at?: number | null
created_by: string
created_by_account?: WorkflowCommentAccount
id: string
mention_count: number
participants: Array<WorkflowCommentAccount>
position_x: number
position_y: number
reply_count: number
resolved: boolean
resolved_at?: number | null
resolved_by?: string | null
resolved_by_account?: WorkflowCommentAccount
updated_at?: number | null
export type AnonymousInlineModel6Fec07Cd0D85 = {
avatar_url?: {
[key: string]: unknown
}
email?: string
id?: string
name?: string
}
export type AccountWithRole = {
@ -1161,25 +1188,20 @@ export type AccountWithRole = {
status: string
}
export type WorkflowCommentAccount = {
readonly avatar_url: string | null
email: string
id: string
name: string
export type AnonymousInlineModelF7Ff64Cce858 = {
mentioned_user_account?: AnonymousInlineModel6Fec07Cd0D85
mentioned_user_id?: string
reply_id?: string
}
export type WorkflowCommentMention = {
mentioned_user_account?: WorkflowCommentAccount
mentioned_user_id: string
reply_id?: string | null
}
export type WorkflowCommentReply = {
content: string
created_at?: number | null
created_by: string
created_by_account?: WorkflowCommentAccount
id: string
export type AnonymousInlineModel55C39C6A4B9e = {
content?: string
created_at?: {
[key: string]: unknown
}
created_by?: string
created_by_account?: AnonymousInlineModel6Fec07Cd0D85
id?: string
}
export type ConversationVariable = {
@ -1327,7 +1349,11 @@ export type UserActionConfig = {
title: string
}
export type FormInputConfig = unknown
export type FormInputConfig
= | ParagraphInputConfig
| SelectInputConfig
| FileInputConfig
| FileListInputConfig
export type ButtonStyle = 'accent' | 'default' | 'ghost' | 'primary'
@ -1378,65 +1404,6 @@ export type FileTransferMethod = 'datasource_file' | 'local_file' | 'remote_url'
export type ValueSourceType = 'constant' | 'variable'
export type WorkflowCommentBasicListWritable = {
data: Array<WorkflowCommentBasicWritable>
}
export type WorkflowCommentDetailWritable = {
content: string
created_at?: number | null
created_by: string
created_by_account?: WorkflowCommentAccountWritable
id: string
mentions: Array<WorkflowCommentMentionWritable>
position_x: number
position_y: number
replies: Array<WorkflowCommentReplyWritable>
resolved: boolean
resolved_at?: number | null
resolved_by?: string | null
resolved_by_account?: WorkflowCommentAccountWritable
updated_at?: number | null
}
export type WorkflowCommentBasicWritable = {
content: string
created_at?: number | null
created_by: string
created_by_account?: WorkflowCommentAccountWritable
id: string
mention_count: number
participants: Array<WorkflowCommentAccountWritable>
position_x: number
position_y: number
reply_count: number
resolved: boolean
resolved_at?: number | null
resolved_by?: string | null
resolved_by_account?: WorkflowCommentAccountWritable
updated_at?: number | null
}
export type WorkflowCommentAccountWritable = {
email: string
id: string
name: string
}
export type WorkflowCommentMentionWritable = {
mentioned_user_account?: WorkflowCommentAccountWritable
mentioned_user_id: string
reply_id?: string | null
}
export type WorkflowCommentReplyWritable = {
content: string
created_at?: number | null
created_by: string
created_by_account?: WorkflowCommentAccountWritable
id: string
}
export type GetAppsData = {
body?: never
path?: never
@ -3595,7 +3562,7 @@ export type GetAppsByAppIdWorkflowCommentsData = {
}
export type GetAppsByAppIdWorkflowCommentsResponses = {
200: WorkflowCommentBasicList
200: WorkflowCommentBasic
}
export type GetAppsByAppIdWorkflowCommentsResponse

View File

@ -373,12 +373,9 @@ export const zWorkflowCommentCreatePayload = z.object({
position_y: z.number(),
})
/**
* WorkflowCommentCreate
*/
export const zWorkflowCommentCreate = z.object({
created_at: z.int().nullish(),
id: z.string(),
created_at: z.record(z.string(), z.unknown()).optional(),
id: z.string().optional(),
})
/**
@ -391,12 +388,9 @@ export const zWorkflowCommentUpdatePayload = z.object({
position_y: z.number().nullish(),
})
/**
* WorkflowCommentUpdate
*/
export const zWorkflowCommentUpdate = z.object({
id: z.string(),
updated_at: z.int().nullish(),
id: z.string().optional(),
updated_at: z.record(z.string(), z.unknown()).optional(),
})
/**
@ -407,30 +401,21 @@ export const zWorkflowCommentReplyPayload = z.object({
mentioned_user_ids: z.array(z.string()).optional(),
})
/**
* WorkflowCommentReplyCreate
*/
export const zWorkflowCommentReplyCreate = z.object({
created_at: z.int().nullish(),
id: z.string(),
created_at: z.record(z.string(), z.unknown()).optional(),
id: z.string().optional(),
})
/**
* WorkflowCommentReplyUpdate
*/
export const zWorkflowCommentReplyUpdate = z.object({
id: z.string(),
updated_at: z.int().nullish(),
id: z.string().optional(),
updated_at: z.record(z.string(), z.unknown()).optional(),
})
/**
* WorkflowCommentResolve
*/
export const zWorkflowCommentResolve = z.object({
id: z.string(),
resolved: z.boolean(),
resolved_at: z.int().nullish(),
resolved_by: z.string().nullish(),
id: z.string().optional(),
resolved: z.boolean().optional(),
resolved_at: z.record(z.string(), z.unknown()).optional(),
resolved_by: z.string().optional(),
})
/**
@ -1008,6 +993,31 @@ export const zWorkflowRunNodeExecutionListResponse = z.object({
data: z.array(zWorkflowRunNodeExecutionResponse),
})
export const zAnonymousInlineModel6Fec07Cd0D85 = z.object({
avatar_url: z.record(z.string(), z.unknown()).optional(),
email: z.string().optional(),
id: z.string().optional(),
name: z.string().optional(),
})
export const zWorkflowCommentBasic = z.object({
content: z.string().optional(),
created_at: z.record(z.string(), z.unknown()).optional(),
created_by: z.string().optional(),
created_by_account: zAnonymousInlineModel6Fec07Cd0D85.optional(),
id: z.string().optional(),
mention_count: z.int().optional(),
participants: z.array(zAnonymousInlineModel6Fec07Cd0D85).optional(),
position_x: z.number().optional(),
position_y: z.number().optional(),
reply_count: z.int().optional(),
resolved: z.boolean().optional(),
resolved_at: z.record(z.string(), z.unknown()).optional(),
resolved_by: z.string().optional(),
resolved_by_account: zAnonymousInlineModel6Fec07Cd0D85.optional(),
updated_at: z.record(z.string(), z.unknown()).optional(),
})
/**
* AccountWithRole
*/
@ -1030,82 +1040,35 @@ export const zWorkflowCommentMentionUsersPayload = z.object({
users: z.array(zAccountWithRole),
})
/**
* WorkflowCommentAccount
*/
export const zWorkflowCommentAccount = z.object({
avatar_url: z.string().readonly().nullable(),
email: z.string(),
id: z.string(),
name: z.string(),
export const zAnonymousInlineModelF7Ff64Cce858 = z.object({
mentioned_user_account: zAnonymousInlineModel6Fec07Cd0D85.optional(),
mentioned_user_id: z.string().optional(),
reply_id: z.string().optional(),
})
/**
* WorkflowCommentBasic
*/
export const zWorkflowCommentBasic = z.object({
content: z.string(),
created_at: z.int().nullish(),
created_by: z.string(),
created_by_account: zWorkflowCommentAccount.optional(),
id: z.string(),
mention_count: z.int(),
participants: z.array(zWorkflowCommentAccount),
position_x: z.number(),
position_y: z.number(),
reply_count: z.int(),
resolved: z.boolean(),
resolved_at: z.int().nullish(),
resolved_by: z.string().nullish(),
resolved_by_account: zWorkflowCommentAccount.optional(),
updated_at: z.int().nullish(),
export const zAnonymousInlineModel55C39C6A4B9e = z.object({
content: z.string().optional(),
created_at: z.record(z.string(), z.unknown()).optional(),
created_by: z.string().optional(),
created_by_account: zAnonymousInlineModel6Fec07Cd0D85.optional(),
id: z.string().optional(),
})
/**
* WorkflowCommentBasicList
*/
export const zWorkflowCommentBasicList = z.object({
data: z.array(zWorkflowCommentBasic),
})
/**
* WorkflowCommentMention
*/
export const zWorkflowCommentMention = z.object({
mentioned_user_account: zWorkflowCommentAccount.optional(),
mentioned_user_id: z.string(),
reply_id: z.string().nullish(),
})
/**
* WorkflowCommentReply
*/
export const zWorkflowCommentReply = z.object({
content: z.string(),
created_at: z.int().nullish(),
created_by: z.string(),
created_by_account: zWorkflowCommentAccount.optional(),
id: z.string(),
})
/**
* WorkflowCommentDetail
*/
export const zWorkflowCommentDetail = z.object({
content: z.string(),
created_at: z.int().nullish(),
created_by: z.string(),
created_by_account: zWorkflowCommentAccount.optional(),
id: z.string(),
mentions: z.array(zWorkflowCommentMention),
position_x: z.number(),
position_y: z.number(),
replies: z.array(zWorkflowCommentReply),
resolved: z.boolean(),
resolved_at: z.int().nullish(),
resolved_by: z.string().nullish(),
resolved_by_account: zWorkflowCommentAccount.optional(),
updated_at: z.int().nullish(),
content: z.string().optional(),
created_at: z.record(z.string(), z.unknown()).optional(),
created_by: z.string().optional(),
created_by_account: zAnonymousInlineModel6Fec07Cd0D85.optional(),
id: z.string().optional(),
mentions: z.array(zAnonymousInlineModelF7Ff64Cce858).optional(),
position_x: z.number().optional(),
position_y: z.number().optional(),
replies: z.array(zAnonymousInlineModel55C39C6A4B9e).optional(),
resolved: z.boolean().optional(),
resolved_at: z.record(z.string(), z.unknown()).optional(),
resolved_by: z.string().optional(),
resolved_by_account: zAnonymousInlineModel6Fec07Cd0D85.optional(),
updated_at: z.record(z.string(), z.unknown()).optional(),
})
export const zConversationVariable = z.object({
@ -1561,8 +1524,6 @@ export const zWorkflowArchivedLogPaginationResponse = z.object({
total: z.int(),
})
export const zFormInputConfig = z.unknown()
/**
* ButtonStyle
*
@ -1581,72 +1542,6 @@ export const zUserActionConfig = z.object({
title: z.string().max(100),
})
/**
* HumanInputFormDefinition
*/
export const zHumanInputFormDefinition = z.object({
actions: z.array(zUserActionConfig).optional(),
display_in_ui: z.boolean().optional().default(false),
expiration_time: z.int(),
form_content: z.string(),
form_id: z.string(),
form_token: z.string().nullish(),
inputs: z.array(zFormInputConfig).optional(),
node_id: z.string(),
node_title: z.string(),
resolved_default_values: z.record(z.string(), z.unknown()).optional(),
})
/**
* HumanInputContent
*/
export const zHumanInputContent = z.object({
form_definition: zHumanInputFormDefinition.optional(),
form_submission_data: zHumanInputFormSubmissionData.optional(),
submitted: z.boolean(),
type: zExecutionContentType.optional(),
workflow_run_id: z.string(),
})
/**
* MessageDetailResponse
*/
export const zMessageDetailResponse = z.object({
agent_thoughts: z.array(zAgentThought).optional(),
annotation: zConversationAnnotation.optional(),
annotation_hit_history: zConversationAnnotationHitHistory.optional(),
answer_tokens: z.int().nullish(),
conversation_id: z.string(),
created_at: z.int().nullish(),
error: z.string().nullish(),
extra_contents: z.array(zHumanInputContent).optional(),
feedbacks: z.array(zFeedback).optional(),
from_account_id: z.string().nullish(),
from_end_user_id: z.string().nullish(),
from_source: z.string(),
id: z.string(),
inputs: z.record(z.string(), zJsonValue),
message: zJsonValue.optional(),
message_files: z.array(zMessageFile).optional(),
message_metadata_dict: zJsonValue.optional(),
message_tokens: z.int().nullish(),
parent_message_id: z.string().nullish(),
provider_response_latency: z.number().nullish(),
query: z.string(),
re_sign_file_url_answer: z.string(),
status: z.string(),
workflow_run_id: z.string().nullish(),
})
/**
* MessageInfiniteScrollPaginationResponse
*/
export const zMessageInfiniteScrollPaginationResponse = z.object({
data: z.array(zMessageDetailResponse),
has_more: z.boolean(),
limit: z.int(),
})
/**
* FileType
*/
@ -1733,81 +1628,77 @@ export const zSelectInputConfig = z.object({
type: z.string().optional().default('select'),
})
export const zFormInputConfig = z.union([
zParagraphInputConfig,
zSelectInputConfig,
zFileInputConfig,
zFileListInputConfig,
])
/**
* WorkflowCommentAccount
* HumanInputFormDefinition
*/
export const zWorkflowCommentAccountWritable = z.object({
email: z.string(),
id: z.string(),
name: z.string(),
export const zHumanInputFormDefinition = z.object({
actions: z.array(zUserActionConfig).optional(),
display_in_ui: z.boolean().optional().default(false),
expiration_time: z.int(),
form_content: z.string(),
form_id: z.string(),
form_token: z.string().nullish(),
inputs: z.array(zFormInputConfig).optional(),
node_id: z.string(),
node_title: z.string(),
resolved_default_values: z.record(z.string(), z.unknown()).optional(),
})
/**
* WorkflowCommentBasic
* HumanInputContent
*/
export const zWorkflowCommentBasicWritable = z.object({
content: z.string(),
export const zHumanInputContent = z.object({
form_definition: zHumanInputFormDefinition.optional(),
form_submission_data: zHumanInputFormSubmissionData.optional(),
submitted: z.boolean(),
type: zExecutionContentType.optional(),
workflow_run_id: z.string(),
})
/**
* MessageDetailResponse
*/
export const zMessageDetailResponse = z.object({
agent_thoughts: z.array(zAgentThought).optional(),
annotation: zConversationAnnotation.optional(),
annotation_hit_history: zConversationAnnotationHitHistory.optional(),
answer_tokens: z.int().nullish(),
conversation_id: z.string(),
created_at: z.int().nullish(),
created_by: z.string(),
created_by_account: zWorkflowCommentAccountWritable.optional(),
error: z.string().nullish(),
extra_contents: z.array(zHumanInputContent).optional(),
feedbacks: z.array(zFeedback).optional(),
from_account_id: z.string().nullish(),
from_end_user_id: z.string().nullish(),
from_source: z.string(),
id: z.string(),
mention_count: z.int(),
participants: z.array(zWorkflowCommentAccountWritable),
position_x: z.number(),
position_y: z.number(),
reply_count: z.int(),
resolved: z.boolean(),
resolved_at: z.int().nullish(),
resolved_by: z.string().nullish(),
resolved_by_account: zWorkflowCommentAccountWritable.optional(),
updated_at: z.int().nullish(),
inputs: z.record(z.string(), zJsonValue),
message: zJsonValue.optional(),
message_files: z.array(zMessageFile).optional(),
message_metadata_dict: zJsonValue.optional(),
message_tokens: z.int().nullish(),
parent_message_id: z.string().nullish(),
provider_response_latency: z.number().nullish(),
query: z.string(),
re_sign_file_url_answer: z.string(),
status: z.string(),
workflow_run_id: z.string().nullish(),
})
/**
* WorkflowCommentBasicList
* MessageInfiniteScrollPaginationResponse
*/
export const zWorkflowCommentBasicListWritable = z.object({
data: z.array(zWorkflowCommentBasicWritable),
})
/**
* WorkflowCommentMention
*/
export const zWorkflowCommentMentionWritable = z.object({
mentioned_user_account: zWorkflowCommentAccountWritable.optional(),
mentioned_user_id: z.string(),
reply_id: z.string().nullish(),
})
/**
* WorkflowCommentReply
*/
export const zWorkflowCommentReplyWritable = z.object({
content: z.string(),
created_at: z.int().nullish(),
created_by: z.string(),
created_by_account: zWorkflowCommentAccountWritable.optional(),
id: z.string(),
})
/**
* WorkflowCommentDetail
*/
export const zWorkflowCommentDetailWritable = z.object({
content: z.string(),
created_at: z.int().nullish(),
created_by: z.string(),
created_by_account: zWorkflowCommentAccountWritable.optional(),
id: z.string(),
mentions: z.array(zWorkflowCommentMentionWritable),
position_x: z.number(),
position_y: z.number(),
replies: z.array(zWorkflowCommentReplyWritable),
resolved: z.boolean(),
resolved_at: z.int().nullish(),
resolved_by: z.string().nullish(),
resolved_by_account: zWorkflowCommentAccountWritable.optional(),
updated_at: z.int().nullish(),
export const zMessageInfiniteScrollPaginationResponse = z.object({
data: z.array(zMessageDetailResponse),
has_more: z.boolean(),
limit: z.int(),
})
export const zGetAppsQuery = z.object({
@ -2902,7 +2793,7 @@ export const zGetAppsByAppIdWorkflowCommentsPath = z.object({
/**
* Comments retrieved successfully
*/
export const zGetAppsByAppIdWorkflowCommentsResponse = zWorkflowCommentBasicList
export const zGetAppsByAppIdWorkflowCommentsResponse = zWorkflowCommentBasic
export const zPostAppsByAppIdWorkflowCommentsBody = zWorkflowCommentCreatePayload

View File

@ -13,7 +13,6 @@ export type EmailCodeLoginPayload = {
code: string
email: string
language?: string | null
timezone?: string | null
token: string
}

View File

@ -17,7 +17,6 @@ export const zEmailCodeLoginPayload = z.object({
code: z.string(),
email: z.string(),
language: z.string().nullish(),
timezone: z.string().nullish(),
token: z.string(),
})

View File

@ -16,10 +16,6 @@ export type TagBasePayload = {
type: TagType
}
export type TagUpdateRequestPayload = {
name: string
}
export type TagType = 'app' | 'knowledge'
export type GetTagsData = {
@ -71,7 +67,7 @@ export type DeleteTagsByTagIdResponses = {
export type DeleteTagsByTagIdResponse = DeleteTagsByTagIdResponses[keyof DeleteTagsByTagIdResponses]
export type PatchTagsByTagIdData = {
body: TagUpdateRequestPayload
body: TagBasePayload
path: {
tag_id: string
}

View File

@ -12,13 +12,6 @@ export const zTagResponse = z.object({
type: z.string().nullish(),
})
/**
* TagUpdateRequestPayload
*/
export const zTagUpdateRequestPayload = z.object({
name: z.string().min(1).max(50),
})
/**
* TagType
*
@ -60,7 +53,7 @@ export const zDeleteTagsByTagIdPath = z.object({
*/
export const zDeleteTagsByTagIdResponse = z.record(z.string(), z.unknown())
export const zPatchTagsByTagIdBody = zTagUpdateRequestPayload
export const zPatchTagsByTagIdBody = zTagBasePayload
export const zPatchTagsByTagIdPath = z.object({
tag_id: z.string(),

View File

@ -563,6 +563,16 @@ const createApiConfig = (job: ApiJob): UserConfig => ({
suffix: '.gen',
},
path: job.outputPath,
postProcess: [
{
args: ['fmt', '{{path}}'],
command: 'vp',
},
{
args: ['--fix', '{{path}}/*.ts'],
command: 'eslint',
},
],
},
plugins: [
{

View File

@ -14,8 +14,7 @@
}
},
"scripts": {
"gen-api-contract": "pnpm gen-api-openapi && pnpm gen-api-contract-from-openapi",
"gen-api-contract-from-openapi": "node -e \"fs.rmSync('generated/api', { recursive: true, force: true })\" && openapi-ts -f openapi-ts.api.config.ts && vp fmt generated/api && eslint --fix generated/api",
"gen-api-contract": "pnpm gen-api-openapi && node -e \"fs.rmSync('generated/api', { recursive: true, force: true })\" && openapi-ts -f openapi-ts.api.config.ts",
"gen-api-openapi": "uv run --project ../../api ../../api/dev/generate_swagger_specs.py --output-dir openapi",
"gen-enterprise-contract": "openapi-ts -f openapi-ts.enterprise.config.ts",
"type-check": "tsgo"

View File

@ -25,14 +25,6 @@
"types": "./src/button/index.tsx",
"import": "./src/button/index.tsx"
},
"./checkbox": {
"types": "./src/checkbox/index.tsx",
"import": "./src/checkbox/index.tsx"
},
"./checkbox-group": {
"types": "./src/checkbox-group/index.tsx",
"import": "./src/checkbox-group/index.tsx"
},
"./combobox": {
"types": "./src/combobox/index.tsx",
"import": "./src/combobox/index.tsx"

View File

@ -1,76 +0,0 @@
import { Field } from '@base-ui/react/field'
import { Fieldset } from '@base-ui/react/fieldset'
import { useState } from 'react'
import { render } from 'vitest-browser-react'
import { Checkbox } from '../../checkbox'
import { CheckboxGroup } from '../index'
const asHTMLElement = (element: HTMLElement | SVGElement) => element as HTMLElement
describe('CheckboxGroup', () => {
it('should manage selected values and parent mixed state', async () => {
function PermissionsDemo() {
const [value, setValue] = useState(['read'])
return (
<CheckboxGroup value={value} onValueChange={setValue} allValues={['read', 'write']}>
<Checkbox parent aria-label="All permissions" />
<label>
<Checkbox value="read" />
Read
</label>
<label>
<Checkbox value="write" />
Write
</label>
</CheckboxGroup>
)
}
const screen = await render(<PermissionsDemo />)
const parent = screen.getByRole('checkbox', { name: 'All permissions' })
const write = screen.getByRole('checkbox', { name: 'Write' })
await expect.element(parent).toHaveAttribute('aria-checked', 'mixed')
await expect.element(parent).toHaveAttribute('data-indeterminate', '')
await expect.element(write).toHaveAttribute('aria-checked', 'false')
asHTMLElement(parent.element()).click()
await vi.waitFor(async () => {
await expect.element(parent).toHaveAttribute('aria-checked', 'true')
await expect.element(write).toHaveAttribute('aria-checked', 'true')
})
})
it('should compose with Base UI Field and Fieldset without losing labels', async () => {
const onValueChange = vi.fn()
const screen = await render(
<Field.Root name="features">
<Fieldset.Root render={<CheckboxGroup value={['search']} onValueChange={onValueChange} />}>
<Fieldset.Legend>Features</Fieldset.Legend>
<Field.Item>
<Field.Label>
<Checkbox value="search" />
Search
</Field.Label>
</Field.Item>
<Field.Item>
<Field.Label>
<Checkbox value="analytics" />
Analytics
</Field.Label>
</Field.Item>
</Fieldset.Root>
</Field.Root>,
)
const analytics = screen.getByRole('checkbox', { name: 'Analytics' })
await expect.element(analytics).toHaveAttribute('aria-checked', 'false')
asHTMLElement(analytics.element()).click()
expect(onValueChange).toHaveBeenCalledTimes(1)
expect(onValueChange.mock.calls[0]?.[0]).toEqual(['search', 'analytics'])
})
})

View File

@ -1,116 +0,0 @@
import type { Meta, StoryObj } from '@storybook/react-vite'
import { Field } from '@base-ui/react/field'
import { Fieldset } from '@base-ui/react/fieldset'
import { useId, useState } from 'react'
import { CheckboxGroup } from '.'
import { Checkbox } from '../checkbox'
const meta = {
title: 'Base/UI/CheckboxGroup',
component: CheckboxGroup,
parameters: {
layout: 'centered',
docs: {
description: {
component: 'CheckboxGroup primitive built on Base UI. It owns multi-checkbox array state, allValues, and parent checkbox semantics. Import from `@langgenius/dify-ui/checkbox-group` and compose with `Checkbox` from `@langgenius/dify-ui/checkbox`.',
},
},
},
tags: ['autodocs'],
} satisfies Meta<typeof CheckboxGroup>
export default meta
type Story = StoryObj<typeof meta>
function DocumentSelectionDemo() {
const documentIds = ['doc-1', 'doc-2', 'doc-3']
const [selected, setSelected] = useState<string[]>(['doc-1'])
const groupLabelId = useId()
return (
<CheckboxGroup
aria-labelledby={groupLabelId}
value={selected}
onValueChange={setSelected}
allValues={documentIds}
className="flex flex-col gap-3"
>
<label id={groupLabelId} className="flex items-center gap-2 system-sm-semibold-uppercase text-text-secondary">
<Checkbox parent />
Current page documents
</label>
<div className="flex flex-col gap-2 pl-6">
{[
{ id: 'doc-1', name: 'onboarding-guide.pdf' },
{ id: 'doc-2', name: 'pricing-faq.md' },
{ id: 'doc-3', name: 'release-notes.txt' },
].map(document => (
<label key={document.id} className="flex items-center gap-2 system-sm-medium text-text-secondary">
<Checkbox value={document.id} />
{document.name}
</label>
))}
</div>
</CheckboxGroup>
)
}
export const DocumentSelection: Story = {
render: () => <DocumentSelectionDemo />,
parameters: {
docs: {
description: {
story: 'Matches Dify table/list selection patterns such as documents, segments, annotations, and install bundle items: CheckboxGroup owns the selected ID array, allValues defines the current selectable page, and the parent checkbox provides select-all plus mixed state.',
},
},
},
}
function DynamicFormFieldDemo() {
const options = [
{ value: 'markdown', label: 'Markdown' },
{ value: 'pdf', label: 'PDF' },
{ value: 'html', label: 'HTML' },
]
const [selected, setSelected] = useState<string[]>(['markdown'])
return (
<Field.Root name="allowed_file_types" className="flex w-80 flex-col gap-2">
<Field.Description className="body-xs-regular text-text-tertiary">
This mirrors Dify dynamic form fields where checkbox options are controlled by schema and persisted as a string array.
</Field.Description>
<Fieldset.Root
render={(
<CheckboxGroup
value={selected}
onValueChange={setSelected}
className="flex flex-col gap-2 rounded-lg border border-components-panel-border bg-components-panel-bg p-3"
/>
)}
>
<Fieldset.Legend className="system-sm-medium text-text-secondary">
Allowed file types
</Fieldset.Legend>
{options.map(option => (
<Field.Item key={option.value}>
<Field.Label className="flex items-center gap-2 system-sm-medium text-text-secondary">
<Checkbox value={option.value} />
{option.label}
</Field.Label>
</Field.Item>
))}
</Fieldset.Root>
</Field.Root>
)
}
export const DynamicFormField: Story = {
render: () => <DynamicFormFieldDemo />,
parameters: {
docs: {
description: {
story: 'Matches Dify checkbox-list form usage in workflow node forms and base form rendering. Field and Fieldset provide group labeling; CheckboxGroup owns controlled array state.',
},
},
},
}

View File

@ -1,10 +0,0 @@
'use client'
import type { CheckboxGroup as BaseCheckboxGroupNS } from '@base-ui/react/checkbox-group'
import { CheckboxGroup as BaseCheckboxGroup } from '@base-ui/react/checkbox-group'
export type CheckboxGroupProps = BaseCheckboxGroupNS.Props
export function CheckboxGroup(props: CheckboxGroupProps) {
return <BaseCheckboxGroup {...props} />
}

View File

@ -1,129 +0,0 @@
import { render } from 'vitest-browser-react'
import {
Checkbox,
CheckboxIndicator,
CheckboxRoot,
CheckboxSkeleton,
} from '../index'
const asHTMLElement = (element: HTMLElement | SVGElement) => element as HTMLElement
describe('Checkbox', () => {
it('should render an unchecked checkbox with Base UI semantics', async () => {
const screen = await render(<Checkbox checked={false} aria-label="Accept terms" />)
const checkbox = screen.getByRole('checkbox', { name: 'Accept terms' })
await expect.element(checkbox).toHaveAttribute('aria-checked', 'false')
await expect.element(checkbox).toHaveAttribute('data-unchecked', '')
await expect.element(checkbox).not.toHaveAttribute('data-checked')
await expect.element(checkbox).not.toHaveAttribute('data-indeterminate')
})
it('should expose checked data attributes and icon styling hooks', async () => {
const screen = await render(<Checkbox checked aria-label="Accept terms" />)
const checkbox = screen.getByRole('checkbox', { name: 'Accept terms' })
await expect.element(checkbox).toHaveAttribute('aria-checked', 'true')
await expect.element(checkbox).toHaveAttribute('data-checked', '')
await expect.element(checkbox).toHaveClass('data-checked:bg-components-checkbox-bg')
expect(screen.container.querySelector('.i-ri-check-line')).toBeInTheDocument()
})
it('should expose mixed state when indeterminate', async () => {
const screen = await render(<Checkbox checked={false} indeterminate aria-label="Select all" />)
const checkbox = screen.getByRole('checkbox', { name: 'Select all' })
await expect.element(checkbox).toHaveAttribute('aria-checked', 'mixed')
await expect.element(checkbox).toHaveAttribute('data-indeterminate', '')
expect(screen.container.querySelector('.i-ri-check-line')).not.toBeInTheDocument()
expect(screen.container.querySelector('span span.rounded-full.bg-current')).toBeInTheDocument()
})
it('should call onCheckedChange with the next checked value', async () => {
const onCheckedChange = vi.fn()
const screen = await render(
<Checkbox checked={false} aria-label="Accept terms" onCheckedChange={onCheckedChange} />,
)
asHTMLElement(screen.getByRole('checkbox', { name: 'Accept terms' }).element()).click()
expect(onCheckedChange).toHaveBeenCalledTimes(1)
expect(onCheckedChange.mock.calls[0]?.[0]).toBe(true)
})
it('should stay controlled until the checked prop changes', async () => {
const onCheckedChange = vi.fn()
const screen = await render(
<Checkbox checked={false} aria-label="Accept terms" onCheckedChange={onCheckedChange} />,
)
const checkbox = screen.getByRole('checkbox', { name: 'Accept terms' })
asHTMLElement(checkbox.element()).click()
expect(onCheckedChange.mock.calls[0]?.[0]).toBe(true)
await expect.element(checkbox).toHaveAttribute('aria-checked', 'false')
await screen.rerender(<Checkbox checked aria-label="Accept terms" onCheckedChange={onCheckedChange} />)
await expect.element(screen.getByRole('checkbox', { name: 'Accept terms' })).toHaveAttribute('aria-checked', 'true')
})
it('should ignore interaction when disabled', async () => {
const onCheckedChange = vi.fn()
const screen = await render(
<Checkbox checked={false} disabled aria-label="Accept terms" onCheckedChange={onCheckedChange} />,
)
const checkbox = screen.getByRole('checkbox', { name: 'Accept terms' })
await expect.element(checkbox).toHaveAttribute('data-disabled', '')
await expect.element(checkbox).toHaveClass('data-disabled:cursor-not-allowed')
asHTMLElement(checkbox.element()).click()
expect(onCheckedChange).not.toHaveBeenCalled()
})
it('should submit checked and unchecked form values through the hidden input', async () => {
const screen = await render(
<form>
<Checkbox
checked
name="terms"
value="accepted"
uncheckedValue="declined"
aria-label="Terms"
/>
<Checkbox
checked={false}
name="newsletter"
value="yes"
uncheckedValue="no"
aria-label="Newsletter"
/>
</form>,
)
const form = screen.container.querySelector('form') as HTMLFormElement
const data = new FormData(form)
expect(data.get('terms')).toBe('accepted')
expect(data.get('newsletter')).toBe('no')
})
it('should support custom compound composition with CheckboxRoot and CheckboxIndicator', async () => {
const screen = await render(
<CheckboxRoot checked aria-label="Custom checkbox" className="custom-root">
<CheckboxIndicator className="custom-indicator" />
</CheckboxRoot>,
)
await expect.element(screen.getByRole('checkbox', { name: 'Custom checkbox' })).toHaveClass('custom-root')
expect(screen.container.querySelector('.custom-indicator')).toBeInTheDocument()
})
})
describe('CheckboxSkeleton', () => {
it('should render a visual placeholder without checkbox semantics', async () => {
const screen = await render(<CheckboxSkeleton data-testid="checkbox-skeleton" />)
expect(screen.container.querySelector('[role="checkbox"]')).not.toBeInTheDocument()
await expect.element(screen.getByTestId('checkbox-skeleton')).toHaveClass('bg-text-quaternary', 'opacity-20')
})
})

View File

@ -1,145 +0,0 @@
import type { Meta, StoryObj } from '@storybook/react-vite'
import type { ComponentProps } from 'react'
import { useState } from 'react'
import {
Checkbox,
CheckboxSkeleton,
} from '.'
const meta = {
title: 'Base/UI/Checkbox',
component: Checkbox,
parameters: {
layout: 'centered',
docs: {
description: {
component: 'Checkbox primitive built on Base UI. It preserves Base UI checked, indeterminate, disabled, and hidden input semantics while applying the Dify 16px checkbox design from Figma. Import from `@langgenius/dify-ui/checkbox`.',
},
},
},
tags: ['autodocs'],
args: {
checked: false,
disabled: false,
indeterminate: false,
},
argTypes: {
checked: {
control: 'boolean',
description: 'Controlled checked state.',
},
indeterminate: {
control: 'boolean',
description: 'Mixed state used by parent or select-all checkboxes.',
},
disabled: {
control: 'boolean',
description: 'Disables user interaction and exposes Base UI disabled state attributes.',
},
},
} satisfies Meta<typeof Checkbox>
export default meta
type Story = StoryObj<typeof meta>
function CheckboxDemo(args: Partial<ComponentProps<typeof Checkbox>>) {
const [checked, setChecked] = useState(args.checked ?? false)
return (
<label className="flex items-center gap-2 system-sm-medium text-text-secondary">
<Checkbox
{...args}
checked={checked}
onCheckedChange={setChecked}
/>
Enable feature
</label>
)
}
export const Default: Story = {
render: args => <CheckboxDemo {...args} />,
args: {
checked: false,
indeterminate: false,
disabled: false,
},
}
export const Checked: Story = {
render: args => <CheckboxDemo {...args} />,
args: {
checked: true,
indeterminate: false,
disabled: false,
},
}
export const Indeterminate: Story = {
args: {
'checked': false,
'indeterminate': true,
'disabled': false,
'aria-label': 'Partial selection',
},
}
export const Disabled: Story = {
render: () => (
<div className="flex flex-col gap-3">
<label className="flex items-center gap-2 system-sm-medium text-text-secondary">
<Checkbox checked={false} disabled />
Disabled unchecked
</label>
<label className="flex items-center gap-2 system-sm-medium text-text-secondary">
<Checkbox checked disabled />
Disabled checked
</label>
<label className="flex items-center gap-2 system-sm-medium text-text-secondary">
<Checkbox checked={false} indeterminate disabled />
Disabled mixed
</label>
</div>
),
}
function StateMatrixDemo() {
const states = [
{ label: 'Unchecked', checked: false },
{ label: 'Checked', checked: true },
{ label: 'Indeterminate', checked: false, indeterminate: true },
{ label: 'Disabled unchecked', checked: false, disabled: true },
{ label: 'Disabled checked', checked: true, disabled: true },
{ label: 'Disabled indeterminate', checked: false, indeterminate: true, disabled: true },
]
return (
<div className="flex flex-col gap-3">
{states.map(state => (
<label key={state.label} className="flex items-center gap-2 system-sm-medium text-text-secondary">
<Checkbox
checked={state.checked}
indeterminate={state.indeterminate}
disabled={state.disabled}
/>
{state.label}
</label>
))}
<div className="flex items-center gap-2 system-sm-medium text-text-secondary">
<CheckboxSkeleton aria-hidden="true" />
Skeleton
</div>
</div>
)
}
export const StateMatrix: Story = {
render: () => <StateMatrixDemo />,
parameters: {
docs: {
description: {
story: 'The full visual matrix for Dify checkbox states. State styling comes from Base UI data attributes such as data-checked, data-indeterminate, and data-disabled.',
},
},
},
}

View File

@ -1,100 +0,0 @@
'use client'
import type { Checkbox as BaseCheckboxNS } from '@base-ui/react/checkbox'
import type { HTMLAttributes } from 'react'
import { Checkbox as BaseCheckbox } from '@base-ui/react/checkbox'
import { cn } from '../cn'
const checkboxRootClassName = cn(
'inline-flex size-4 shrink-0 touch-manipulation items-center justify-center rounded-sm shadow-xs shadow-shadow-shadow-3 transition-colors motion-reduce:transition-none',
'border border-components-checkbox-border bg-components-checkbox-bg-unchecked text-components-checkbox-icon',
'hover:border-components-checkbox-border-hover hover:bg-components-checkbox-bg-unchecked-hover',
'focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-components-checkbox-bg focus-visible:ring-offset-0',
'data-checked:border-transparent data-checked:bg-components-checkbox-bg data-checked:hover:bg-components-checkbox-bg-hover',
'data-indeterminate:border-transparent data-indeterminate:bg-components-checkbox-bg data-indeterminate:hover:bg-components-checkbox-bg-hover',
'data-disabled:cursor-not-allowed data-disabled:border-components-checkbox-border-disabled data-disabled:bg-components-checkbox-bg-disabled',
'data-disabled:hover:border-components-checkbox-border-disabled data-disabled:hover:bg-components-checkbox-bg-disabled',
'data-disabled:data-checked:border-transparent data-disabled:data-checked:bg-components-checkbox-bg-disabled-checked data-disabled:data-checked:text-components-checkbox-icon-disabled',
'data-disabled:data-checked:hover:bg-components-checkbox-bg-disabled-checked',
'data-disabled:data-indeterminate:border-transparent data-disabled:data-indeterminate:bg-components-checkbox-bg-disabled-checked data-disabled:data-indeterminate:text-components-checkbox-icon-disabled',
'data-disabled:data-indeterminate:hover:bg-components-checkbox-bg-disabled-checked',
)
const checkboxIndicatorClassName = 'flex size-3 items-center justify-center text-current data-unchecked:hidden'
const checkboxSkeletonClassName = 'size-4 shrink-0 rounded-sm bg-text-quaternary opacity-20'
export type CheckboxRootProps
= Omit<BaseCheckboxNS.Root.Props, 'className'>
& {
className?: string
}
export function CheckboxRoot({
className,
...props
}: CheckboxRootProps) {
return (
<BaseCheckbox.Root
className={cn(checkboxRootClassName, className)}
{...props}
/>
)
}
export type CheckboxIndicatorProps
= Omit<BaseCheckboxNS.Indicator.Props, 'className' | 'children'>
& {
className?: string
}
export function CheckboxIndicator({
className,
render,
...props
}: CheckboxIndicatorProps) {
return (
<BaseCheckbox.Indicator
className={cn(checkboxIndicatorClassName, className)}
render={render ?? ((indicatorProps, state) => (
<span {...indicatorProps}>
{state.indeterminate
? <span className="block h-[1.5px] w-1.75 rounded-full bg-current" />
: <span className="i-ri-check-line block size-3 shrink-0" />}
</span>
))}
{...props}
/>
)
}
export type CheckboxProps
= Omit<CheckboxRootProps, 'children'>
export function Checkbox({
...props
}: CheckboxProps) {
return (
<CheckboxRoot {...props}>
<CheckboxIndicator />
</CheckboxRoot>
)
}
export type CheckboxSkeletonProps
= Omit<HTMLAttributes<HTMLDivElement>, 'className'>
& {
className?: string
}
export function CheckboxSkeleton({
className,
...props
}: CheckboxSkeletonProps) {
return (
<div
className={cn(checkboxSkeletonClassName, className)}
{...props}
/>
)
}

View File

@ -1,9 +1,8 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { CheckboxList } from '..'
import CheckboxList from '..'
describe('checkbox list component', () => {
const selectAllName = 'common.operation.selectAll'
const options = [
{ label: 'Option 1', value: 'option1' },
{ label: 'Option 2', value: 'option2' },
@ -39,7 +38,8 @@ describe('checkbox list component', () => {
it('renders select-all checkbox', () => {
render(<CheckboxList options={options} showSelectAll />)
expect(screen.getByRole('checkbox', { name: selectAllName })).toBeInTheDocument()
const checkboxes = screen.getByTestId('checkbox-selectAll')
expect(checkboxes)!.toBeInTheDocument()
})
it('selects all options when select-all is clicked', async () => {
@ -54,7 +54,7 @@ describe('checkbox list component', () => {
/>,
)
const selectAll = screen.getByRole('checkbox', { name: selectAllName })
const selectAll = screen.getByTestId('checkbox-selectAll')
await userEvent.click(selectAll)
expect(onChange).toHaveBeenCalledWith(['option1', 'option2', 'option3', 'apple'])
@ -73,7 +73,7 @@ describe('checkbox list component', () => {
/>,
)
const selectAll = screen.getByRole('checkbox', { name: selectAllName })
const selectAll = screen.getByTestId('checkbox-selectAll')
await userEvent.click(selectAll)
expect(onChange).not.toHaveBeenCalled()
@ -91,7 +91,7 @@ describe('checkbox list component', () => {
/>,
)
const selectAll = screen.getByRole('checkbox', { name: selectAllName })
const selectAll = screen.getByTestId('checkbox-selectAll')
await userEvent.click(selectAll)
expect(onChange).toHaveBeenCalledWith([])
@ -109,14 +109,14 @@ describe('checkbox list component', () => {
/>,
)
const selectAll = screen.getByRole('checkbox', { name: selectAllName })
expect(selectAll).toHaveAttribute('aria-checked', 'true')
const selectAll = screen.getByTestId('checkbox-selectAll')
expect(selectAll.querySelector('[data-testid="check-icon-selectAll"]'))!.toBeInTheDocument()
})
it('hides select-all checkbox when searching', async () => {
render(<CheckboxList options={options} />)
await userEvent.type(screen.getByRole('textbox'), 'app')
expect(screen.queryByRole('checkbox', { name: selectAllName })).not.toBeInTheDocument()
expect(screen.queryByTestId('checkbox-selectAll')).not.toBeInTheDocument()
})
it('selects options when checkbox is clicked', async () => {
@ -131,7 +131,7 @@ describe('checkbox list component', () => {
/>,
)
const selectOption = screen.getByRole('checkbox', { name: 'Option 1' })
const selectOption = screen.getByTestId('checkbox-option1')
await userEvent.click(selectOption)
expect(onChange).toHaveBeenCalledWith(['option1'])
})
@ -148,7 +148,7 @@ describe('checkbox list component', () => {
/>,
)
const selectOption = screen.getByRole('checkbox', { name: 'Option 1' })
const selectOption = screen.getByTestId('checkbox-option1')
await userEvent.click(selectOption)
expect(onChange).toHaveBeenCalledWith([])
})
@ -165,7 +165,7 @@ describe('checkbox list component', () => {
/>,
)
const selectOption = screen.getByRole('checkbox', { name: 'Option 1' })
const selectOption = screen.getByTestId('checkbox-option1')
await userEvent.click(selectOption)
expect(onChange).not.toHaveBeenCalled()
})
@ -202,12 +202,12 @@ describe('checkbox list component', () => {
/>,
)
const disabledCheckbox = screen.getByRole('checkbox', { name: 'Disabled' })
const disabledCheckbox = screen.getByTestId('checkbox-disabled')
await userEvent.click(disabledCheckbox)
expect(onChange).not.toHaveBeenCalled()
})
it('does not toggle option when component is disabled and option label is clicked', async () => {
it('does not toggle option when component is disabled and option is clicked via div', async () => {
const onChange = vi.fn()
render(
@ -219,7 +219,11 @@ describe('checkbox list component', () => {
/>,
)
await userEvent.click(screen.getByText('Option 1'))
// Find option and click the div container
const optionLabels = screen.getAllByText('Option 1')
const optionDiv = optionLabels[0]!.closest('[data-testid="option-item"]')
expect(optionDiv)!.toBeInTheDocument()
await userEvent.click(optionDiv as HTMLElement)
expect(onChange).not.toHaveBeenCalled()
})
@ -242,7 +246,7 @@ describe('checkbox list component', () => {
showSearch={false}
/>,
)
expect(screen.queryByRole('checkbox', { name: selectAllName })).not.toBeInTheDocument()
expect(screen.queryByTestId('checkbox-selectAll')).not.toBeInTheDocument()
options.forEach((option) => {
expect(screen.getByText(option.label))!.toBeInTheDocument()
})
@ -280,7 +284,7 @@ describe('checkbox list component', () => {
/>,
)
// When some but not all options are selected, clicking select-all should select all remaining options
const selectAll = screen.getByRole('checkbox', { name: selectAllName })
const selectAll = screen.getByTestId('checkbox-selectAll')
expect(selectAll)!.toBeInTheDocument()
expect(selectAll)!.toHaveAttribute('aria-checked', 'mixed')
@ -322,7 +326,7 @@ describe('checkbox list component', () => {
)
const optionLabel = screen.getByText('Option 1')
const optionRow = optionLabel.closest('label[data-testid="option-item"]')
const optionRow = optionLabel.closest('div[data-testid="option-item"]')
expect(optionRow)!.toBeInTheDocument()
await userEvent.click(optionRow as HTMLElement)
@ -343,7 +347,7 @@ describe('checkbox list component', () => {
/>,
)
const optionRow = screen.getByText('Option 1').closest('label[data-testid="option-item"]')
const optionRow = screen.getByText('Option 1').closest('div[data-testid="option-item"]')
expect(optionRow)!.toBeInTheDocument()
await userEvent.click(optionRow as HTMLElement)
@ -400,7 +404,7 @@ describe('checkbox list component', () => {
/>,
)
const checkbox = screen.getByRole('checkbox', { name: 'Option' })
const checkbox = screen.getByTestId('checkbox-option')
await userEvent.click(checkbox)
expect(onChange).not.toHaveBeenCalled()
})

View File

@ -1,11 +1,11 @@
'use client'
import type { FC } from 'react'
import { Button } from '@langgenius/dify-ui/button'
import { Checkbox } from '@langgenius/dify-ui/checkbox'
import { CheckboxGroup } from '@langgenius/dify-ui/checkbox-group'
import { cn } from '@langgenius/dify-ui/cn'
import { useId, useMemo, useState } from 'react'
import { useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Badge from '@/app/components/base/badge'
import Checkbox from '@/app/components/base/checkbox'
import SearchInput from '@/app/components/base/search-input'
import SearchMenu from '@/assets/search-menu.svg'
@ -30,7 +30,7 @@ type CheckboxListProps = {
maxHeight?: string | number
}
export const CheckboxList = ({
const CheckboxList: FC<CheckboxListProps> = ({
title = '',
label,
description,
@ -43,9 +43,8 @@ export const CheckboxList = ({
showCount = true,
showSearch = true,
maxHeight,
}: CheckboxListProps) => {
}) => {
const { t } = useTranslation()
const groupLabelId = useId()
const [searchQuery, setSearchQuery] = useState('')
const filteredOptions = useMemo(() => {
@ -60,15 +59,48 @@ export const CheckboxList = ({
const selectedCount = value.length
const selectableOptionValues = useMemo(
() => options.filter(option => !option.disabled).map(option => option.value),
[options],
)
const isAllSelected = useMemo(() => {
const selectableOptions = options.filter(option => !option.disabled)
return selectableOptions.length > 0 && selectableOptions.every(option => value.includes(option.value))
}, [options, value])
const isIndeterminate = useMemo(() => {
const selectableOptions = options.filter(option => !option.disabled)
const selectedCount = selectableOptions.filter(option => value.includes(option.value)).length
return selectedCount > 0 && selectedCount < selectableOptions.length
}, [options, value])
const handleSelectAll = useCallback(() => {
if (disabled)
return
if (isAllSelected) {
// Deselect all
onChange?.([])
}
else {
// Select all non-disabled options
const allValues = options
.filter(option => !option.disabled)
.map(option => option.value)
onChange?.(allValues)
}
}, [isAllSelected, options, onChange, disabled])
const handleToggleOption = useCallback((optionValue: string) => {
if (disabled)
return
const newValue = value.includes(optionValue)
? value.filter(v => v !== optionValue)
: [...value, optionValue]
onChange?.(newValue)
}, [value, onChange, disabled])
return (
<div className={cn('flex w-full flex-col gap-1', containerClassName)}>
{label && (
<div id={groupLabelId} className="system-sm-medium text-text-secondary">
<div className="system-sm-medium text-text-secondary">
{label}
</div>
)}
@ -78,24 +110,17 @@ export const CheckboxList = ({
</div>
)}
<CheckboxGroup
aria-labelledby={label ? groupLabelId : undefined}
value={value}
onValueChange={nextValue => onChange?.(nextValue)}
allValues={selectableOptionValues}
disabled={disabled}
className="rounded-lg border border-components-panel-border bg-components-panel-bg"
>
<div className="rounded-lg border border-components-panel-border bg-components-panel-bg">
{(showSelectAll || title || showSearch) && (
<div className="relative flex items-center gap-2 border-b border-divider-subtle px-3 py-2">
{!searchQuery && showSelectAll && (
<label className={cn('flex shrink-0 items-center', !disabled && 'cursor-pointer')}>
<Checkbox
parent
disabled={disabled}
/>
<span className="sr-only">{t('operation.selectAll', { ns: 'common' })}</span>
</label>
<Checkbox
checked={isAllSelected}
indeterminate={isIndeterminate}
onCheck={handleSelectAll}
disabled={disabled}
id="selectAll"
/>
)}
{!searchQuery
? (
@ -152,30 +177,45 @@ export const CheckboxList = ({
</div>
)
: (
filteredOptions.map(option => (
<label
key={option.value}
data-testid="option-item"
className={cn(
'flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 transition-colors hover:bg-state-base-hover',
(option.disabled || disabled) && 'cursor-not-allowed opacity-50',
)}
>
<Checkbox
value={option.value}
disabled={option.disabled || disabled}
/>
<span
className="flex-1 truncate system-sm-medium text-text-secondary"
title={option.label}
filteredOptions.map((option) => {
const selected = value.includes(option.value)
return (
<div
key={option.value}
data-testid="option-item"
className={cn(
'flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 transition-colors hover:bg-state-base-hover',
option.disabled && 'cursor-not-allowed opacity-50',
)}
onClick={() => {
if (!option.disabled && !disabled)
handleToggleOption(option.value)
}}
>
{option.label}
</span>
</label>
))
<Checkbox
checked={selected}
onCheck={() => {
if (!option.disabled && !disabled)
handleToggleOption(option.value)
}}
disabled={option.disabled || disabled}
id={option.value}
/>
<div
className="flex-1 truncate system-sm-medium text-text-secondary"
title={option.label}
>
{option.label}
</div>
</div>
)
})
)}
</div>
</CheckboxGroup>
</div>
</div>
)
}
export default CheckboxList

View File

@ -394,8 +394,8 @@ describe('BaseField', () => {
fireEvent.click(screen.getByText('Feature B'))
})
const checkboxB = screen.getByRole('checkbox', { name: 'Feature B' })
expect(checkboxB).toHaveAttribute('aria-checked', 'true')
const checkboxB = screen.getByTestId('checkbox-b')
expect(checkboxB).toBeChecked()
})
it('should handle dynamic select error state', () => {

View File

@ -18,7 +18,7 @@ import {
useMemo,
} from 'react'
import { useTranslation } from 'react-i18next'
import { CheckboxList } from '@/app/components/base/checkbox-list'
import CheckboxList from '@/app/components/base/checkbox-list'
import { FormItemValidateStatusEnum, FormTypeEnum } from '@/app/components/base/form/types'
import { Infotip } from '@/app/components/base/infotip'
import Input from '@/app/components/base/input'

View File

@ -1,6 +1,5 @@
import type { MetadataItemInBatchEdit, MetadataItemWithEdit } from '../../types'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import { DataType, UpdateType } from '../../types'
import EditMetadataBatchModal from '../modal'
@ -213,7 +212,6 @@ describe('EditMetadataBatchModal', () => {
})
it('should toggle apply to all checkbox', async () => {
const user = userEvent.setup()
render(<EditMetadataBatchModal {...defaultProps} />)
await waitFor(() => {
@ -221,7 +219,7 @@ describe('EditMetadataBatchModal', () => {
})
const checkbox = screen.getByRole('checkbox', { name: 'dataset.metadata.batchEditMetadata.applyToAllSelectDocument' })
await user.click(checkbox)
fireEvent.click(checkbox)
await waitFor(() => {
expect(checkbox).toHaveAttribute('aria-checked', 'true')
@ -484,7 +482,6 @@ describe('EditMetadataBatchModal', () => {
})
it('should pass isApplyToAllSelectDocument as true when checked', async () => {
const user = userEvent.setup()
const onSave = vi.fn()
render(<EditMetadataBatchModal {...defaultProps} onSave={onSave} />)
@ -492,7 +489,7 @@ describe('EditMetadataBatchModal', () => {
expect(screen.getByRole('dialog'))!.toBeInTheDocument()
})
await user.click(screen.getByRole('checkbox', { name: 'dataset.metadata.batchEditMetadata.applyToAllSelectDocument' }))
fireEvent.click(screen.getByRole('checkbox', { name: 'dataset.metadata.batchEditMetadata.applyToAllSelectDocument' }))
fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' }))

View File

@ -2,7 +2,6 @@
import type { FC } from 'react'
import type { BuiltInMetadataItem, MetadataItemInBatchEdit, MetadataItemWithEdit } from '../types'
import { Button } from '@langgenius/dify-ui/button'
import { Checkbox } from '@langgenius/dify-ui/checkbox'
import { Dialog, DialogCloseButton, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog'
import { toast } from '@langgenius/dify-ui/toast'
import { produce } from 'immer'
@ -11,6 +10,7 @@ import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Divider from '@/app/components/base/divider'
import { useCreateMetaData } from '@/service/knowledge/use-metadata'
import Checkbox from '../../../base/checkbox'
import { Infotip } from '../../../base/infotip'
import useCheckMetadataName from '../hooks/use-check-metadata-name'
import { DatasetMetadataPicker } from '../metadata-dataset/dataset-metadata-picker'
@ -131,15 +131,13 @@ const EditMetadataBatchModal: FC<Props> = ({ datasetId, documentNum, list, onSav
<div className="mt-4 flex items-center justify-between">
<div className="flex items-center select-none">
<label className="flex cursor-pointer items-center">
<Checkbox
checked={isApplyToAllSelectDocument}
onCheckedChange={setIsApplyToAllSelectDocument}
/>
<span className="mr-1 ml-2 system-xs-medium text-text-secondary">
{t(`${i18nPrefix}.applyToAllSelectDocument`, { ns: 'dataset' })}
</span>
</label>
<Checkbox
checked={isApplyToAllSelectDocument}
onCheck={() => setIsApplyToAllSelectDocument(!isApplyToAllSelectDocument)}
id="apply-to-all"
ariaLabel={t(`${i18nPrefix}.applyToAllSelectDocument`, { ns: 'dataset' })}
/>
<div className="mr-1 ml-2 system-xs-medium text-text-secondary">{t(`${i18nPrefix}.applyToAllSelectDocument`, { ns: 'dataset' })}</div>
<Infotip
aria-label={t(`${i18nPrefix}.applyToAllSelectDocumentTip`, { ns: 'dataset' })}
className="p-px"

View File

@ -8,7 +8,7 @@ import type { ToolWithProvider, ValueSelector, Var } from '@/app/components/work
import { cn } from '@langgenius/dify-ui/cn'
import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select'
import { useEffect, useMemo, useState } from 'react'
import { CheckboxList } from '@/app/components/base/checkbox-list'
import CheckboxList from '@/app/components/base/checkbox-list'
import Input from '@/app/components/base/input'
import { useLanguage } from '@/app/components/header/account-setting/model-provider-page/hooks'
import { AppSelector } from '@/app/components/plugins/plugin-detail-panel/app-selector'