mirror of
https://github.com/langgenius/dify.git
synced 2026-05-18 16:06:36 +08:00
Compare commits
1 Commits
feat/dify_
...
dependabot
| Author | SHA1 | Date | |
|---|---|---|---|
| a16817c27e |
6
.github/workflows/autofix.yml
vendored
6
.github/workflows/autofix.yml
vendored
@ -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'
|
||||
|
||||
@ -1 +0,0 @@
|
||||
"""External service client packages."""
|
||||
@ -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",
|
||||
]
|
||||
@ -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__)
|
||||
@ -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}")
|
||||
@ -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__}")
|
||||
@ -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))
|
||||
@ -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"),
|
||||
),
|
||||
)
|
||||
@ -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
|
||||
@ -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")
|
||||
@ -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",
|
||||
|
||||
@ -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 {}
|
||||
@ -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",
|
||||
|
||||
@ -1 +0,0 @@
|
||||
"""Client unit tests."""
|
||||
@ -1 +0,0 @@
|
||||
"""Agent backend client contract tests."""
|
||||
@ -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"
|
||||
@ -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",
|
||||
)
|
||||
]
|
||||
@ -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"
|
||||
@ -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]"
|
||||
@ -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
34
api/uv.lock
generated
@ -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" },
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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] = []
|
||||
|
||||
|
||||
@ -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"})
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -13,7 +13,6 @@ export type EmailCodeLoginPayload = {
|
||||
code: string
|
||||
email: string
|
||||
language?: string | null
|
||||
timezone?: string | null
|
||||
token: string
|
||||
}
|
||||
|
||||
|
||||
@ -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(),
|
||||
})
|
||||
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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: [
|
||||
{
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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'])
|
||||
})
|
||||
})
|
||||
@ -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.',
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
@ -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} />
|
||||
}
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
@ -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.',
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@ -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()
|
||||
})
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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' }))
|
||||
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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'
|
||||
|
||||
Reference in New Issue
Block a user