mirror of
https://github.com/langgenius/dify.git
synced 2026-06-17 13:17:44 +08:00
Compare commits
4 Commits
codex/adap
...
fix/agent-
| Author | SHA1 | Date | |
|---|---|---|---|
| b339a66403 | |||
| d3c27d9d89 | |||
| f992ede836 | |||
| 6ab5cf109b |
@ -2,7 +2,7 @@ from uuid import UUID
|
||||
|
||||
from flask import request
|
||||
from flask_restx import Resource
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import AliasChoices, BaseModel, Field
|
||||
|
||||
from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models
|
||||
from controllers.console import console_ns
|
||||
@ -10,6 +10,7 @@ from controllers.console.app.app import (
|
||||
AppDetailWithSite,
|
||||
AppListQuery,
|
||||
AppPagination,
|
||||
AppPartial,
|
||||
UpdateAppPayload,
|
||||
_normalize_app_list_query_args,
|
||||
)
|
||||
@ -63,6 +64,16 @@ class AgentAppUpdatePayload(UpdateAppPayload):
|
||||
role: str | None = Field(default=None, description="Agent role", max_length=255)
|
||||
|
||||
|
||||
class AgentAppPartial(AppPartial):
|
||||
published_reference_count: int = 0
|
||||
published_node_reference_count: int = 0
|
||||
published_references: list[AgentPublishedReferenceResponse] = Field(default_factory=list)
|
||||
|
||||
|
||||
class AgentAppPagination(AppPagination):
|
||||
data: list[AgentAppPartial] = Field(validation_alias=AliasChoices("items", "data"))
|
||||
|
||||
|
||||
register_schema_models(
|
||||
console_ns,
|
||||
AgentAppCreatePayload,
|
||||
@ -76,7 +87,7 @@ register_schema_models(
|
||||
register_response_schema_models(
|
||||
console_ns,
|
||||
AppDetailWithSite,
|
||||
AppPagination,
|
||||
AgentAppPagination,
|
||||
AgentConfigSnapshotDetailResponse,
|
||||
AgentConfigSnapshotListResponse,
|
||||
AgentInviteOptionsResponse,
|
||||
@ -122,6 +133,10 @@ def _serialize_agent_app_pagination(app_pagination, *, tenant_id: str) -> dict:
|
||||
tenant_id=tenant_id,
|
||||
agents=list(agents_by_app_id.values()),
|
||||
)
|
||||
published_references_by_agent_id = roster_service.load_published_references_by_agent_id(
|
||||
tenant_id=tenant_id,
|
||||
agent_ids=[agent.id for agent in agents_by_app_id.values()],
|
||||
)
|
||||
payload = AppPagination.model_validate(app_pagination, from_attributes=True).model_dump(mode="json")
|
||||
for item in payload["data"]:
|
||||
app_id = item["id"]
|
||||
@ -132,7 +147,16 @@ def _serialize_agent_app_pagination(app_pagination, *, tenant_id: str) -> dict:
|
||||
item["id"] = agent.id
|
||||
item["role"] = agent.role or ""
|
||||
item["active_config_is_published"] = active_config_is_published_by_agent_id.get(agent.id, False)
|
||||
return payload
|
||||
published_references = published_references_by_agent_id.get(agent.id, [])
|
||||
item["published_reference_count"] = len(published_references)
|
||||
item["published_node_reference_count"] = sum(
|
||||
len(reference["node_ids"]) for reference in published_references
|
||||
)
|
||||
item["published_references"] = published_references
|
||||
return AgentAppPagination.model_validate(payload).model_dump(
|
||||
mode="json",
|
||||
exclude={"data": {"__all__": {"bound_agent_id"}}},
|
||||
)
|
||||
|
||||
|
||||
def _resolve_agent_app_model(*, tenant_id: str, agent_id: UUID):
|
||||
@ -142,7 +166,7 @@ def _resolve_agent_app_model(*, tenant_id: str, agent_id: UUID):
|
||||
@console_ns.route("/agent")
|
||||
class AgentAppListApi(Resource):
|
||||
@console_ns.doc(params=query_params_from_model(AppListQuery))
|
||||
@console_ns.response(200, "Agent app list", console_ns.models[AppPagination.__name__])
|
||||
@console_ns.response(200, "Agent app list", console_ns.models[AgentAppPagination.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@ -163,7 +187,7 @@ class AgentAppListApi(Resource):
|
||||
|
||||
app_pagination = AppService().get_paginate_apps(current_user.id, current_tenant_id, params)
|
||||
if app_pagination is None:
|
||||
empty = AppPagination(page=args.page, limit=args.limit, total=0, has_more=False, data=[])
|
||||
empty = AgentAppPagination(page=args.page, limit=args.limit, total=0, has_more=False, data=[])
|
||||
return empty.model_dump(mode="json")
|
||||
|
||||
return _serialize_agent_app_pagination(app_pagination, tenant_id=current_tenant_id)
|
||||
|
||||
@ -35,6 +35,7 @@ Example:
|
||||
"""
|
||||
|
||||
from collections.abc import Callable, Sequence
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Protocol, TypedDict
|
||||
|
||||
@ -65,6 +66,21 @@ class RunsWithRelatedCountsDict(TypedDict):
|
||||
pause_reasons: int
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WorkflowRunCleanupRef:
|
||||
"""
|
||||
Lightweight workflow run reference for retention cleanup scans.
|
||||
|
||||
Cleanup jobs use this DTO when they only need cursor, tenant eligibility, and run-id deletion data. Keeping the
|
||||
query shape explicit prevents free-plan cleanup from hydrating full WorkflowRun models for rows that may be skipped
|
||||
after billing checks.
|
||||
"""
|
||||
|
||||
id: str
|
||||
tenant_id: str
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class APIWorkflowRunRepository(WorkflowExecutionRepository, Protocol):
|
||||
"""
|
||||
Protocol for service-layer WorkflowRun repository operations.
|
||||
@ -286,6 +302,36 @@ class APIWorkflowRunRepository(WorkflowExecutionRepository, Protocol):
|
||||
"""
|
||||
...
|
||||
|
||||
def get_cleanup_refs_batch_by_time_range(
|
||||
self,
|
||||
start_from: datetime | None,
|
||||
end_before: datetime,
|
||||
last_seen: tuple[datetime, str] | None,
|
||||
batch_size: int,
|
||||
run_types: Sequence[WorkflowType] | None = None,
|
||||
tenant_ids: Sequence[str] | None = None,
|
||||
workflow_ids: Sequence[str] | None = None,
|
||||
upper_bound: tuple[datetime, str] | None = None,
|
||||
) -> Sequence[WorkflowRunCleanupRef]:
|
||||
"""
|
||||
Fetch lightweight ended workflow run refs in a time window for cleanup batching.
|
||||
|
||||
Args:
|
||||
start_from: Optional inclusive lower time boundary.
|
||||
end_before: Exclusive upper time boundary.
|
||||
last_seen: Optional exclusive `(created_at, id)` cursor lower bound.
|
||||
batch_size: Maximum number of refs to return.
|
||||
run_types: Optional workflow type filter.
|
||||
tenant_ids: Optional tenant filter.
|
||||
workflow_ids: Optional workflow ID filter.
|
||||
upper_bound: Optional inclusive `(created_at, id)` cursor upper bound. Cleanup uses this for a second,
|
||||
tenant-filtered target query that must stay within the candidate page high-water cursor.
|
||||
|
||||
Returns:
|
||||
Ordered lightweight cleanup refs containing only id, tenant_id, and created_at.
|
||||
"""
|
||||
...
|
||||
|
||||
def get_archived_run_ids(
|
||||
self,
|
||||
session: Session,
|
||||
@ -370,6 +416,19 @@ class APIWorkflowRunRepository(WorkflowExecutionRepository, Protocol):
|
||||
"""
|
||||
...
|
||||
|
||||
def delete_runs_with_related_by_ids(
|
||||
self,
|
||||
run_ids: Sequence[str],
|
||||
delete_node_executions: Callable[[Session, Sequence[str]], tuple[int, int]] | None = None,
|
||||
delete_trigger_logs: Callable[[Session, Sequence[str]], int] | None = None,
|
||||
) -> RunsWithRelatedCountsDict:
|
||||
"""
|
||||
Delete workflow runs and cleanup-owned related records by workflow run IDs.
|
||||
|
||||
This mirrors delete_runs_with_related() for cleanup callers that do not need full WorkflowRun models.
|
||||
"""
|
||||
...
|
||||
|
||||
def get_app_logs_by_run_id(
|
||||
self,
|
||||
session: Session,
|
||||
@ -417,6 +476,19 @@ class APIWorkflowRunRepository(WorkflowExecutionRepository, Protocol):
|
||||
"""
|
||||
...
|
||||
|
||||
def count_runs_with_related_by_ids(
|
||||
self,
|
||||
run_ids: Sequence[str],
|
||||
count_node_executions: Callable[[Session, Sequence[str]], tuple[int, int]] | None = None,
|
||||
count_trigger_logs: Callable[[Session, Sequence[str]], int] | None = None,
|
||||
) -> RunsWithRelatedCountsDict:
|
||||
"""
|
||||
Count workflow runs and cleanup-owned related records by workflow run IDs.
|
||||
|
||||
This mirrors count_runs_with_related() for dry-run cleanup callers that do not need full WorkflowRun models.
|
||||
"""
|
||||
...
|
||||
|
||||
def create_workflow_pause(
|
||||
self,
|
||||
workflow_run_id: str,
|
||||
|
||||
@ -44,7 +44,11 @@ from libs.time_parser import get_time_threshold
|
||||
from models.enums import WorkflowRunTriggeredFrom
|
||||
from models.human_input import HumanInputForm, HumanInputFormRecipient
|
||||
from models.workflow import WorkflowAppLog, WorkflowArchiveLog, WorkflowPause, WorkflowPauseReason, WorkflowRun
|
||||
from repositories.api_workflow_run_repository import APIWorkflowRunRepository, RunsWithRelatedCountsDict
|
||||
from repositories.api_workflow_run_repository import (
|
||||
APIWorkflowRunRepository,
|
||||
RunsWithRelatedCountsDict,
|
||||
WorkflowRunCleanupRef,
|
||||
)
|
||||
from repositories.entities.workflow_pause import WorkflowPauseEntity
|
||||
from repositories.types import (
|
||||
AverageInteractionStats,
|
||||
@ -420,6 +424,71 @@ class DifyAPISQLAlchemyWorkflowRunRepository(APIWorkflowRunRepository):
|
||||
|
||||
return session.scalars(stmt).all()
|
||||
|
||||
@override
|
||||
def get_cleanup_refs_batch_by_time_range(
|
||||
self,
|
||||
start_from: datetime | None,
|
||||
end_before: datetime,
|
||||
last_seen: tuple[datetime, str] | None,
|
||||
batch_size: int,
|
||||
run_types: Sequence[WorkflowType] | None = None,
|
||||
tenant_ids: Sequence[str] | None = None,
|
||||
workflow_ids: Sequence[str] | None = None,
|
||||
upper_bound: tuple[datetime, str] | None = None,
|
||||
) -> Sequence[WorkflowRunCleanupRef]:
|
||||
"""
|
||||
Fetch lightweight ended workflow run refs in a time window for cleanup batching.
|
||||
|
||||
The optional upper_bound is inclusive and is paired with last_seen by free-plan cleanup so a second,
|
||||
tenant-filtered target query stays within the candidate page already checked against billing.
|
||||
"""
|
||||
with self._session_maker() as session:
|
||||
stmt = (
|
||||
select(WorkflowRun.id, WorkflowRun.tenant_id, WorkflowRun.created_at)
|
||||
.where(
|
||||
WorkflowRun.created_at < end_before,
|
||||
WorkflowRun.status.in_(WorkflowExecutionStatus.ended_values()),
|
||||
)
|
||||
.order_by(WorkflowRun.created_at.asc(), WorkflowRun.id.asc())
|
||||
.limit(batch_size)
|
||||
)
|
||||
if run_types is not None:
|
||||
if not run_types:
|
||||
return []
|
||||
stmt = stmt.where(WorkflowRun.type.in_(run_types))
|
||||
|
||||
if start_from:
|
||||
stmt = stmt.where(WorkflowRun.created_at >= start_from)
|
||||
|
||||
if tenant_ids:
|
||||
stmt = stmt.where(WorkflowRun.tenant_id.in_(tenant_ids))
|
||||
|
||||
if workflow_ids:
|
||||
stmt = stmt.where(WorkflowRun.workflow_id.in_(workflow_ids))
|
||||
|
||||
if last_seen:
|
||||
stmt = stmt.where(
|
||||
tuple_(WorkflowRun.created_at, WorkflowRun.id)
|
||||
> tuple_(
|
||||
sa.literal(last_seen[0], type_=sa.DateTime()),
|
||||
sa.literal(last_seen[1], type_=WorkflowRun.id.type),
|
||||
)
|
||||
)
|
||||
|
||||
if upper_bound:
|
||||
stmt = stmt.where(
|
||||
tuple_(WorkflowRun.created_at, WorkflowRun.id)
|
||||
<= tuple_(
|
||||
sa.literal(upper_bound[0], type_=sa.DateTime()),
|
||||
sa.literal(upper_bound[1], type_=WorkflowRun.id.type),
|
||||
)
|
||||
)
|
||||
|
||||
return [
|
||||
WorkflowRunCleanupRef(id=run_id, tenant_id=tenant_id, created_at=created_at)
|
||||
for run_id, tenant_id, created_at in session.execute(stmt).all()
|
||||
]
|
||||
|
||||
@override
|
||||
def get_archived_run_ids(
|
||||
self,
|
||||
@ -530,6 +599,56 @@ class DifyAPISQLAlchemyWorkflowRunRepository(APIWorkflowRunRepository):
|
||||
"pause_reasons": pause_reasons_deleted,
|
||||
}
|
||||
|
||||
@override
|
||||
def delete_runs_with_related_by_ids(
|
||||
self,
|
||||
run_ids: Sequence[str],
|
||||
delete_node_executions: Callable[[Session, Sequence[str]], tuple[int, int]] | None = None,
|
||||
delete_trigger_logs: Callable[[Session, Sequence[str]], int] | None = None,
|
||||
) -> RunsWithRelatedCountsDict:
|
||||
if not run_ids:
|
||||
return self._empty_runs_with_related_counts()
|
||||
|
||||
run_ids = list(run_ids)
|
||||
with self._session_maker() as session:
|
||||
if delete_node_executions:
|
||||
node_executions_deleted, offloads_deleted = delete_node_executions(session, run_ids)
|
||||
else:
|
||||
node_executions_deleted, offloads_deleted = 0, 0
|
||||
|
||||
app_logs_result = session.execute(delete(WorkflowAppLog).where(WorkflowAppLog.workflow_run_id.in_(run_ids)))
|
||||
app_logs_deleted = cast(CursorResult, app_logs_result).rowcount or 0
|
||||
|
||||
pause_stmt = select(WorkflowPause.id).where(WorkflowPause.workflow_run_id.in_(run_ids))
|
||||
pause_ids = session.scalars(pause_stmt).all()
|
||||
pause_reasons_deleted = 0
|
||||
pauses_deleted = 0
|
||||
|
||||
if pause_ids:
|
||||
pause_reasons_result = session.execute(
|
||||
delete(WorkflowPauseReason).where(WorkflowPauseReason.pause_id.in_(pause_ids))
|
||||
)
|
||||
pause_reasons_deleted = cast(CursorResult, pause_reasons_result).rowcount or 0
|
||||
pauses_result = session.execute(delete(WorkflowPause).where(WorkflowPause.id.in_(pause_ids)))
|
||||
pauses_deleted = cast(CursorResult, pauses_result).rowcount or 0
|
||||
|
||||
trigger_logs_deleted = delete_trigger_logs(session, run_ids) if delete_trigger_logs else 0
|
||||
|
||||
runs_result = session.execute(delete(WorkflowRun).where(WorkflowRun.id.in_(run_ids)))
|
||||
runs_deleted = cast(CursorResult, runs_result).rowcount or 0
|
||||
|
||||
session.commit()
|
||||
|
||||
return {
|
||||
"runs": runs_deleted,
|
||||
"node_executions": node_executions_deleted,
|
||||
"offloads": offloads_deleted,
|
||||
"app_logs": app_logs_deleted,
|
||||
"trigger_logs": trigger_logs_deleted,
|
||||
"pauses": pauses_deleted,
|
||||
"pause_reasons": pause_reasons_deleted,
|
||||
}
|
||||
|
||||
@override
|
||||
def get_app_logs_by_run_id(
|
||||
self,
|
||||
@ -711,6 +830,72 @@ class DifyAPISQLAlchemyWorkflowRunRepository(APIWorkflowRunRepository):
|
||||
"pause_reasons": int(pause_reasons_count),
|
||||
}
|
||||
|
||||
@override
|
||||
def count_runs_with_related_by_ids(
|
||||
self,
|
||||
run_ids: Sequence[str],
|
||||
count_node_executions: Callable[[Session, Sequence[str]], tuple[int, int]] | None = None,
|
||||
count_trigger_logs: Callable[[Session, Sequence[str]], int] | None = None,
|
||||
) -> RunsWithRelatedCountsDict:
|
||||
if not run_ids:
|
||||
return self._empty_runs_with_related_counts()
|
||||
|
||||
run_ids = list(run_ids)
|
||||
with self._session_maker() as session:
|
||||
if count_node_executions:
|
||||
node_executions_count, offloads_count = count_node_executions(session, run_ids)
|
||||
else:
|
||||
node_executions_count, offloads_count = 0, 0
|
||||
|
||||
runs_count = (
|
||||
session.scalar(select(func.count()).select_from(WorkflowRun).where(WorkflowRun.id.in_(run_ids))) or 0
|
||||
)
|
||||
app_logs_count = (
|
||||
session.scalar(
|
||||
select(func.count()).select_from(WorkflowAppLog).where(WorkflowAppLog.workflow_run_id.in_(run_ids))
|
||||
)
|
||||
or 0
|
||||
)
|
||||
|
||||
pause_ids = session.scalars(
|
||||
select(WorkflowPause.id).where(WorkflowPause.workflow_run_id.in_(run_ids))
|
||||
).all()
|
||||
pauses_count = len(pause_ids)
|
||||
pause_reasons_count = 0
|
||||
if pause_ids:
|
||||
pause_reasons_count = (
|
||||
session.scalar(
|
||||
select(func.count())
|
||||
.select_from(WorkflowPauseReason)
|
||||
.where(WorkflowPauseReason.pause_id.in_(pause_ids))
|
||||
)
|
||||
or 0
|
||||
)
|
||||
|
||||
trigger_logs_count = count_trigger_logs(session, run_ids) if count_trigger_logs else 0
|
||||
|
||||
return {
|
||||
"runs": int(runs_count),
|
||||
"node_executions": node_executions_count,
|
||||
"offloads": offloads_count,
|
||||
"app_logs": int(app_logs_count),
|
||||
"trigger_logs": trigger_logs_count,
|
||||
"pauses": pauses_count,
|
||||
"pause_reasons": int(pause_reasons_count),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _empty_runs_with_related_counts() -> RunsWithRelatedCountsDict:
|
||||
return {
|
||||
"runs": 0,
|
||||
"node_executions": 0,
|
||||
"offloads": 0,
|
||||
"app_logs": 0,
|
||||
"trigger_logs": 0,
|
||||
"pauses": 0,
|
||||
"pause_reasons": 0,
|
||||
}
|
||||
|
||||
@override
|
||||
def create_workflow_pause(
|
||||
self,
|
||||
|
||||
@ -432,6 +432,12 @@ class AgentRosterService:
|
||||
|
||||
return self._load_published_references_by_agent_id(tenant_id=tenant_id, agent_ids=[agent.id]).get(agent.id, [])
|
||||
|
||||
def load_published_references_by_agent_id(
|
||||
self, *, tenant_id: str, agent_ids: list[str]
|
||||
) -> dict[str, list[AgentReferencingWorkflow]]:
|
||||
"""Return published workflow references grouped by roster Agent id."""
|
||||
return self._load_published_references_by_agent_id(tenant_id=tenant_id, agent_ids=agent_ids)
|
||||
|
||||
def get_roster_agent_detail(self, *, tenant_id: str, agent_id: str) -> dict[str, Any]:
|
||||
agent = self._get_agent(tenant_id=tenant_id, agent_id=agent_id, roster_only=True)
|
||||
active_version = self._get_version(
|
||||
|
||||
@ -1,3 +1,10 @@
|
||||
"""Cleanup expired workflow run logs for free-plan tenants.
|
||||
|
||||
The cleanup service owns billing eligibility decisions while repositories own database-efficient batch selection and
|
||||
deletion. Free-plan cleanup intentionally scans lightweight workflow run references first, then re-queries the same
|
||||
candidate cursor slice with eligible tenant IDs so paid tenants are skipped without hydrating full WorkflowRun models.
|
||||
"""
|
||||
|
||||
import datetime
|
||||
import logging
|
||||
import random
|
||||
@ -11,8 +18,11 @@ from sqlalchemy.orm import Session, sessionmaker
|
||||
from configs import dify_config
|
||||
from enums.cloud_plan import CloudPlan
|
||||
from extensions.ext_database import db
|
||||
from models.workflow import WorkflowRun
|
||||
from repositories.api_workflow_run_repository import APIWorkflowRunRepository, RunsWithRelatedCountsDict
|
||||
from repositories.api_workflow_run_repository import (
|
||||
APIWorkflowRunRepository,
|
||||
RunsWithRelatedCountsDict,
|
||||
WorkflowRunCleanupRef,
|
||||
)
|
||||
from repositories.factory import DifyAPIRepositoryFactory
|
||||
from repositories.sqlalchemy_workflow_trigger_log_repository import SQLAlchemyWorkflowTriggerLogRepository
|
||||
from services.billing_service import BillingService, SubscriptionPlan
|
||||
@ -186,6 +196,13 @@ _RELATED_RECORD_KEYS = ("node_executions", "offloads", "app_logs", "trigger_logs
|
||||
|
||||
|
||||
class WorkflowRunCleanup:
|
||||
"""
|
||||
Coordinates free-plan workflow run retention cleanup.
|
||||
|
||||
The cleanup cursor advances by candidate refs, not target refs. This keeps pagination stable
|
||||
when billing filters out paid or unknown tenants before the repository performs the target lookup.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
days: int,
|
||||
@ -254,26 +271,28 @@ class WorkflowRunCleanup:
|
||||
batch_start = time.monotonic()
|
||||
|
||||
fetch_start = time.monotonic()
|
||||
run_rows = self.workflow_run_repo.get_runs_batch_by_time_range(
|
||||
candidate_last_seen = last_seen
|
||||
candidate_refs = self.workflow_run_repo.get_cleanup_refs_batch_by_time_range(
|
||||
start_from=self.window_start,
|
||||
end_before=self.window_end,
|
||||
last_seen=last_seen,
|
||||
last_seen=candidate_last_seen,
|
||||
batch_size=self.batch_size,
|
||||
)
|
||||
if not run_rows:
|
||||
if not candidate_refs:
|
||||
logger.info("workflow_run_cleanup (batch #%s): no more rows to process", batch_index + 1)
|
||||
break
|
||||
|
||||
batch_index += 1
|
||||
last_seen = (run_rows[-1].created_at, run_rows[-1].id)
|
||||
candidate_high_water = self._cursor_from_ref(candidate_refs[-1])
|
||||
last_seen = candidate_high_water
|
||||
logger.info(
|
||||
"workflow_run_cleanup (batch #%s): fetched %s rows in %sms",
|
||||
"workflow_run_cleanup (batch #%s): fetched %s candidate refs in %sms",
|
||||
batch_index,
|
||||
len(run_rows),
|
||||
len(candidate_refs),
|
||||
int((time.monotonic() - fetch_start) * 1000),
|
||||
)
|
||||
|
||||
tenant_ids = {row.tenant_id for row in run_rows}
|
||||
tenant_ids = {ref.tenant_id for ref in candidate_refs}
|
||||
|
||||
filter_start = time.monotonic()
|
||||
free_tenants = self._filter_free_tenants(tenant_ids)
|
||||
@ -285,10 +304,28 @@ class WorkflowRunCleanup:
|
||||
int((time.monotonic() - filter_start) * 1000),
|
||||
)
|
||||
|
||||
free_runs = [row for row in run_rows if row.tenant_id in free_tenants]
|
||||
paid_or_skipped = len(run_rows) - len(free_runs)
|
||||
target_refs: Sequence[WorkflowRunCleanupRef] = []
|
||||
if free_tenants:
|
||||
target_fetch_start = time.monotonic()
|
||||
target_refs = self.workflow_run_repo.get_cleanup_refs_batch_by_time_range(
|
||||
start_from=self.window_start,
|
||||
end_before=self.window_end,
|
||||
last_seen=candidate_last_seen,
|
||||
batch_size=self.batch_size,
|
||||
tenant_ids=sorted(free_tenants),
|
||||
upper_bound=candidate_high_water,
|
||||
)
|
||||
logger.info(
|
||||
"workflow_run_cleanup (batch #%s): fetched %s target refs in %sms",
|
||||
batch_index,
|
||||
len(target_refs),
|
||||
int((time.monotonic() - target_fetch_start) * 1000),
|
||||
)
|
||||
|
||||
if not free_runs:
|
||||
target_run_ids = [ref.id for ref in target_refs]
|
||||
paid_or_skipped = max(len(candidate_refs) - len(target_run_ids), 0)
|
||||
|
||||
if not target_run_ids:
|
||||
skipped_message = (
|
||||
f"[batch #{batch_index}] skipped (no sandbox runs in batch, {paid_or_skipped} paid/unknown)"
|
||||
)
|
||||
@ -299,7 +336,7 @@ class WorkflowRunCleanup:
|
||||
)
|
||||
)
|
||||
self._metrics.record_batch(
|
||||
batch_rows=len(run_rows),
|
||||
batch_rows=len(candidate_refs),
|
||||
targeted_runs=0,
|
||||
skipped_runs=paid_or_skipped,
|
||||
deleted_runs=0,
|
||||
@ -309,13 +346,13 @@ class WorkflowRunCleanup:
|
||||
)
|
||||
continue
|
||||
|
||||
total_runs_targeted += len(free_runs)
|
||||
total_runs_targeted += len(target_run_ids)
|
||||
|
||||
if self.dry_run:
|
||||
count_start = time.monotonic()
|
||||
batch_counts = self.workflow_run_repo.count_runs_with_related(
|
||||
free_runs,
|
||||
count_node_executions=self._count_node_executions,
|
||||
batch_counts = self.workflow_run_repo.count_runs_with_related_by_ids(
|
||||
target_run_ids,
|
||||
count_node_executions=self._count_node_executions_by_run_ids,
|
||||
count_trigger_logs=self._count_trigger_logs,
|
||||
)
|
||||
logger.info(
|
||||
@ -325,10 +362,10 @@ class WorkflowRunCleanup:
|
||||
)
|
||||
if related_totals is not None:
|
||||
self._accumulate_related_counts(related_totals, batch_counts)
|
||||
sample_ids = ", ".join(run.id for run in free_runs[:5])
|
||||
sample_ids = ", ".join(target_run_ids[:5])
|
||||
click.echo(
|
||||
click.style(
|
||||
f"[batch #{batch_index}] would delete {len(free_runs)} runs "
|
||||
f"[batch #{batch_index}] would delete {len(target_run_ids)} runs "
|
||||
f"(sample ids: {sample_ids}) and skip {paid_or_skipped} paid/unknown",
|
||||
fg="yellow",
|
||||
)
|
||||
@ -339,8 +376,8 @@ class WorkflowRunCleanup:
|
||||
int((time.monotonic() - batch_start) * 1000),
|
||||
)
|
||||
self._metrics.record_batch(
|
||||
batch_rows=len(run_rows),
|
||||
targeted_runs=len(free_runs),
|
||||
batch_rows=len(candidate_refs),
|
||||
targeted_runs=len(target_run_ids),
|
||||
skipped_runs=paid_or_skipped,
|
||||
deleted_runs=0,
|
||||
related_counts={
|
||||
@ -354,14 +391,14 @@ class WorkflowRunCleanup:
|
||||
|
||||
try:
|
||||
delete_start = time.monotonic()
|
||||
counts = self.workflow_run_repo.delete_runs_with_related(
|
||||
free_runs,
|
||||
delete_node_executions=self._delete_node_executions,
|
||||
counts = self.workflow_run_repo.delete_runs_with_related_by_ids(
|
||||
target_run_ids,
|
||||
delete_node_executions=self._delete_node_executions_by_run_ids,
|
||||
delete_trigger_logs=self._delete_trigger_logs,
|
||||
)
|
||||
delete_ms = int((time.monotonic() - delete_start) * 1000)
|
||||
except Exception:
|
||||
logger.exception("Failed to delete workflow runs batch ending at %s", last_seen[0])
|
||||
logger.exception("Failed to delete workflow runs batch ending at %s", candidate_high_water[0])
|
||||
raise
|
||||
|
||||
total_runs_deleted += counts["runs"]
|
||||
@ -382,8 +419,8 @@ class WorkflowRunCleanup:
|
||||
int((time.monotonic() - batch_start) * 1000),
|
||||
)
|
||||
self._metrics.record_batch(
|
||||
batch_rows=len(run_rows),
|
||||
targeted_runs=len(free_runs),
|
||||
batch_rows=len(candidate_refs),
|
||||
targeted_runs=len(target_run_ids),
|
||||
skipped_runs=paid_or_skipped,
|
||||
deleted_runs=counts["runs"],
|
||||
related_counts={
|
||||
@ -439,7 +476,7 @@ class WorkflowRunCleanup:
|
||||
)
|
||||
|
||||
def _filter_free_tenants(self, tenant_ids: Iterable[str]) -> set[str]:
|
||||
tenant_id_list = list(tenant_ids)
|
||||
tenant_id_list = sorted(set(tenant_ids))
|
||||
|
||||
if not dify_config.BILLING_ENABLED:
|
||||
return set(tenant_id_list)
|
||||
@ -553,15 +590,17 @@ class WorkflowRunCleanup:
|
||||
totals["pauses"] += batch.get("pauses", 0)
|
||||
totals["pause_reasons"] += batch.get("pause_reasons", 0)
|
||||
|
||||
def _count_node_executions(self, session: Session, runs: Sequence[WorkflowRun]) -> tuple[int, int]:
|
||||
run_ids = [run.id for run in runs]
|
||||
@staticmethod
|
||||
def _cursor_from_ref(ref: WorkflowRunCleanupRef) -> tuple[datetime.datetime, str]:
|
||||
return ref.created_at, ref.id
|
||||
|
||||
def _count_node_executions_by_run_ids(self, session: Session, run_ids: Sequence[str]) -> tuple[int, int]:
|
||||
repo = DifyAPIRepositoryFactory.create_api_workflow_node_execution_repository(
|
||||
session_maker=sessionmaker(bind=session.get_bind(), expire_on_commit=False)
|
||||
)
|
||||
return repo.count_by_runs(session, run_ids)
|
||||
|
||||
def _delete_node_executions(self, session: Session, runs: Sequence[WorkflowRun]) -> tuple[int, int]:
|
||||
run_ids = [run.id for run in runs]
|
||||
def _delete_node_executions_by_run_ids(self, session: Session, run_ids: Sequence[str]) -> tuple[int, int]:
|
||||
repo = DifyAPIRepositoryFactory.create_api_workflow_node_execution_repository(
|
||||
session_maker=sessionmaker(bind=session.get_bind(), expire_on_commit=False)
|
||||
)
|
||||
|
||||
@ -0,0 +1,320 @@
|
||||
"""Integration tests for workflow run cleanup repository queries."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timedelta
|
||||
from typing import override
|
||||
from uuid import uuid4
|
||||
|
||||
from sqlalchemy import Engine, select
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from graphon.entities import WorkflowExecution
|
||||
from graphon.entities.pause_reason import PauseReasonType
|
||||
from graphon.enums import WorkflowExecutionStatus, WorkflowType
|
||||
from models.enums import CreatorUserRole, WorkflowRunTriggeredFrom
|
||||
from models.workflow import WorkflowAppLog, WorkflowAppLogCreatedFrom, WorkflowPause, WorkflowPauseReason, WorkflowRun
|
||||
from repositories.sqlalchemy_api_workflow_run_repository import DifyAPISQLAlchemyWorkflowRunRepository
|
||||
|
||||
|
||||
class _TestWorkflowRunRepository(DifyAPISQLAlchemyWorkflowRunRepository):
|
||||
"""Concrete repository for tests where save() is not under test."""
|
||||
|
||||
@override
|
||||
def save(self, execution: WorkflowExecution) -> None:
|
||||
return None
|
||||
|
||||
|
||||
@dataclass
|
||||
class _TestScope:
|
||||
"""Per-test identifiers for rows created by cleanup repository tests."""
|
||||
|
||||
tenant_id: str = field(default_factory=lambda: str(uuid4()))
|
||||
app_id: str = field(default_factory=lambda: str(uuid4()))
|
||||
workflow_id: str = field(default_factory=lambda: str(uuid4()))
|
||||
user_id: str = field(default_factory=lambda: str(uuid4()))
|
||||
|
||||
|
||||
def _repository(db_session_with_containers: Session) -> DifyAPISQLAlchemyWorkflowRunRepository:
|
||||
engine = db_session_with_containers.get_bind()
|
||||
assert isinstance(engine, Engine)
|
||||
return _TestWorkflowRunRepository(session_maker=sessionmaker(bind=engine, expire_on_commit=False))
|
||||
|
||||
|
||||
def _create_workflow_run(
|
||||
session: Session,
|
||||
scope: _TestScope,
|
||||
*,
|
||||
status: WorkflowExecutionStatus = WorkflowExecutionStatus.SUCCEEDED,
|
||||
created_at: datetime,
|
||||
tenant_id: str | None = None,
|
||||
workflow_id: str | None = None,
|
||||
workflow_type: str = WorkflowType.WORKFLOW,
|
||||
) -> WorkflowRun:
|
||||
workflow_run = WorkflowRun(
|
||||
id=str(uuid4()),
|
||||
tenant_id=tenant_id or scope.tenant_id,
|
||||
app_id=scope.app_id,
|
||||
workflow_id=workflow_id or scope.workflow_id,
|
||||
type=workflow_type,
|
||||
triggered_from=WorkflowRunTriggeredFrom.DEBUGGING,
|
||||
version="draft",
|
||||
graph="{}",
|
||||
inputs="{}",
|
||||
status=status,
|
||||
created_by_role=CreatorUserRole.ACCOUNT,
|
||||
created_by=scope.user_id,
|
||||
created_at=created_at,
|
||||
)
|
||||
session.add(workflow_run)
|
||||
session.commit()
|
||||
return workflow_run
|
||||
|
||||
|
||||
def _add_app_log(session: Session, scope: _TestScope, workflow_run: WorkflowRun) -> None:
|
||||
session.add(
|
||||
WorkflowAppLog(
|
||||
tenant_id=workflow_run.tenant_id,
|
||||
app_id=scope.app_id,
|
||||
workflow_id=workflow_run.workflow_id,
|
||||
workflow_run_id=workflow_run.id,
|
||||
created_from=WorkflowAppLogCreatedFrom.SERVICE_API,
|
||||
created_by_role=CreatorUserRole.ACCOUNT,
|
||||
created_by=scope.user_id,
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
|
||||
|
||||
def _add_pause_with_reason(session: Session, workflow_run: WorkflowRun) -> WorkflowPause:
|
||||
pause = WorkflowPause(
|
||||
workflow_id=workflow_run.workflow_id,
|
||||
workflow_run_id=workflow_run.id,
|
||||
state_object_key=f"workflow-state-{uuid4()}.json",
|
||||
)
|
||||
pause_reason = WorkflowPauseReason(
|
||||
pause_id=pause.id,
|
||||
type_=PauseReasonType.SCHEDULED_PAUSE,
|
||||
message="scheduled pause",
|
||||
)
|
||||
session.add_all([pause, pause_reason])
|
||||
session.commit()
|
||||
return pause
|
||||
|
||||
|
||||
class TestGetCleanupRefsBatchByTimeRange:
|
||||
def test_applies_cursor_window_and_cleanup_filters(self, db_session_with_containers: Session) -> None:
|
||||
repository = _repository(db_session_with_containers)
|
||||
scope = _TestScope()
|
||||
base = datetime(2024, 1, 1, 12, 0, 0)
|
||||
|
||||
_create_workflow_run(db_session_with_containers, scope, created_at=base - timedelta(minutes=1))
|
||||
cursor_run = _create_workflow_run(db_session_with_containers, scope, created_at=base)
|
||||
first_target = _create_workflow_run(db_session_with_containers, scope, created_at=base + timedelta(minutes=1))
|
||||
second_target = _create_workflow_run(
|
||||
db_session_with_containers,
|
||||
scope,
|
||||
status=WorkflowExecutionStatus.FAILED,
|
||||
created_at=base + timedelta(minutes=2),
|
||||
)
|
||||
_create_workflow_run(
|
||||
db_session_with_containers,
|
||||
scope,
|
||||
status=WorkflowExecutionStatus.RUNNING,
|
||||
created_at=base + timedelta(minutes=1),
|
||||
)
|
||||
_create_workflow_run(
|
||||
db_session_with_containers,
|
||||
scope,
|
||||
created_at=base + timedelta(minutes=1),
|
||||
tenant_id=str(uuid4()),
|
||||
)
|
||||
_create_workflow_run(
|
||||
db_session_with_containers,
|
||||
scope,
|
||||
created_at=base + timedelta(minutes=1),
|
||||
workflow_id=str(uuid4()),
|
||||
)
|
||||
_create_workflow_run(
|
||||
db_session_with_containers,
|
||||
scope,
|
||||
created_at=base + timedelta(minutes=1),
|
||||
workflow_type=WorkflowType.CHAT,
|
||||
)
|
||||
_create_workflow_run(db_session_with_containers, scope, created_at=base + timedelta(minutes=3))
|
||||
|
||||
refs = repository.get_cleanup_refs_batch_by_time_range(
|
||||
start_from=base,
|
||||
end_before=base + timedelta(minutes=4),
|
||||
last_seen=(cursor_run.created_at, cursor_run.id),
|
||||
batch_size=10,
|
||||
run_types=[WorkflowType.WORKFLOW],
|
||||
tenant_ids=[scope.tenant_id],
|
||||
workflow_ids=[scope.workflow_id],
|
||||
upper_bound=(second_target.created_at, second_target.id),
|
||||
)
|
||||
|
||||
assert [(ref.id, ref.tenant_id, ref.created_at) for ref in refs] == [
|
||||
(first_target.id, scope.tenant_id, first_target.created_at),
|
||||
(second_target.id, scope.tenant_id, second_target.created_at),
|
||||
]
|
||||
|
||||
def test_returns_empty_when_run_type_filter_is_empty(self, db_session_with_containers: Session) -> None:
|
||||
repository = _repository(db_session_with_containers)
|
||||
|
||||
refs = repository.get_cleanup_refs_batch_by_time_range(
|
||||
start_from=None,
|
||||
end_before=datetime(2024, 1, 2),
|
||||
last_seen=None,
|
||||
batch_size=10,
|
||||
run_types=[],
|
||||
)
|
||||
|
||||
assert refs == []
|
||||
|
||||
|
||||
class TestCountRunsWithRelatedByIds:
|
||||
def test_counts_existing_runs_and_related_rows(self, db_session_with_containers: Session) -> None:
|
||||
repository = _repository(db_session_with_containers)
|
||||
scope = _TestScope()
|
||||
workflow_run = _create_workflow_run(
|
||||
db_session_with_containers,
|
||||
scope,
|
||||
created_at=datetime(2024, 1, 1, 12, 0, 0),
|
||||
)
|
||||
missing_run_id = str(uuid4())
|
||||
_add_app_log(db_session_with_containers, scope, workflow_run)
|
||||
_add_pause_with_reason(db_session_with_containers, workflow_run)
|
||||
counted_node_run_ids: list[str] = []
|
||||
counted_trigger_run_ids: list[str] = []
|
||||
|
||||
counts = repository.count_runs_with_related_by_ids(
|
||||
[workflow_run.id, missing_run_id],
|
||||
count_node_executions=lambda _session, run_ids: counted_node_run_ids.extend(run_ids) or (2, 1),
|
||||
count_trigger_logs=lambda _session, run_ids: counted_trigger_run_ids.extend(run_ids) or 3,
|
||||
)
|
||||
|
||||
assert counted_node_run_ids == [workflow_run.id, missing_run_id]
|
||||
assert counted_trigger_run_ids == [workflow_run.id, missing_run_id]
|
||||
assert counts == {
|
||||
"runs": 1,
|
||||
"node_executions": 2,
|
||||
"offloads": 1,
|
||||
"app_logs": 1,
|
||||
"trigger_logs": 3,
|
||||
"pauses": 1,
|
||||
"pause_reasons": 1,
|
||||
}
|
||||
|
||||
def test_defaults_optional_related_counts(self, db_session_with_containers: Session) -> None:
|
||||
repository = _repository(db_session_with_containers)
|
||||
scope = _TestScope()
|
||||
workflow_run = _create_workflow_run(
|
||||
db_session_with_containers,
|
||||
scope,
|
||||
created_at=datetime(2024, 1, 1, 12, 0, 0),
|
||||
)
|
||||
|
||||
counts = repository.count_runs_with_related_by_ids([workflow_run.id])
|
||||
|
||||
assert counts == {
|
||||
"runs": 1,
|
||||
"node_executions": 0,
|
||||
"offloads": 0,
|
||||
"app_logs": 0,
|
||||
"trigger_logs": 0,
|
||||
"pauses": 0,
|
||||
"pause_reasons": 0,
|
||||
}
|
||||
|
||||
|
||||
class TestDeleteRunsWithRelatedByIds:
|
||||
def test_deletes_runs_and_related_rows(self, db_session_with_containers: Session) -> None:
|
||||
repository = _repository(db_session_with_containers)
|
||||
scope = _TestScope()
|
||||
workflow_run = _create_workflow_run(
|
||||
db_session_with_containers,
|
||||
scope,
|
||||
created_at=datetime(2024, 1, 1, 12, 0, 0),
|
||||
)
|
||||
_add_app_log(db_session_with_containers, scope, workflow_run)
|
||||
pause = _add_pause_with_reason(db_session_with_containers, workflow_run)
|
||||
pause_id = pause.id
|
||||
deleted_node_run_ids: list[str] = []
|
||||
deleted_trigger_run_ids: list[str] = []
|
||||
|
||||
counts = repository.delete_runs_with_related_by_ids(
|
||||
[workflow_run.id],
|
||||
delete_node_executions=lambda _session, run_ids: deleted_node_run_ids.extend(run_ids) or (2, 1),
|
||||
delete_trigger_logs=lambda _session, run_ids: deleted_trigger_run_ids.extend(run_ids) or 3,
|
||||
)
|
||||
|
||||
assert deleted_node_run_ids == [workflow_run.id]
|
||||
assert deleted_trigger_run_ids == [workflow_run.id]
|
||||
assert counts == {
|
||||
"runs": 1,
|
||||
"node_executions": 2,
|
||||
"offloads": 1,
|
||||
"app_logs": 1,
|
||||
"trigger_logs": 3,
|
||||
"pauses": 1,
|
||||
"pause_reasons": 1,
|
||||
}
|
||||
verification_session = Session(bind=db_session_with_containers.get_bind())
|
||||
with verification_session:
|
||||
assert verification_session.get(WorkflowRun, workflow_run.id) is None
|
||||
assert verification_session.get(WorkflowPause, pause_id) is None
|
||||
assert (
|
||||
verification_session.scalar(
|
||||
select(WorkflowAppLog).where(WorkflowAppLog.workflow_run_id == workflow_run.id)
|
||||
)
|
||||
is None
|
||||
)
|
||||
assert (
|
||||
verification_session.scalar(select(WorkflowPauseReason).where(WorkflowPauseReason.pause_id == pause_id))
|
||||
is None
|
||||
)
|
||||
|
||||
def test_defaults_optional_related_counts(self, db_session_with_containers: Session) -> None:
|
||||
repository = _repository(db_session_with_containers)
|
||||
scope = _TestScope()
|
||||
workflow_run = _create_workflow_run(
|
||||
db_session_with_containers,
|
||||
scope,
|
||||
created_at=datetime(2024, 1, 1, 12, 0, 0),
|
||||
)
|
||||
|
||||
counts = repository.delete_runs_with_related_by_ids([workflow_run.id])
|
||||
|
||||
assert counts == {
|
||||
"runs": 1,
|
||||
"node_executions": 0,
|
||||
"offloads": 0,
|
||||
"app_logs": 0,
|
||||
"trigger_logs": 0,
|
||||
"pauses": 0,
|
||||
"pause_reasons": 0,
|
||||
}
|
||||
|
||||
def test_empty_ids_return_empty_counts(self, db_session_with_containers: Session) -> None:
|
||||
repository = _repository(db_session_with_containers)
|
||||
|
||||
assert repository.count_runs_with_related_by_ids([]) == {
|
||||
"runs": 0,
|
||||
"node_executions": 0,
|
||||
"offloads": 0,
|
||||
"app_logs": 0,
|
||||
"trigger_logs": 0,
|
||||
"pauses": 0,
|
||||
"pause_reasons": 0,
|
||||
}
|
||||
assert repository.delete_runs_with_related_by_ids([]) == {
|
||||
"runs": 0,
|
||||
"node_executions": 0,
|
||||
"offloads": 0,
|
||||
"app_logs": 0,
|
||||
"trigger_logs": 0,
|
||||
"pauses": 0,
|
||||
"pause_reasons": 0,
|
||||
}
|
||||
@ -1,7 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from unittest.mock import patch
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
@ -259,9 +258,8 @@ class TestEndUserServiceGetOrCreateEndUserByType:
|
||||
|
||||
assert len(matching_logs) == 1
|
||||
|
||||
@patch("services.end_user_service.logger")
|
||||
def test_get_existing_end_user_matching_type(
|
||||
self, mock_logger, db_session_with_containers: Session, factory: TestEndUserServiceFactory
|
||||
self, db_session_with_containers: Session, factory: TestEndUserServiceFactory, caplog
|
||||
):
|
||||
"""Test retrieving existing end user with matching type."""
|
||||
# Arrange
|
||||
@ -279,17 +277,19 @@ class TestEndUserServiceGetOrCreateEndUserByType:
|
||||
)
|
||||
|
||||
# Act - Request with same type
|
||||
result = EndUserService.get_or_create_end_user_by_type(
|
||||
type=InvokeFrom.SERVICE_API,
|
||||
tenant_id=tenant_id,
|
||||
app_id=app_id,
|
||||
user_id=user_id,
|
||||
)
|
||||
with caplog.at_level(logging.INFO, logger="services.end_user_service"):
|
||||
result = EndUserService.get_or_create_end_user_by_type(
|
||||
type=InvokeFrom.SERVICE_API,
|
||||
tenant_id=tenant_id,
|
||||
app_id=app_id,
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert result.id == existing_user.id
|
||||
assert result.type == InvokeFrom.SERVICE_API
|
||||
mock_logger.info.assert_not_called()
|
||||
# No legacy-upgrade log should be emitted when the existing user's type already matches.
|
||||
assert [record for record in caplog.records if record.levelno == logging.INFO] == []
|
||||
|
||||
def test_create_anonymous_user_with_default_session(
|
||||
self, db_session_with_containers: Session, factory: TestEndUserServiceFactory
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import logging
|
||||
import uuid
|
||||
from unittest.mock import ANY, call, patch
|
||||
from unittest.mock import call, patch
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import delete, func, select
|
||||
@ -146,10 +147,7 @@ class TestDeleteDraftVariablesBatch:
|
||||
assert db_session_with_containers.scalar(select(func.count()).select_from(WorkflowDraftVariable)) == 0
|
||||
|
||||
@patch("tasks.remove_app_and_related_data_task._delete_draft_variable_offload_data")
|
||||
@patch("tasks.remove_app_and_related_data_task.logger")
|
||||
def test_delete_draft_variables_batch_logs_progress(
|
||||
self, mock_logger, mock_offload_cleanup, db_session_with_containers
|
||||
):
|
||||
def test_delete_draft_variables_batch_logs_progress(self, mock_offload_cleanup, db_session_with_containers, caplog):
|
||||
"""Test that batch deletion logs progress correctly."""
|
||||
tenant, app = _create_tenant_and_app(db_session_with_containers)
|
||||
offload_data = _create_offload_data(db_session_with_containers, tenant_id=tenant.id, app_id=app.id, count=10)
|
||||
@ -163,14 +161,15 @@ class TestDeleteDraftVariablesBatch:
|
||||
|
||||
mock_offload_cleanup.return_value = len(file_id_by_index)
|
||||
|
||||
result = delete_draft_variables_batch(app.id, 50)
|
||||
with caplog.at_level(logging.INFO, logger="tasks.remove_app_and_related_data_task"):
|
||||
result = delete_draft_variables_batch(app.id, 50)
|
||||
|
||||
assert result == 30
|
||||
mock_offload_cleanup.assert_called_once()
|
||||
_, called_file_ids = mock_offload_cleanup.call_args.args
|
||||
assert {str(file_id) for file_id in called_file_ids} == {str(file_id) for file_id in file_id_by_index.values()}
|
||||
assert mock_logger.info.call_count == 2
|
||||
mock_logger.info.assert_any_call(ANY)
|
||||
info_records = [record for record in caplog.records if record.levelno == logging.INFO]
|
||||
assert len(info_records) == 2
|
||||
|
||||
|
||||
class TestDeleteDraftVariableOffloadData:
|
||||
@ -204,10 +203,7 @@ class TestDeleteDraftVariableOffloadData:
|
||||
assert remaining_upload_files_count == 0
|
||||
|
||||
@patch("extensions.ext_storage.storage")
|
||||
@patch("tasks.remove_app_and_related_data_task.logging")
|
||||
def test_delete_draft_variable_offload_data_storage_failure(
|
||||
self, mock_logging, mock_storage, db_session_with_containers
|
||||
):
|
||||
def test_delete_draft_variable_offload_data_storage_failure(self, mock_storage, db_session_with_containers, caplog):
|
||||
"""Test handling of storage deletion failures."""
|
||||
tenant, app = _create_tenant_and_app(db_session_with_containers)
|
||||
offload_data = _create_offload_data(db_session_with_containers, tenant_id=tenant.id, app_id=app.id, count=2)
|
||||
@ -217,11 +213,12 @@ class TestDeleteDraftVariableOffloadData:
|
||||
|
||||
mock_storage.delete.side_effect = [Exception("Storage error"), None]
|
||||
|
||||
with session_factory.create_session() as session, session.begin():
|
||||
result = _delete_draft_variable_offload_data(session, file_ids)
|
||||
with caplog.at_level(logging.ERROR):
|
||||
with session_factory.create_session() as session, session.begin():
|
||||
result = _delete_draft_variable_offload_data(session, file_ids)
|
||||
|
||||
assert result == 1
|
||||
mock_logging.exception.assert_called_once_with("Failed to delete storage object %s", storage_keys[0])
|
||||
assert f"Failed to delete storage object {storage_keys[0]}" in caplog.text
|
||||
|
||||
remaining_var_files_count = db_session_with_containers.scalar(
|
||||
select(func.count())
|
||||
|
||||
@ -210,6 +210,26 @@ def test_agent_app_list_and_create_use_agent_route(
|
||||
id="agent-created", role="Created role", active_config_snapshot_id=None
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
roster_controller.AgentRosterService,
|
||||
"load_published_references_by_agent_id",
|
||||
lambda _self, **kwargs: {
|
||||
"agent-list": [
|
||||
{
|
||||
"app_id": "workflow-app-id",
|
||||
"app_name": "RFP Review Flow",
|
||||
"app_icon_type": "emoji",
|
||||
"app_icon": "A",
|
||||
"app_icon_background": "#fff",
|
||||
"app_mode": "workflow",
|
||||
"app_updated_at": 1781660000,
|
||||
"workflow_id": "workflow-1",
|
||||
"workflow_version": "v1",
|
||||
"node_ids": ["node-1", "node-2"],
|
||||
}
|
||||
]
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
roster_controller.FeatureService,
|
||||
"get_system_features",
|
||||
@ -226,6 +246,10 @@ def test_agent_app_list_and_create_use_agent_route(
|
||||
assert listed["data"][0]["app_id"] == "app-list"
|
||||
assert listed["data"][0]["role"] == "List role"
|
||||
assert listed["data"][0]["active_config_is_published"] is False
|
||||
assert listed["data"][0]["published_reference_count"] == 1
|
||||
assert listed["data"][0]["published_node_reference_count"] == 2
|
||||
assert listed["data"][0]["published_references"][0]["app_id"] == "workflow-app-id"
|
||||
assert listed["data"][0]["published_references"][0]["node_ids"] == ["node-1", "node-2"]
|
||||
assert "bound_agent_id" not in listed["data"][0]
|
||||
list_call = cast(dict[str, object], captured["list"])
|
||||
list_params = cast(Any, list_call["params"])
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
Unit tests for Service API File Preview endpoint
|
||||
"""
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
@ -348,8 +349,7 @@ class TestFilePreviewApi:
|
||||
|
||||
assert "Storage error" in str(exc_info.value)
|
||||
|
||||
@patch("controllers.service_api.app.file_preview.logger")
|
||||
def test_validate_file_ownership_unexpected_error_logging(self, mock_logger, file_preview_api: FilePreviewApi):
|
||||
def test_validate_file_ownership_unexpected_error_logging(self, file_preview_api: FilePreviewApi, caplog):
|
||||
"""Test that unexpected errors are logged properly"""
|
||||
file_id = str(uuid.uuid4())
|
||||
app_id = str(uuid.uuid4())
|
||||
@ -359,14 +359,18 @@ class TestFilePreviewApi:
|
||||
mock_db.session.scalar.side_effect = Exception("Unexpected database error")
|
||||
|
||||
# Execute and assert exception
|
||||
with pytest.raises(FileAccessDeniedError) as exc_info:
|
||||
file_preview_api._validate_file_ownership(file_id, app_id)
|
||||
with caplog.at_level(logging.ERROR, logger="controllers.service_api.app.file_preview"):
|
||||
with pytest.raises(FileAccessDeniedError) as exc_info:
|
||||
file_preview_api._validate_file_ownership(file_id, app_id)
|
||||
|
||||
# Verify error message
|
||||
assert "File access validation failed" in str(exc_info.value)
|
||||
|
||||
# Verify logging was called
|
||||
mock_logger.exception.assert_called_once_with(
|
||||
"Unexpected error during file ownership validation",
|
||||
extra={"file_id": file_id, "app_id": app_id, "error": "Unexpected database error"},
|
||||
)
|
||||
# Verify logging was called with the structured context fields. The ``extra`` keys
|
||||
# are attached to the LogRecord as attributes, so they are not in ``caplog.text``.
|
||||
assert len(caplog.records) == 1
|
||||
record = caplog.records[0]
|
||||
assert record.getMessage() == "Unexpected error during file ownership validation"
|
||||
assert record.file_id == file_id
|
||||
assert record.app_id == app_id
|
||||
assert record.error == "Unexpected database error"
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import logging
|
||||
import queue
|
||||
import time
|
||||
from concurrent.futures import Future, ThreadPoolExecutor
|
||||
@ -511,10 +512,8 @@ def test_receive_loop_http_error_unknown_id(streams):
|
||||
|
||||
|
||||
@pytest.mark.timeout(10)
|
||||
def test_receive_loop_validation_error_notification(streams):
|
||||
from core.mcp.session.base_session import logger
|
||||
|
||||
with patch.object(logger, "warning") as mock_warning:
|
||||
def test_receive_loop_validation_error_notification(streams, caplog):
|
||||
with caplog.at_level(logging.WARNING, logger="core.mcp.session.base_session"):
|
||||
read_stream, write_stream = streams
|
||||
session = MockSession(read_stream, write_stream, ReceiveRequest, RootModel[MockNotification])
|
||||
|
||||
@ -523,7 +522,7 @@ def test_receive_loop_validation_error_notification(streams):
|
||||
read_stream.put(SessionMessage(message=JSONRPCMessage.model_validate(notif_payload)))
|
||||
time.sleep(1.0)
|
||||
|
||||
assert mock_warning.called
|
||||
assert "Failed to validate notification" in caplog.text
|
||||
|
||||
|
||||
@pytest.mark.timeout(5)
|
||||
@ -571,16 +570,16 @@ def test_session_exit_timeout(streams):
|
||||
|
||||
|
||||
@pytest.mark.timeout(10)
|
||||
def test_receive_loop_fatal_exception(streams):
|
||||
def test_receive_loop_fatal_exception(streams, caplog):
|
||||
read_stream, write_stream = streams
|
||||
session = MockSession(read_stream, write_stream, ReceiveRequest, ReceiveNotification)
|
||||
|
||||
with patch.object(read_stream, "get", side_effect=RuntimeError("Fatal loop error")):
|
||||
with patch("core.mcp.session.base_session.logger") as mock_logger:
|
||||
with caplog.at_level(logging.ERROR, logger="core.mcp.session.base_session"):
|
||||
with pytest.raises(RuntimeError, match="Fatal loop error"):
|
||||
with session:
|
||||
pass
|
||||
mock_logger.exception.assert_called_with("Error in message processing loop")
|
||||
assert "Error in message processing loop" in caplog.text
|
||||
|
||||
|
||||
@pytest.mark.timeout(5)
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import UTC, datetime
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
@ -88,11 +89,10 @@ def test_api_key_and_custom_headers_merge(mock_metric_exporter: MagicMock, mock_
|
||||
assert ("x-custom", "foo") in headers
|
||||
|
||||
|
||||
@patch("enterprise.telemetry.exporter.logger")
|
||||
@patch("enterprise.telemetry.exporter.GRPCSpanExporter")
|
||||
@patch("enterprise.telemetry.exporter.GRPCMetricExporter")
|
||||
def test_api_key_overrides_conflicting_header(
|
||||
mock_metric_exporter: MagicMock, mock_span_exporter: MagicMock, mock_logger: MagicMock
|
||||
mock_metric_exporter: MagicMock, mock_span_exporter: MagicMock, caplog
|
||||
) -> None:
|
||||
"""Test that API key overrides conflicting authorization header and logs warning."""
|
||||
mock_config = SimpleNamespace(
|
||||
@ -105,7 +105,8 @@ def test_api_key_overrides_conflicting_header(
|
||||
ENTERPRISE_OTLP_API_KEY="test-key",
|
||||
)
|
||||
|
||||
EnterpriseExporter(mock_config)
|
||||
with caplog.at_level(logging.WARNING, logger="enterprise.telemetry.exporter"):
|
||||
EnterpriseExporter(mock_config)
|
||||
|
||||
# Verify Bearer header takes precedence
|
||||
assert mock_span_exporter.call_args is not None
|
||||
@ -116,11 +117,8 @@ def test_api_key_overrides_conflicting_header(
|
||||
assert ("authorization", "Basic old") not in headers
|
||||
|
||||
# Verify warning was logged
|
||||
mock_logger.warning.assert_called_once()
|
||||
assert mock_logger.warning.call_args is not None
|
||||
warning_message = mock_logger.warning.call_args[0][0]
|
||||
assert "ENTERPRISE_OTLP_API_KEY is set" in warning_message
|
||||
assert "authorization" in warning_message
|
||||
assert "ENTERPRISE_OTLP_API_KEY is set" in caplog.text
|
||||
assert "authorization" in caplog.text
|
||||
|
||||
|
||||
@patch("enterprise.telemetry.exporter.GRPCSpanExporter")
|
||||
@ -535,33 +533,33 @@ def test_export_span_cross_workflow_parent_context() -> None:
|
||||
assert kwargs["context"] is not None
|
||||
|
||||
|
||||
@patch("enterprise.telemetry.exporter.logger")
|
||||
def test_export_span_logs_exception_on_error(mock_logger: MagicMock) -> None:
|
||||
def test_export_span_logs_exception_on_error(caplog) -> None:
|
||||
"""If the span block raises, the exception is logged and context is still cleared."""
|
||||
exporter, mock_tracer, mock_span = _make_exporter_with_mock_tracer()
|
||||
|
||||
mock_tracer.start_as_current_span.side_effect = RuntimeError("boom")
|
||||
|
||||
exporter.export_span(name="bad.span", attributes={}) # must not raise
|
||||
with caplog.at_level(logging.ERROR, logger="enterprise.telemetry.exporter"):
|
||||
exporter.export_span(name="bad.span", attributes={}) # must not raise
|
||||
|
||||
mock_logger.exception.assert_called_once()
|
||||
assert "bad.span" in mock_logger.exception.call_args[0][1]
|
||||
assert "Failed to export span" in caplog.text
|
||||
assert "bad.span" in caplog.text
|
||||
|
||||
|
||||
@patch("enterprise.telemetry.exporter.logger")
|
||||
def test_export_span_invalid_trace_correlation_logs_warning(mock_logger: MagicMock) -> None:
|
||||
def test_export_span_invalid_trace_correlation_logs_warning(caplog) -> None:
|
||||
"""Invalid UUID for trace_correlation_override triggers a warning log."""
|
||||
exporter, mock_tracer, mock_span = _make_exporter_with_mock_tracer()
|
||||
|
||||
parent_uid = "987fbc97-4bed-5078-9f07-9141ba07c9f3"
|
||||
exporter.export_span(
|
||||
name="link.span",
|
||||
attributes={},
|
||||
correlation_id="not-a-valid-uuid",
|
||||
parent_span_id_source=parent_uid,
|
||||
)
|
||||
with caplog.at_level(logging.WARNING, logger="enterprise.telemetry.exporter"):
|
||||
exporter.export_span(
|
||||
name="link.span",
|
||||
attributes={},
|
||||
correlation_id="not-a-valid-uuid",
|
||||
parent_span_id_source=parent_uid,
|
||||
)
|
||||
|
||||
mock_logger.warning.assert_called()
|
||||
assert "Invalid trace correlation UUID for cross-workflow link" in caplog.text
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@ -7,15 +7,16 @@ from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from repositories.api_workflow_run_repository import WorkflowRunCleanupRef
|
||||
from services.retention.workflow_run.clear_free_plan_expired_workflow_run_logs import WorkflowRunCleanup
|
||||
|
||||
|
||||
def make_run(tenant_id: str = "t1", run_id: str = "r1", created_at: datetime.datetime | None = None):
|
||||
run = MagicMock()
|
||||
run.tenant_id = tenant_id
|
||||
run.id = run_id
|
||||
run.created_at = created_at or datetime.datetime(2024, 1, 1, tzinfo=datetime.UTC)
|
||||
return run
|
||||
def make_ref(tenant_id: str = "t1", run_id: str = "r1", created_at: datetime.datetime | None = None):
|
||||
return WorkflowRunCleanupRef(
|
||||
id=run_id,
|
||||
tenant_id=tenant_id,
|
||||
created_at=created_at or datetime.datetime(2024, 1, 1, tzinfo=datetime.UTC),
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@ -341,28 +342,28 @@ class TestRunDeleteMode:
|
||||
return WorkflowRunCleanup(days=30, batch_size=10, workflow_run_repo=mock_repo)
|
||||
|
||||
def test_no_rows_stops_immediately(self, mock_repo):
|
||||
mock_repo.get_runs_batch_by_time_range.return_value = []
|
||||
mock_repo.get_cleanup_refs_batch_by_time_range.return_value = []
|
||||
c = self._make_cleanup(mock_repo)
|
||||
with patch("services.retention.workflow_run.clear_free_plan_expired_workflow_run_logs.dify_config") as cfg:
|
||||
cfg.BILLING_ENABLED = False
|
||||
c.run()
|
||||
mock_repo.delete_runs_with_related.assert_not_called()
|
||||
mock_repo.delete_runs_with_related_by_ids.assert_not_called()
|
||||
|
||||
def test_all_paid_skips_delete(self, mock_repo):
|
||||
run = make_run("t1")
|
||||
mock_repo.get_runs_batch_by_time_range.side_effect = [[run], []]
|
||||
ref = make_ref("t1")
|
||||
mock_repo.get_cleanup_refs_batch_by_time_range.side_effect = [[ref], []]
|
||||
c = self._make_cleanup(mock_repo)
|
||||
# billing disabled -> all free; but let's override _filter_free_tenants to return empty
|
||||
c._filter_free_tenants = MagicMock(return_value=set())
|
||||
with patch("services.retention.workflow_run.clear_free_plan_expired_workflow_run_logs.dify_config") as cfg:
|
||||
cfg.BILLING_ENABLED = False
|
||||
c.run()
|
||||
mock_repo.delete_runs_with_related.assert_not_called()
|
||||
mock_repo.delete_runs_with_related_by_ids.assert_not_called()
|
||||
|
||||
def test_runs_deleted_successfully(self, mock_repo):
|
||||
run = make_run("t1")
|
||||
mock_repo.get_runs_batch_by_time_range.side_effect = [[run], []]
|
||||
mock_repo.delete_runs_with_related.return_value = {
|
||||
ref = make_ref("t1")
|
||||
mock_repo.get_cleanup_refs_batch_by_time_range.side_effect = [[ref], [ref], []]
|
||||
mock_repo.delete_runs_with_related_by_ids.return_value = {
|
||||
"runs": 1,
|
||||
"node_executions": 0,
|
||||
"offloads": 0,
|
||||
@ -376,12 +377,12 @@ class TestRunDeleteMode:
|
||||
cfg.BILLING_ENABLED = False
|
||||
with patch("services.retention.workflow_run.clear_free_plan_expired_workflow_run_logs.time.sleep"):
|
||||
c.run()
|
||||
mock_repo.delete_runs_with_related.assert_called_once()
|
||||
mock_repo.delete_runs_with_related_by_ids.assert_called_once()
|
||||
|
||||
def test_delete_exception_reraises(self, mock_repo):
|
||||
run = make_run("t1")
|
||||
mock_repo.get_runs_batch_by_time_range.side_effect = [[run], []]
|
||||
mock_repo.delete_runs_with_related.side_effect = RuntimeError("db error")
|
||||
ref = make_ref("t1")
|
||||
mock_repo.get_cleanup_refs_batch_by_time_range.side_effect = [[ref], [ref]]
|
||||
mock_repo.delete_runs_with_related_by_ids.side_effect = RuntimeError("db error")
|
||||
c = self._make_cleanup(mock_repo)
|
||||
with patch("services.retention.workflow_run.clear_free_plan_expired_workflow_run_logs.dify_config") as cfg:
|
||||
cfg.BILLING_ENABLED = False
|
||||
@ -389,7 +390,7 @@ class TestRunDeleteMode:
|
||||
c.run()
|
||||
|
||||
def test_summary_with_window_start(self, mock_repo):
|
||||
mock_repo.get_runs_batch_by_time_range.return_value = []
|
||||
mock_repo.get_cleanup_refs_batch_by_time_range.return_value = []
|
||||
with patch("services.retention.workflow_run.clear_free_plan_expired_workflow_run_logs.dify_config") as cfg:
|
||||
cfg.SANDBOX_EXPIRED_RECORDS_CLEAN_GRACEFUL_PERIOD = 0
|
||||
cfg.BILLING_ENABLED = False
|
||||
@ -421,9 +422,10 @@ class TestRunDryRunMode:
|
||||
)
|
||||
|
||||
def test_dry_run_no_delete_called(self, mock_repo):
|
||||
run = make_run("t1")
|
||||
mock_repo.get_runs_batch_by_time_range.side_effect = [[run], []]
|
||||
mock_repo.count_runs_with_related.return_value = {
|
||||
ref = make_ref("t1")
|
||||
mock_repo.get_cleanup_refs_batch_by_time_range.side_effect = [[ref], [ref], []]
|
||||
mock_repo.count_runs_with_related_by_ids.return_value = {
|
||||
"runs": 1,
|
||||
"node_executions": 2,
|
||||
"offloads": 0,
|
||||
"app_logs": 0,
|
||||
@ -435,11 +437,11 @@ class TestRunDryRunMode:
|
||||
with patch("services.retention.workflow_run.clear_free_plan_expired_workflow_run_logs.dify_config") as cfg:
|
||||
cfg.BILLING_ENABLED = False
|
||||
c.run()
|
||||
mock_repo.delete_runs_with_related.assert_not_called()
|
||||
mock_repo.count_runs_with_related.assert_called_once()
|
||||
mock_repo.delete_runs_with_related_by_ids.assert_not_called()
|
||||
mock_repo.count_runs_with_related_by_ids.assert_called_once()
|
||||
|
||||
def test_dry_run_summary_with_window_start(self, mock_repo):
|
||||
mock_repo.get_runs_batch_by_time_range.return_value = []
|
||||
mock_repo.get_cleanup_refs_batch_by_time_range.return_value = []
|
||||
with patch("services.retention.workflow_run.clear_free_plan_expired_workflow_run_logs.dify_config") as cfg:
|
||||
cfg.SANDBOX_EXPIRED_RECORDS_CLEAN_GRACEFUL_PERIOD = 0
|
||||
cfg.BILLING_ENABLED = False
|
||||
@ -454,14 +456,14 @@ class TestRunDryRunMode:
|
||||
c.run()
|
||||
|
||||
def test_dry_run_all_paid_skips_count(self, mock_repo):
|
||||
run = make_run("t1")
|
||||
mock_repo.get_runs_batch_by_time_range.side_effect = [[run], []]
|
||||
ref = make_ref("t1")
|
||||
mock_repo.get_cleanup_refs_batch_by_time_range.side_effect = [[ref], []]
|
||||
c = self._make_dry_cleanup(mock_repo)
|
||||
c._filter_free_tenants = MagicMock(return_value=set())
|
||||
with patch("services.retention.workflow_run.clear_free_plan_expired_workflow_run_logs.dify_config") as cfg:
|
||||
cfg.BILLING_ENABLED = False
|
||||
c.run()
|
||||
mock_repo.count_runs_with_related.assert_not_called()
|
||||
mock_repo.count_runs_with_related_by_ids.assert_not_called()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@ -492,7 +494,7 @@ class TestTriggerLogMethods:
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _count_node_executions / _delete_node_executions
|
||||
# _count_node_executions_by_run_ids / _delete_node_executions_by_run_ids
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@ -500,25 +502,23 @@ class TestNodeExecutionMethods:
|
||||
def test_count_node_executions(self, cleanup):
|
||||
session = MagicMock()
|
||||
session.get_bind.return_value = MagicMock()
|
||||
runs = [make_run("t1", "r1")]
|
||||
with patch(
|
||||
"services.retention.workflow_run.clear_free_plan_expired_workflow_run_logs.DifyAPIRepositoryFactory"
|
||||
) as factory:
|
||||
repo = factory.create_api_workflow_node_execution_repository.return_value
|
||||
repo.count_by_runs.return_value = (10, 2)
|
||||
with patch("services.retention.workflow_run.clear_free_plan_expired_workflow_run_logs.sessionmaker"):
|
||||
result = cleanup._count_node_executions(session, runs)
|
||||
result = cleanup._count_node_executions_by_run_ids(session, ["r1"])
|
||||
assert result == (10, 2)
|
||||
|
||||
def test_delete_node_executions(self, cleanup):
|
||||
session = MagicMock()
|
||||
session.get_bind.return_value = MagicMock()
|
||||
runs = [make_run("t1", "r1")]
|
||||
with patch(
|
||||
"services.retention.workflow_run.clear_free_plan_expired_workflow_run_logs.DifyAPIRepositoryFactory"
|
||||
) as factory:
|
||||
repo = factory.create_api_workflow_node_execution_repository.return_value
|
||||
repo.delete_by_runs.return_value = (5, 1)
|
||||
with patch("services.retention.workflow_run.clear_free_plan_expired_workflow_run_logs.sessionmaker"):
|
||||
result = cleanup._delete_node_executions(session, runs)
|
||||
result = cleanup._delete_node_executions_by_run_ids(session, ["r1"])
|
||||
assert result == (5, 1)
|
||||
|
||||
@ -3,38 +3,27 @@ from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from repositories.api_workflow_run_repository import WorkflowRunCleanupRef
|
||||
from services.billing_service import SubscriptionPlan
|
||||
from services.retention.workflow_run import clear_free_plan_expired_workflow_run_logs as cleanup_module
|
||||
from services.retention.workflow_run.clear_free_plan_expired_workflow_run_logs import WorkflowRunCleanup
|
||||
|
||||
|
||||
class FakeRun:
|
||||
def __init__(
|
||||
self,
|
||||
run_id: str,
|
||||
tenant_id: str,
|
||||
created_at: datetime.datetime,
|
||||
app_id: str = "app-1",
|
||||
workflow_id: str = "wf-1",
|
||||
triggered_from: str = "workflow-run",
|
||||
) -> None:
|
||||
self.id = run_id
|
||||
self.tenant_id = tenant_id
|
||||
self.app_id = app_id
|
||||
self.workflow_id = workflow_id
|
||||
self.triggered_from = triggered_from
|
||||
self.created_at = created_at
|
||||
def make_ref(run_id: str, tenant_id: str, created_at: datetime.datetime) -> WorkflowRunCleanupRef:
|
||||
return WorkflowRunCleanupRef(id=run_id, tenant_id=tenant_id, created_at=created_at)
|
||||
|
||||
|
||||
class FakeRepo:
|
||||
def __init__(
|
||||
self,
|
||||
batches: list[list[FakeRun]],
|
||||
batches: list[list[WorkflowRunCleanupRef]],
|
||||
delete_result: dict[str, int] | None = None,
|
||||
count_result: dict[str, int] | None = None,
|
||||
) -> None:
|
||||
self.batches = batches
|
||||
self.call_idx = 0
|
||||
self.candidate_call_idx = 0
|
||||
self.last_candidate_batch: list[WorkflowRunCleanupRef] = []
|
||||
self.cleanup_ref_calls: list[dict[str, object]] = []
|
||||
self.deleted: list[list[str]] = []
|
||||
self.counted: list[list[str]] = []
|
||||
self.delete_result = delete_result or {
|
||||
@ -56,7 +45,7 @@ class FakeRepo:
|
||||
"pause_reasons": 0,
|
||||
}
|
||||
|
||||
def get_runs_batch_by_time_range(
|
||||
def get_cleanup_refs_batch_by_time_range(
|
||||
self,
|
||||
start_from: datetime.datetime | None,
|
||||
end_before: datetime.datetime,
|
||||
@ -65,27 +54,50 @@ class FakeRepo:
|
||||
run_types=None,
|
||||
tenant_ids=None,
|
||||
workflow_ids=None,
|
||||
) -> list[FakeRun]:
|
||||
if self.call_idx >= len(self.batches):
|
||||
upper_bound: tuple[datetime.datetime, str] | None = None,
|
||||
) -> list[WorkflowRunCleanupRef]:
|
||||
self.cleanup_ref_calls.append(
|
||||
{
|
||||
"start_from": start_from,
|
||||
"end_before": end_before,
|
||||
"last_seen": last_seen,
|
||||
"batch_size": batch_size,
|
||||
"run_types": run_types,
|
||||
"tenant_ids": tenant_ids,
|
||||
"workflow_ids": workflow_ids,
|
||||
"upper_bound": upper_bound,
|
||||
}
|
||||
)
|
||||
if tenant_ids is not None or upper_bound is not None:
|
||||
refs = self.last_candidate_batch
|
||||
if tenant_ids is not None:
|
||||
tenant_id_set = set(tenant_ids)
|
||||
refs = [ref for ref in refs if ref.tenant_id in tenant_id_set]
|
||||
if upper_bound is not None:
|
||||
refs = [ref for ref in refs if (ref.created_at, ref.id) <= upper_bound]
|
||||
return refs[:batch_size]
|
||||
|
||||
if self.candidate_call_idx >= len(self.batches):
|
||||
return []
|
||||
batch = self.batches[self.call_idx]
|
||||
self.call_idx += 1
|
||||
batch = self.batches[self.candidate_call_idx]
|
||||
self.candidate_call_idx += 1
|
||||
self.last_candidate_batch = batch
|
||||
return batch
|
||||
|
||||
def delete_runs_with_related(
|
||||
self, runs: list[FakeRun], delete_node_executions=None, delete_trigger_logs=None
|
||||
def delete_runs_with_related_by_ids(
|
||||
self, run_ids: list[str], delete_node_executions=None, delete_trigger_logs=None
|
||||
) -> dict[str, int]:
|
||||
self.deleted.append([run.id for run in runs])
|
||||
self.deleted.append(list(run_ids))
|
||||
result = self.delete_result.copy()
|
||||
result["runs"] = len(runs)
|
||||
result["runs"] = len(run_ids)
|
||||
return result
|
||||
|
||||
def count_runs_with_related(
|
||||
self, runs: list[FakeRun], count_node_executions=None, count_trigger_logs=None
|
||||
def count_runs_with_related_by_ids(
|
||||
self, run_ids: list[str], count_node_executions=None, count_trigger_logs=None
|
||||
) -> dict[str, int]:
|
||||
self.counted.append([run.id for run in runs])
|
||||
self.counted.append(list(run_ids))
|
||||
result = self.count_result.copy()
|
||||
result["runs"] = len(runs)
|
||||
result["runs"] = len(run_ids)
|
||||
return result
|
||||
|
||||
|
||||
@ -218,8 +230,8 @@ def test_run_deletes_only_free_tenants(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
repo = FakeRepo(
|
||||
batches=[
|
||||
[
|
||||
FakeRun("run-free", "t_free", cutoff),
|
||||
FakeRun("run-paid", "t_paid", cutoff),
|
||||
make_ref("run-free", "t_free", cutoff),
|
||||
make_ref("run-paid", "t_paid", cutoff),
|
||||
]
|
||||
]
|
||||
)
|
||||
@ -240,11 +252,43 @@ def test_run_deletes_only_free_tenants(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
cleanup.run()
|
||||
|
||||
assert repo.deleted == [["run-free"]]
|
||||
assert repo.cleanup_ref_calls[1]["tenant_ids"] == ["t_free"]
|
||||
|
||||
|
||||
def test_run_filters_candidate_tenants_before_target_query(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
cutoff = datetime.datetime.now()
|
||||
repo = FakeRepo(
|
||||
batches=[
|
||||
[
|
||||
make_ref("run-free", "t_free", cutoff),
|
||||
make_ref("run-paid", "t_paid", cutoff),
|
||||
]
|
||||
]
|
||||
)
|
||||
cleanup = create_cleanup(monkeypatch, repo=repo, days=30, batch_size=10)
|
||||
|
||||
monkeypatch.setattr(cleanup_module.dify_config, "BILLING_ENABLED", True)
|
||||
billing_calls: list[list[str]] = []
|
||||
|
||||
def fake_bulk(tenant_ids: list[str]) -> dict[str, SubscriptionPlan]:
|
||||
billing_calls.append(tenant_ids)
|
||||
return {
|
||||
"t_free": plan_info("sandbox", -1),
|
||||
"t_paid": plan_info("team", -1),
|
||||
}
|
||||
|
||||
monkeypatch.setattr(cleanup_module.BillingService, "get_plan_bulk_with_cache", staticmethod(fake_bulk))
|
||||
|
||||
cleanup.run()
|
||||
|
||||
assert billing_calls == [["t_free", "t_paid"]]
|
||||
assert repo.cleanup_ref_calls[1]["tenant_ids"] == ["t_free"]
|
||||
assert repo.deleted == [["run-free"]]
|
||||
|
||||
|
||||
def test_run_skips_when_no_free_tenants(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
cutoff = datetime.datetime.now()
|
||||
repo = FakeRepo(batches=[[FakeRun("run-paid", "t_paid", cutoff)]])
|
||||
repo = FakeRepo(batches=[[make_ref("run-paid", "t_paid", cutoff)]])
|
||||
cleanup = create_cleanup(monkeypatch, repo=repo, days=30, batch_size=10)
|
||||
|
||||
monkeypatch.setattr(cleanup_module.dify_config, "BILLING_ENABLED", True)
|
||||
@ -257,6 +301,53 @@ def test_run_skips_when_no_free_tenants(monkeypatch: pytest.MonkeyPatch) -> None
|
||||
cleanup.run()
|
||||
|
||||
assert repo.deleted == []
|
||||
assert len(repo.cleanup_ref_calls) == 2
|
||||
|
||||
|
||||
def test_run_paid_only_records_skipped_metrics(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
cutoff = datetime.datetime.now()
|
||||
repo = FakeRepo(batches=[[make_ref("run-paid", "t_paid", cutoff)]])
|
||||
cleanup = create_cleanup(monkeypatch, repo=repo, days=30, batch_size=10)
|
||||
|
||||
monkeypatch.setattr(cleanup_module.dify_config, "BILLING_ENABLED", True)
|
||||
monkeypatch.setattr(
|
||||
cleanup_module.BillingService,
|
||||
"get_plan_bulk_with_cache",
|
||||
staticmethod(lambda tenant_ids: {tenant_id: plan_info("team", 1893456000) for tenant_id in tenant_ids}),
|
||||
)
|
||||
batch_calls: list[dict[str, object]] = []
|
||||
monkeypatch.setattr(cleanup._metrics, "record_batch", lambda **kwargs: batch_calls.append(kwargs))
|
||||
|
||||
cleanup.run()
|
||||
|
||||
assert repo.deleted == []
|
||||
assert repo.counted == []
|
||||
assert batch_calls[0]["batch_rows"] == 1
|
||||
assert batch_calls[0]["targeted_runs"] == 0
|
||||
assert batch_calls[0]["skipped_runs"] == 1
|
||||
assert batch_calls[0]["deleted_runs"] == 0
|
||||
|
||||
|
||||
def test_run_target_query_is_bounded_by_candidate_high_water(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
first_created_at = datetime.datetime(2024, 1, 1, 0, 0, 0)
|
||||
second_created_at = datetime.datetime(2024, 1, 1, 0, 1, 0)
|
||||
repo = FakeRepo(
|
||||
batches=[
|
||||
[
|
||||
make_ref("run-free-1", "t_free", first_created_at),
|
||||
make_ref("run-free-2", "t_free", second_created_at),
|
||||
]
|
||||
]
|
||||
)
|
||||
cleanup = create_cleanup(monkeypatch, repo=repo, days=30, batch_size=2)
|
||||
|
||||
monkeypatch.setattr(cleanup_module.dify_config, "BILLING_ENABLED", False)
|
||||
|
||||
cleanup.run()
|
||||
|
||||
assert repo.cleanup_ref_calls[1]["last_seen"] is None
|
||||
assert repo.cleanup_ref_calls[1]["upper_bound"] == (second_created_at, "run-free-2")
|
||||
assert repo.cleanup_ref_calls[2]["last_seen"] == (second_created_at, "run-free-2")
|
||||
|
||||
|
||||
def test_run_exits_on_empty_batch(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
@ -268,7 +359,7 @@ def test_run_exits_on_empty_batch(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def test_run_records_metrics_on_success(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
cutoff = datetime.datetime.now()
|
||||
repo = FakeRepo(
|
||||
batches=[[FakeRun("run-free", "t_free", cutoff)]],
|
||||
batches=[[make_ref("run-free", "t_free", cutoff)]],
|
||||
delete_result={
|
||||
"runs": 0,
|
||||
"node_executions": 2,
|
||||
@ -300,13 +391,13 @@ def test_run_records_metrics_on_success(monkeypatch: pytest.MonkeyPatch) -> None
|
||||
|
||||
def test_run_records_failed_metrics(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
class FailingRepo(FakeRepo):
|
||||
def delete_runs_with_related(
|
||||
self, runs: list[FakeRun], delete_node_executions=None, delete_trigger_logs=None
|
||||
def delete_runs_with_related_by_ids(
|
||||
self, run_ids: list[str], delete_node_executions=None, delete_trigger_logs=None
|
||||
) -> dict[str, int]:
|
||||
raise RuntimeError("delete failed")
|
||||
|
||||
cutoff = datetime.datetime.now()
|
||||
repo = FailingRepo(batches=[[FakeRun("run-free", "t_free", cutoff)]])
|
||||
repo = FailingRepo(batches=[[make_ref("run-free", "t_free", cutoff)]])
|
||||
cleanup = create_cleanup(monkeypatch, repo=repo, days=30, batch_size=10)
|
||||
monkeypatch.setattr(cleanup_module.dify_config, "BILLING_ENABLED", False)
|
||||
|
||||
@ -323,7 +414,7 @@ def test_run_records_failed_metrics(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def test_run_dry_run_skips_deletions(monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
cutoff = datetime.datetime.now()
|
||||
repo = FakeRepo(
|
||||
batches=[[FakeRun("run-free", "t_free", cutoff)]],
|
||||
batches=[[make_ref("run-free", "t_free", cutoff)]],
|
||||
count_result={
|
||||
"runs": 0,
|
||||
"node_executions": 2,
|
||||
|
||||
@ -50,8 +50,7 @@ class TestDeleteDraftVariableOffloadData:
|
||||
assert result == 0
|
||||
mock_conn.execute.assert_not_called()
|
||||
|
||||
@patch("tasks.remove_app_and_related_data_task.logging")
|
||||
def test_delete_draft_variable_offload_data_database_failure(self, mock_logging):
|
||||
def test_delete_draft_variable_offload_data_database_failure(self, caplog):
|
||||
"""Test handling of database operation failures."""
|
||||
mock_conn = MagicMock()
|
||||
file_ids = ["file-1"]
|
||||
@ -60,13 +59,14 @@ class TestDeleteDraftVariableOffloadData:
|
||||
mock_conn.execute.side_effect = Exception("Database error")
|
||||
|
||||
# Execute function - should not raise, but log error
|
||||
result = _delete_draft_variable_offload_data(mock_conn, file_ids)
|
||||
with caplog.at_level(logging.ERROR):
|
||||
result = _delete_draft_variable_offload_data(mock_conn, file_ids)
|
||||
|
||||
# Should return 0 when error occurs
|
||||
assert result == 0
|
||||
|
||||
# Verify error was logged
|
||||
mock_logging.exception.assert_called_once_with("Error deleting draft variable offload data:")
|
||||
assert "Error deleting draft variable offload data:" in caplog.text
|
||||
|
||||
|
||||
class TestDeleteWorkflowArchiveLogs:
|
||||
|
||||
@ -4,8 +4,8 @@ export type ClientOptions = {
|
||||
baseUrl: `${string}://${string}/console/api` | (string & {})
|
||||
}
|
||||
|
||||
export type AppPagination = {
|
||||
data: Array<AppPartial>
|
||||
export type AgentAppPagination = {
|
||||
data: Array<AgentAppPartial>
|
||||
has_more: boolean
|
||||
limit: number
|
||||
page: number
|
||||
@ -258,7 +258,7 @@ export type AgentConfigSnapshotDetailResponse = {
|
||||
version_note?: string | null
|
||||
}
|
||||
|
||||
export type AppPartial = {
|
||||
export type AgentAppPartial = {
|
||||
access_mode?: string | null
|
||||
active_config_is_published?: boolean
|
||||
app_id?: string | null
|
||||
@ -279,6 +279,9 @@ export type AppPartial = {
|
||||
mode: string
|
||||
model_config?: ModelConfigPartial | null
|
||||
name: string
|
||||
published_node_reference_count?: number
|
||||
published_reference_count?: number
|
||||
published_references?: Array<AgentPublishedReferenceResponse>
|
||||
role?: string | null
|
||||
tags?: Array<Tag>
|
||||
updated_at?: number | null
|
||||
@ -665,12 +668,6 @@ export type ModelConfigPartial = {
|
||||
updated_by?: string | null
|
||||
}
|
||||
|
||||
export type LlmMode = 'chat' | 'completion'
|
||||
|
||||
export type AgentKind = 'dify_agent'
|
||||
|
||||
export type AgentIconType = 'emoji' | 'image' | 'link'
|
||||
|
||||
export type AgentPublishedReferenceResponse = {
|
||||
app_icon?: string | null
|
||||
app_icon_background?: string | null
|
||||
@ -684,6 +681,12 @@ export type AgentPublishedReferenceResponse = {
|
||||
workflow_version: string
|
||||
}
|
||||
|
||||
export type LlmMode = 'chat' | 'completion'
|
||||
|
||||
export type AgentKind = 'dify_agent'
|
||||
|
||||
export type AgentIconType = 'emoji' | 'image' | 'link'
|
||||
|
||||
export type AgentScope = 'roster' | 'workflow_only'
|
||||
|
||||
export type AgentSource = 'agent_app' | 'imported' | 'roster' | 'system' | 'workflow'
|
||||
@ -1258,8 +1261,8 @@ export type FileTransferMethod = 'datasource_file' | 'local_file' | 'remote_url'
|
||||
|
||||
export type ValueSourceType = 'constant' | 'variable'
|
||||
|
||||
export type AppPaginationWritable = {
|
||||
data: Array<AppPartialWritable>
|
||||
export type AgentAppPaginationWritable = {
|
||||
data: Array<AgentAppPartialWritable>
|
||||
has_more: boolean
|
||||
limit: number
|
||||
page: number
|
||||
@ -1296,7 +1299,7 @@ export type AppDetailWithSiteWritable = {
|
||||
workflow?: WorkflowPartial | null
|
||||
}
|
||||
|
||||
export type AppPartialWritable = {
|
||||
export type AgentAppPartialWritable = {
|
||||
access_mode?: string | null
|
||||
active_config_is_published?: boolean
|
||||
app_id?: string | null
|
||||
@ -1316,6 +1319,9 @@ export type AppPartialWritable = {
|
||||
mode: string
|
||||
model_config?: ModelConfigPartial | null
|
||||
name: string
|
||||
published_node_reference_count?: number
|
||||
published_reference_count?: number
|
||||
published_references?: Array<AgentPublishedReferenceResponse>
|
||||
role?: string | null
|
||||
tags?: Array<Tag>
|
||||
updated_at?: number | null
|
||||
@ -1365,7 +1371,7 @@ export type GetAgentData = {
|
||||
}
|
||||
|
||||
export type GetAgentResponses = {
|
||||
200: AppPagination
|
||||
200: AgentAppPagination
|
||||
}
|
||||
|
||||
export type GetAgentResponse = GetAgentResponses[keyof GetAgentResponses]
|
||||
|
||||
@ -471,9 +471,25 @@ export const zModelConfigPartial = z.object({
|
||||
})
|
||||
|
||||
/**
|
||||
* AppPartial
|
||||
* AgentPublishedReferenceResponse
|
||||
*/
|
||||
export const zAppPartial = z.object({
|
||||
export const zAgentPublishedReferenceResponse = z.object({
|
||||
app_icon: z.string().nullish(),
|
||||
app_icon_background: z.string().nullish(),
|
||||
app_icon_type: z.string().nullish(),
|
||||
app_id: z.string(),
|
||||
app_mode: z.string(),
|
||||
app_name: z.string(),
|
||||
app_updated_at: z.int().nullish(),
|
||||
node_ids: z.array(z.string()).optional(),
|
||||
workflow_id: z.string(),
|
||||
workflow_version: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
* AgentAppPartial
|
||||
*/
|
||||
export const zAgentAppPartial = z.object({
|
||||
access_mode: z.string().nullish(),
|
||||
active_config_is_published: z.boolean().optional().default(false),
|
||||
app_id: z.string().nullish(),
|
||||
@ -494,6 +510,9 @@ export const zAppPartial = z.object({
|
||||
mode: z.string(),
|
||||
model_config: zModelConfigPartial.nullish(),
|
||||
name: z.string(),
|
||||
published_node_reference_count: z.int().optional().default(0),
|
||||
published_reference_count: z.int().optional().default(0),
|
||||
published_references: z.array(zAgentPublishedReferenceResponse).optional(),
|
||||
role: z.string().nullish(),
|
||||
tags: z.array(zTag).optional(),
|
||||
updated_at: z.int().nullish(),
|
||||
@ -503,10 +522,10 @@ export const zAppPartial = z.object({
|
||||
})
|
||||
|
||||
/**
|
||||
* AppPagination
|
||||
* AgentAppPagination
|
||||
*/
|
||||
export const zAppPagination = z.object({
|
||||
data: z.array(zAppPartial),
|
||||
export const zAgentAppPagination = z.object({
|
||||
data: z.array(zAgentAppPartial),
|
||||
has_more: z.boolean(),
|
||||
limit: z.int(),
|
||||
page: z.int(),
|
||||
@ -581,22 +600,6 @@ export const zAgentKind = z.enum(['dify_agent'])
|
||||
*/
|
||||
export const zAgentIconType = z.enum(['emoji', 'image', 'link'])
|
||||
|
||||
/**
|
||||
* AgentPublishedReferenceResponse
|
||||
*/
|
||||
export const zAgentPublishedReferenceResponse = z.object({
|
||||
app_icon: z.string().nullish(),
|
||||
app_icon_background: z.string().nullish(),
|
||||
app_icon_type: z.string().nullish(),
|
||||
app_id: z.string(),
|
||||
app_mode: z.string(),
|
||||
app_name: z.string(),
|
||||
app_updated_at: z.int().nullish(),
|
||||
node_ids: z.array(z.string()).optional(),
|
||||
workflow_id: z.string(),
|
||||
workflow_version: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
* AgentScope
|
||||
*
|
||||
@ -1773,9 +1776,9 @@ export const zMessageInfiniteScrollPaginationResponse = z.object({
|
||||
})
|
||||
|
||||
/**
|
||||
* AppPartial
|
||||
* AgentAppPartial
|
||||
*/
|
||||
export const zAppPartialWritable = z.object({
|
||||
export const zAgentAppPartialWritable = z.object({
|
||||
access_mode: z.string().nullish(),
|
||||
active_config_is_published: z.boolean().optional().default(false),
|
||||
app_id: z.string().nullish(),
|
||||
@ -1795,6 +1798,9 @@ export const zAppPartialWritable = z.object({
|
||||
mode: z.string(),
|
||||
model_config: zModelConfigPartial.nullish(),
|
||||
name: z.string(),
|
||||
published_node_reference_count: z.int().optional().default(0),
|
||||
published_reference_count: z.int().optional().default(0),
|
||||
published_references: z.array(zAgentPublishedReferenceResponse).optional(),
|
||||
role: z.string().nullish(),
|
||||
tags: z.array(zTag).optional(),
|
||||
updated_at: z.int().nullish(),
|
||||
@ -1804,10 +1810,10 @@ export const zAppPartialWritable = z.object({
|
||||
})
|
||||
|
||||
/**
|
||||
* AppPagination
|
||||
* AgentAppPagination
|
||||
*/
|
||||
export const zAppPaginationWritable = z.object({
|
||||
data: z.array(zAppPartialWritable),
|
||||
export const zAgentAppPaginationWritable = z.object({
|
||||
data: z.array(zAgentAppPartialWritable),
|
||||
has_more: z.boolean(),
|
||||
limit: z.int(),
|
||||
page: z.int(),
|
||||
@ -1895,7 +1901,7 @@ export const zGetAgentQuery = z.object({
|
||||
/**
|
||||
* Agent app list
|
||||
*/
|
||||
export const zGetAgentResponse = zAppPagination
|
||||
export const zGetAgentResponse = zAgentAppPagination
|
||||
|
||||
export const zPostAgentBody = zAgentAppCreatePayload
|
||||
|
||||
|
||||
@ -17,7 +17,7 @@ vi.mock('@/next/navigation', () => ({
|
||||
|
||||
// Mock useDocLink hook
|
||||
vi.mock('@/context/i18n', () => ({
|
||||
useDocLink: () => (path?: string) => `https://docs.dify.ai/en${path?.startsWith('/use-dify/') ? `/cloud${path}` : path || ''}`,
|
||||
useDocLink: () => (path?: string) => `https://docs.dify.ai/en${path || ''}`,
|
||||
}))
|
||||
|
||||
// Mock external context providers (these are external dependencies)
|
||||
@ -155,7 +155,7 @@ describe('ExternalKnowledgeBaseCreate', () => {
|
||||
renderComponent()
|
||||
|
||||
const docLink = screen.getByText('dataset.connectHelper.helper4')
|
||||
expect(docLink)!.toHaveAttribute('href', 'https://docs.dify.ai/en/cloud/use-dify/knowledge/connect-external-knowledge-base')
|
||||
expect(docLink)!.toHaveAttribute('href', 'https://docs.dify.ai/en/use-dify/knowledge/connect-external-knowledge-base')
|
||||
expect(docLink)!.toHaveAttribute('target', '_blank')
|
||||
expect(docLink)!.toHaveAttribute('rel', 'noopener noreferrer')
|
||||
})
|
||||
|
||||
@ -22,7 +22,6 @@ vi.mock('react-i18next', () => ({
|
||||
|
||||
vi.mock('@/context/i18n', () => ({
|
||||
defaultDocBaseUrl: 'https://docs.dify.ai',
|
||||
getDocHomePath: () => '/home',
|
||||
}))
|
||||
|
||||
vi.mock('@/i18n-config/language', () => ({
|
||||
@ -46,7 +45,7 @@ describe('docsCommand', () => {
|
||||
docsCommand.execute?.()
|
||||
|
||||
expect(openSpy).toHaveBeenCalledWith(
|
||||
'https://docs.dify.ai/en/home',
|
||||
expect.stringContaining('https://docs.dify.ai'),
|
||||
'_blank',
|
||||
'noopener,noreferrer',
|
||||
)
|
||||
@ -86,11 +85,7 @@ describe('docsCommand', () => {
|
||||
const handlers = vi.mocked(registerCommands).mock.calls[0]![0]
|
||||
await handlers['navigation.doc']!()
|
||||
|
||||
expect(openSpy).toHaveBeenCalledWith(
|
||||
'https://docs.dify.ai/en/home',
|
||||
'_blank',
|
||||
'noopener,noreferrer',
|
||||
)
|
||||
expect(openSpy).toHaveBeenCalledWith('https://docs.dify.ai/en', '_blank', 'noopener,noreferrer')
|
||||
openSpy.mockRestore()
|
||||
})
|
||||
|
||||
|
||||
@ -2,20 +2,13 @@ import type { SlashCommandHandler } from './types'
|
||||
import { RiBookOpenLine } from '@remixicon/react'
|
||||
import * as React from 'react'
|
||||
import { getI18n } from 'react-i18next'
|
||||
import { defaultDocBaseUrl, getDocHomePath } from '@/context/i18n'
|
||||
import { defaultDocBaseUrl } from '@/context/i18n'
|
||||
import { getDocLanguage } from '@/i18n-config/language'
|
||||
import { registerCommands, unregisterCommands } from './command-bus'
|
||||
|
||||
// Documentation command dependency types - no external dependencies needed
|
||||
type DocDeps = Record<string, never>
|
||||
|
||||
const getDocsHomeUrl = () => {
|
||||
const i18n = getI18n()
|
||||
const currentLocale = i18n.language
|
||||
const docLanguage = getDocLanguage(currentLocale)
|
||||
return `${defaultDocBaseUrl}/${docLanguage}${getDocHomePath()}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Documentation command - Opens help documentation
|
||||
*/
|
||||
@ -26,7 +19,11 @@ export const docsCommand: SlashCommandHandler<DocDeps> = {
|
||||
|
||||
// Direct execution function
|
||||
execute: () => {
|
||||
window.open(getDocsHomeUrl(), '_blank', 'noopener,noreferrer')
|
||||
const i18n = getI18n()
|
||||
const currentLocale = i18n.language
|
||||
const docLanguage = getDocLanguage(currentLocale)
|
||||
const url = `${defaultDocBaseUrl}/${docLanguage}`
|
||||
window.open(url, '_blank', 'noopener,noreferrer')
|
||||
},
|
||||
|
||||
async search(args: string, locale: string = 'en') {
|
||||
@ -46,9 +43,14 @@ export const docsCommand: SlashCommandHandler<DocDeps> = {
|
||||
},
|
||||
|
||||
register(_deps: DocDeps) {
|
||||
const i18n = getI18n()
|
||||
registerCommands({
|
||||
'navigation.doc': async (_args) => {
|
||||
window.open(getDocsHomeUrl(), '_blank', 'noopener,noreferrer')
|
||||
// Get the current language from i18n
|
||||
const currentLocale = i18n.language
|
||||
const docLanguage = getDocLanguage(currentLocale)
|
||||
const url = `${defaultDocBaseUrl}/${docLanguage}`
|
||||
window.open(url, '_blank', 'noopener,noreferrer')
|
||||
},
|
||||
})
|
||||
},
|
||||
|
||||
@ -11,8 +11,8 @@ describe('Empty State', () => {
|
||||
expect(screen.getByText('common.apiBasedExtension.title')).toBeInTheDocument()
|
||||
const link = screen.getByText('common.apiBasedExtension.link')
|
||||
expect(link).toBeInTheDocument()
|
||||
// The real useDocLink includes language and product prefixes in tests.
|
||||
expect(link.closest('a')).toHaveAttribute('href', 'https://docs.dify.ai/en/self-host/use-dify/workspace/api-extension/api-extension')
|
||||
// The real useDocLink includes the language prefix (defaulting to /en in tests)
|
||||
expect(link.closest('a')).toHaveAttribute('href', 'https://docs.dify.ai/en/use-dify/workspace/api-extension/api-extension')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -527,7 +527,7 @@ describe('IntegrationsPage', () => {
|
||||
expect(screen.getAllByText('common.toolsPage.toolPlugin')).toHaveLength(2)
|
||||
expect(screen.getByText('common.toolsPage.description')).toBeInTheDocument()
|
||||
expect(screen.getByText('common.toolsPage.description').closest('[class*="max-w-[1600px]"]')).toHaveClass('px-6')
|
||||
expect(screen.getByRole('link', { name: /common\.modelProvider\.learnMore/i })).toHaveAttribute('href', 'https://docs.dify.ai/en/self-host/use-dify/workspace/tools')
|
||||
expect(screen.getByRole('link', { name: /common\.modelProvider\.learnMore/i })).toHaveAttribute('href', 'https://docs.dify.ai/en/use-dify/workspace/tools')
|
||||
})
|
||||
|
||||
it('aligns model provider headers to the unified content frame', () => {
|
||||
@ -549,7 +549,7 @@ describe('IntegrationsPage', () => {
|
||||
|
||||
expect(screen.getAllByText('MCP')).toHaveLength(2)
|
||||
expect(screen.getByText('common.mcpPage.description')).toBeInTheDocument()
|
||||
expect(screen.getByRole('link', { name: /common\.modelProvider\.learnMore/i })).toHaveAttribute('href', 'https://docs.dify.ai/en/self-host/use-dify/build/mcp')
|
||||
expect(screen.getByRole('link', { name: /common\.modelProvider\.learnMore/i })).toHaveAttribute('href', 'https://docs.dify.ai/en/use-dify/build/mcp')
|
||||
expect(screen.queryByText('common.toolsPage.description')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
@ -558,7 +558,7 @@ describe('IntegrationsPage', () => {
|
||||
|
||||
expect(screen.getAllByText('common.settings.swaggerAPIAsTool')).toHaveLength(2)
|
||||
expect(screen.getByText('common.swaggerAPIAsToolPage.description')).toBeInTheDocument()
|
||||
expect(screen.getByRole('link', { name: /common\.modelProvider\.learnMore/i })).toHaveAttribute('href', 'https://docs.dify.ai/en/self-host/use-dify/workspace/tools#custom-tool')
|
||||
expect(screen.getByRole('link', { name: /common\.modelProvider\.learnMore/i })).toHaveAttribute('href', 'https://docs.dify.ai/en/use-dify/workspace/tools#custom-tool')
|
||||
expect(screen.queryByText('common.toolsPage.description')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
@ -579,7 +579,7 @@ describe('IntegrationsPage', () => {
|
||||
expect(screen.getAllByText('common.settings.customEndpoint')).toHaveLength(2)
|
||||
expect(screen.getByText('common.apiBasedExtensionPage.description')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('api-extension-toolbar')).toBeInTheDocument()
|
||||
expect(screen.getByRole('link', { name: /common\.modelProvider\.learnMore/i })).toHaveAttribute('href', 'https://docs.dify.ai/en/self-host/use-dify/workspace/api-extension/api-extension')
|
||||
expect(screen.getByRole('link', { name: /common\.modelProvider\.learnMore/i })).toHaveAttribute('href', 'https://docs.dify.ai/en/use-dify/workspace/api-extension/api-extension')
|
||||
expect(screen.queryByText('common.toolsPage.description')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
@ -601,7 +601,7 @@ describe('IntegrationsPage', () => {
|
||||
|
||||
expect(screen.getAllByText('workflow.common.workflowAsTool')).toHaveLength(2)
|
||||
expect(screen.getByText('common.workflowAsToolPage.description')).toBeInTheDocument()
|
||||
expect(screen.getByRole('link', { name: /common\.modelProvider\.learnMore/i })).toHaveAttribute('href', 'https://docs.dify.ai/en/self-host/use-dify/workspace/tools#workflow-tool')
|
||||
expect(screen.getByRole('link', { name: /common\.modelProvider\.learnMore/i })).toHaveAttribute('href', 'https://docs.dify.ai/en/use-dify/workspace/tools#workflow-tool')
|
||||
expect(screen.queryByText('common.toolsPage.description')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
|
||||
@ -17,7 +17,7 @@ vi.mock('@/context/app-context', () => ({
|
||||
// Mock useLocale and useDocLink
|
||||
vi.mock('@/context/i18n', () => ({
|
||||
useLocale: () => 'en-US',
|
||||
useDocLink: () => (path?: string) => `https://docs.dify.ai/en${path?.startsWith('/use-dify/') ? `/cloud${path}` : path || ''}`,
|
||||
useDocLink: () => (path: string) => `https://docs.dify.ai/en/${path?.startsWith('/') ? path.slice(1) : path}`,
|
||||
}))
|
||||
|
||||
// Mock getLanguage
|
||||
@ -139,7 +139,7 @@ describe('CustomCreateCard', () => {
|
||||
render(<CustomCreateCard onRefreshData={mockOnRefreshData} />)
|
||||
|
||||
const docLink = screen.getByText('tools.swaggerAPIAsToolTip').closest('a')
|
||||
expect(docLink).toHaveAttribute('href', 'https://docs.dify.ai/en/cloud/use-dify/workspace/tools#custom-tool')
|
||||
expect(docLink).toHaveAttribute('href', 'https://docs.dify.ai/en/use-dify/workspace/tools#custom-tool')
|
||||
expect(docLink).toHaveAttribute('target', '_blank')
|
||||
expect(docLink).toHaveAttribute('rel', 'noopener noreferrer')
|
||||
})
|
||||
|
||||
@ -84,7 +84,7 @@ describe('Empty', () => {
|
||||
|
||||
expect(screen.getByRole('link', { name: /tools\.workflowToolEmpty\.goToStudio/i })).toHaveAttribute('href', '/apps')
|
||||
expect(screen.getByRole('link', { name: /tools\.workflowToolEmpty\.learnMore/i })).toHaveAttribute('target', '_blank')
|
||||
expect(screen.getByRole('link', { name: /tools\.workflowToolEmpty\.learnMore/i })).toHaveAttribute('href', 'https://docs.dify.ai/en/self-host/use-dify/workspace/tools#workflow-tool')
|
||||
expect(screen.getByRole('link', { name: /tools\.workflowToolEmpty\.learnMore/i })).toHaveAttribute('href', 'https://docs.dify.ai/en/use-dify/workspace/tools#workflow-tool')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -47,16 +47,4 @@ describe('useAvailableNodesMetaData', () => {
|
||||
title: 'workflow.blocks.start',
|
||||
})
|
||||
})
|
||||
|
||||
it('should use explicit docs pages and skip nodes without generated docs pages', () => {
|
||||
mockUseIsChatMode.mockReturnValue(false)
|
||||
|
||||
const { result } = renderHook(() => useAvailableNodesMetaData())
|
||||
|
||||
expect(result.current.nodesMap?.[BlockEnum.End]?.metaData.helpLinkUri).toBe('/docs/use-dify/nodes/output')
|
||||
expect(result.current.nodesMap?.[BlockEnum.IterationStart]?.metaData.helpLinkUri).toBeUndefined()
|
||||
expect(result.current.nodesMap?.[BlockEnum.LoopStart]?.metaData.helpLinkUri).toBeUndefined()
|
||||
expect(result.current.nodesMap?.[BlockEnum.LoopEnd]?.metaData.helpLinkUri).toBeUndefined()
|
||||
expect(result.current.nodesMap?.[BlockEnum.Start]?.metaData.helpLinkUri).toBe('/docs/use-dify/nodes/user-input')
|
||||
})
|
||||
})
|
||||
|
||||
@ -12,20 +12,8 @@ import TriggerScheduleDefault from '@/app/components/workflow/nodes/trigger-sche
|
||||
import TriggerWebhookDefault from '@/app/components/workflow/nodes/trigger-webhook/default'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
import { useDocLink } from '@/context/i18n'
|
||||
import { docPathProductAvailability } from '@/types/doc-paths'
|
||||
import { useIsChatMode } from './use-is-chat-mode'
|
||||
|
||||
const getNodeHelpLinkPath = (helpLinkUri?: string): DocPathWithoutLang | undefined => {
|
||||
if (!helpLinkUri)
|
||||
return undefined
|
||||
|
||||
const helpLinkPath = `/use-dify/nodes/${helpLinkUri}`
|
||||
if (!docPathProductAvailability[helpLinkPath])
|
||||
return undefined
|
||||
|
||||
return helpLinkPath as DocPathWithoutLang
|
||||
}
|
||||
|
||||
export const useAvailableNodesMetaData = () => {
|
||||
const { t } = useTranslation()
|
||||
const isChatMode = useIsChatMode()
|
||||
@ -61,14 +49,14 @@ export const useAvailableNodesMetaData = () => {
|
||||
const { metaData } = node
|
||||
const title = t(`blocks.${metaData.type}`, { ns: 'workflow' })
|
||||
const description = t(`blocksAbout.${metaData.type}`, { ns: 'workflow' })
|
||||
const helpLinkPath = getNodeHelpLinkPath(metaData.helpLinkUri)
|
||||
const helpLinkPath = `/use-dify/nodes/${metaData.helpLinkUri}` as DocPathWithoutLang
|
||||
return {
|
||||
...node,
|
||||
metaData: {
|
||||
...metaData,
|
||||
title,
|
||||
description,
|
||||
helpLinkUri: helpLinkPath ? docLink(helpLinkPath) : undefined,
|
||||
helpLinkUri: docLink(helpLinkPath),
|
||||
},
|
||||
defaultValue: {
|
||||
...node.defaultValue,
|
||||
|
||||
@ -317,7 +317,7 @@ describe('NodeSelector', () => {
|
||||
expect(await screen.findByText('workflow.tabs.unconfiguredStartDisabledTip')).toBeInTheDocument()
|
||||
expect(screen.getByRole('link', { name: 'workflow.tabs.startDisabledTipLearnMore' })).toHaveAttribute(
|
||||
'href',
|
||||
'https://docs.dify.ai/en/self-host/use-dify/nodes/trigger/overview',
|
||||
'https://docs.dify.ai/en/use-dify/nodes/trigger/overview',
|
||||
)
|
||||
expect(screen.getByPlaceholderText('workflow.tabs.searchBlock')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
@ -6,7 +6,6 @@ import { genNodeMetaData } from '@/app/components/workflow/utils'
|
||||
const metaData = genNodeMetaData({
|
||||
sort: 2.1,
|
||||
type: BlockEnum.End,
|
||||
helpLinkUri: 'output',
|
||||
isRequired: false,
|
||||
})
|
||||
const nodeDefault: NodeDefault<EndNodeType> = {
|
||||
|
||||
@ -5,10 +5,6 @@ import { useTranslation } from '#i18n'
|
||||
import { getDocLanguage } from '@/i18n-config/language'
|
||||
import { defaultDocBaseUrl, useDocLink } from './i18n'
|
||||
|
||||
const mockConfig = vi.hoisted(() => ({
|
||||
IS_CLOUD_EDITION: true,
|
||||
}))
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('#i18n', () => ({
|
||||
useTranslation: vi.fn(() => ({
|
||||
@ -16,12 +12,6 @@ vi.mock('#i18n', () => ({
|
||||
})),
|
||||
}))
|
||||
|
||||
vi.mock('@/config', () => ({
|
||||
get IS_CLOUD_EDITION() {
|
||||
return mockConfig.IS_CLOUD_EDITION
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/i18n-config/language', () => ({
|
||||
getDocLanguage: vi.fn((locale: string) => {
|
||||
const map: Record<string, string> = {
|
||||
@ -38,7 +28,6 @@ vi.mock('@/i18n-config/language', () => ({
|
||||
describe('useDocLink', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockConfig.IS_CLOUD_EDITION = true
|
||||
vi.mocked(useTranslation).mockReturnValue({
|
||||
i18n: { language: 'en-US' },
|
||||
} as ReturnType<typeof useTranslation>)
|
||||
@ -56,28 +45,28 @@ describe('useDocLink', () => {
|
||||
it('should use default base URL when no baseUrl provided', () => {
|
||||
const { result } = renderHook(() => useDocLink())
|
||||
const url = result.current()
|
||||
expect(url).toBe(`${defaultDocBaseUrl}/en/home`)
|
||||
expect(url).toBe(`${defaultDocBaseUrl}/en`)
|
||||
})
|
||||
|
||||
it('should use custom base URL when provided', () => {
|
||||
const customBaseUrl = 'https://custom.docs.com'
|
||||
const { result } = renderHook(() => useDocLink(customBaseUrl))
|
||||
const url = result.current()
|
||||
expect(url).toBe(`${customBaseUrl}/en/home`)
|
||||
expect(url).toBe(`${customBaseUrl}/en`)
|
||||
})
|
||||
|
||||
it('should remove trailing slash from base URL', () => {
|
||||
const baseUrlWithSlash = 'https://docs.dify.ai/'
|
||||
const { result } = renderHook(() => useDocLink(baseUrlWithSlash))
|
||||
const url = result.current('/use-dify/getting-started/introduction')
|
||||
expect(url).toBe('https://docs.dify.ai/en/cloud/use-dify/getting-started/introduction')
|
||||
expect(url).toBe('https://docs.dify.ai/en/use-dify/getting-started/introduction')
|
||||
})
|
||||
|
||||
it('should handle base URL without trailing slash', () => {
|
||||
const baseUrlWithoutSlash = 'https://docs.dify.ai'
|
||||
const { result } = renderHook(() => useDocLink(baseUrlWithoutSlash))
|
||||
const url = result.current('/use-dify/getting-started/introduction')
|
||||
expect(url).toBe('https://docs.dify.ai/en/cloud/use-dify/getting-started/introduction')
|
||||
expect(url).toBe('https://docs.dify.ai/en/use-dify/getting-started/introduction')
|
||||
})
|
||||
})
|
||||
|
||||
@ -85,31 +74,19 @@ describe('useDocLink', () => {
|
||||
it('should handle path parameter', () => {
|
||||
const { result } = renderHook(() => useDocLink())
|
||||
const url = result.current('/use-dify/getting-started/introduction')
|
||||
expect(url).toBe(`${defaultDocBaseUrl}/en/cloud/use-dify/getting-started/introduction`)
|
||||
expect(url).toBe(`${defaultDocBaseUrl}/en/use-dify/getting-started/introduction`)
|
||||
})
|
||||
|
||||
it('should handle empty path', () => {
|
||||
const { result } = renderHook(() => useDocLink())
|
||||
const url = result.current()
|
||||
expect(url).toBe(`${defaultDocBaseUrl}/en/home`)
|
||||
expect(url).toBe(`${defaultDocBaseUrl}/en`)
|
||||
})
|
||||
|
||||
it('should handle undefined path', () => {
|
||||
const { result } = renderHook(() => useDocLink())
|
||||
const url = result.current(undefined)
|
||||
expect(url).toBe(`${defaultDocBaseUrl}/en/home`)
|
||||
})
|
||||
|
||||
it('should keep common docs path without product prefix', () => {
|
||||
const { result } = renderHook(() => useDocLink())
|
||||
const url = result.current('/learn/key-concepts' as DocPathWithoutLang)
|
||||
expect(url).toBe(`${defaultDocBaseUrl}/en/learn/key-concepts`)
|
||||
})
|
||||
|
||||
it('should keep explicit product docs path without adding another product prefix', () => {
|
||||
const { result } = renderHook(() => useDocLink())
|
||||
const url = result.current('/cloud/use-dify/build/mcp' as DocPathWithoutLang)
|
||||
expect(url).toBe(`${defaultDocBaseUrl}/en/cloud/use-dify/build/mcp`)
|
||||
expect(url).toBe(`${defaultDocBaseUrl}/en`)
|
||||
})
|
||||
})
|
||||
|
||||
@ -122,12 +99,12 @@ describe('useDocLink', () => {
|
||||
|
||||
const pathMap: DocPathMap = {
|
||||
'zh-Hans': '/use-dify/getting-started/introduction',
|
||||
'en-US': '/use-dify/build/mcp',
|
||||
'en-US': '/use-dify/getting-started/quick-start',
|
||||
}
|
||||
|
||||
const { result } = renderHook(() => useDocLink())
|
||||
const url = result.current('/use-dify/build/mcp', pathMap)
|
||||
expect(url).toBe(`${defaultDocBaseUrl}/zh/cloud/use-dify/getting-started/introduction`)
|
||||
const url = result.current('/use-dify/getting-started/quick-start' as DocPathWithoutLang, pathMap)
|
||||
expect(url).toBe(`${defaultDocBaseUrl}/zh/use-dify/getting-started/introduction`)
|
||||
})
|
||||
|
||||
it('should use default path when locale not in pathMap', () => {
|
||||
@ -138,76 +115,18 @@ describe('useDocLink', () => {
|
||||
|
||||
const pathMap: DocPathMap = {
|
||||
'zh-Hans': '/use-dify/getting-started/introduction',
|
||||
'en-US': '/use-dify/build/mcp',
|
||||
'en-US': '/use-dify/getting-started/quick-start',
|
||||
}
|
||||
|
||||
const { result } = renderHook(() => useDocLink())
|
||||
const url = result.current('/use-dify/build/mcp', pathMap)
|
||||
expect(url).toBe(`${defaultDocBaseUrl}/ja/cloud/use-dify/build/mcp`)
|
||||
const url = result.current('/use-dify/getting-started/quick-start' as DocPathWithoutLang, pathMap)
|
||||
expect(url).toBe(`${defaultDocBaseUrl}/ja/use-dify/getting-started/quick-start`)
|
||||
})
|
||||
|
||||
it('should handle undefined pathMap', () => {
|
||||
const { result } = renderHook(() => useDocLink())
|
||||
const url = result.current('/use-dify/getting-started/introduction', undefined)
|
||||
expect(url).toBe(`${defaultDocBaseUrl}/en/cloud/use-dify/getting-started/introduction`)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Product prefix handling', () => {
|
||||
it('should add cloud product prefix for product docs available in both editions', () => {
|
||||
mockConfig.IS_CLOUD_EDITION = true
|
||||
|
||||
const { result } = renderHook(() => useDocLink())
|
||||
const url = result.current('/use-dify/build/mcp')
|
||||
expect(url).toBe(`${defaultDocBaseUrl}/en/cloud/use-dify/build/mcp`)
|
||||
})
|
||||
|
||||
it('should add self-host product prefix for product docs available in both editions outside cloud edition', () => {
|
||||
mockConfig.IS_CLOUD_EDITION = false
|
||||
|
||||
const { result } = renderHook(() => useDocLink())
|
||||
const url = result.current('/use-dify/build/mcp')
|
||||
expect(url).toBe(`${defaultDocBaseUrl}/en/self-host/use-dify/build/mcp`)
|
||||
})
|
||||
|
||||
it('should use the existing cloud docs path for cloud-only product docs outside cloud edition', () => {
|
||||
mockConfig.IS_CLOUD_EDITION = false
|
||||
|
||||
const { result } = renderHook(() => useDocLink())
|
||||
const url = result.current('/use-dify/workspace/subscription-management#dify-for-education')
|
||||
expect(url).toBe(`${defaultDocBaseUrl}/en/cloud/use-dify/workspace/subscription-management#dify-for-education`)
|
||||
})
|
||||
|
||||
it('should use the existing self-host docs path for self-host-only product docs in cloud edition', () => {
|
||||
mockConfig.IS_CLOUD_EDITION = true
|
||||
|
||||
const { result } = renderHook(() => useDocLink())
|
||||
const url = result.current('/deploy/overview')
|
||||
expect(url).toBe(`${defaultDocBaseUrl}/en/self-host/deploy/overview`)
|
||||
})
|
||||
|
||||
it('should not add a product prefix for unknown productless paths', () => {
|
||||
mockConfig.IS_CLOUD_EDITION = false
|
||||
|
||||
const { result } = renderHook(() => useDocLink())
|
||||
const url = result.current('/use-dify/unknown-page' as DocPathWithoutLang)
|
||||
expect(url).toBe(`${defaultDocBaseUrl}/en/use-dify/unknown-page`)
|
||||
})
|
||||
|
||||
it('should open shared docs home when no path is provided outside cloud edition', () => {
|
||||
mockConfig.IS_CLOUD_EDITION = false
|
||||
|
||||
const { result } = renderHook(() => useDocLink())
|
||||
const url = result.current()
|
||||
expect(url).toBe(`${defaultDocBaseUrl}/en/home`)
|
||||
})
|
||||
|
||||
it('should keep self-host deploy paths without adding use-dify product prefix', () => {
|
||||
mockConfig.IS_CLOUD_EDITION = true
|
||||
|
||||
const { result } = renderHook(() => useDocLink())
|
||||
const url = result.current('/self-host/deploy/overview' as DocPathWithoutLang)
|
||||
expect(url).toBe(`${defaultDocBaseUrl}/en/self-host/deploy/overview`)
|
||||
expect(url).toBe(`${defaultDocBaseUrl}/en/use-dify/getting-started/introduction`)
|
||||
})
|
||||
})
|
||||
|
||||
@ -313,7 +232,7 @@ describe('useDocLink', () => {
|
||||
|
||||
const { result } = renderHook(() => useDocLink())
|
||||
const url = result.current('/use-dify/getting-started/introduction')
|
||||
expect(url).toBe(`${defaultDocBaseUrl}/zh/cloud/use-dify/getting-started/introduction`)
|
||||
expect(url).toBe(`${defaultDocBaseUrl}/zh/use-dify/getting-started/introduction`)
|
||||
})
|
||||
})
|
||||
|
||||
@ -321,15 +240,15 @@ describe('useDocLink', () => {
|
||||
it('should handle path with anchor', () => {
|
||||
const { result } = renderHook(() => useDocLink())
|
||||
const url = result.current('/use-dify/getting-started/introduction#overview' as DocPathWithoutLang)
|
||||
expect(url).toBe(`${defaultDocBaseUrl}/en/cloud/use-dify/getting-started/introduction#overview`)
|
||||
expect(url).toBe(`${defaultDocBaseUrl}/en/use-dify/getting-started/introduction#overview`)
|
||||
})
|
||||
|
||||
it('should handle multiple calls with same hook instance', () => {
|
||||
const { result } = renderHook(() => useDocLink())
|
||||
const url1 = result.current('/use-dify/getting-started/introduction')
|
||||
const url2 = result.current('/use-dify/build/mcp')
|
||||
expect(url1).toBe(`${defaultDocBaseUrl}/en/cloud/use-dify/getting-started/introduction`)
|
||||
expect(url2).toBe(`${defaultDocBaseUrl}/en/cloud/use-dify/build/mcp`)
|
||||
const url2 = result.current('/use-dify/getting-started/quick-start')
|
||||
expect(url1).toBe(`${defaultDocBaseUrl}/en/use-dify/getting-started/introduction`)
|
||||
expect(url2).toBe(`${defaultDocBaseUrl}/en/use-dify/getting-started/quick-start`)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,10 +1,9 @@
|
||||
import type { Locale } from '@/i18n-config/language'
|
||||
import type { DocPathWithoutLang, DocsProduct } from '@/types/doc-paths'
|
||||
import type { DocPathWithoutLang } from '@/types/doc-paths'
|
||||
import { useCallback } from 'react'
|
||||
import { useTranslation } from '#i18n'
|
||||
import { IS_CLOUD_EDITION } from '@/config'
|
||||
import { getDocLanguage, getLanguage, getPricingPageLanguage } from '@/i18n-config/language'
|
||||
import { apiReferencePathTranslations, docPathProductAvailability } from '@/types/doc-paths'
|
||||
import { apiReferencePathTranslations } from '@/types/doc-paths'
|
||||
|
||||
export const useLocale = () => {
|
||||
const { i18n } = useTranslation()
|
||||
@ -25,44 +24,6 @@ export const useGetPricingPageLanguage = () => {
|
||||
export const defaultDocBaseUrl = 'https://docs.dify.ai'
|
||||
export type DocPathMap = Partial<Record<Locale, DocPathWithoutLang>>
|
||||
|
||||
export const getDocHomePath = () => '/home'
|
||||
|
||||
const getCurrentDocsProduct = (): DocsProduct => {
|
||||
return IS_CLOUD_EDITION ? 'cloud' : 'self-host'
|
||||
}
|
||||
|
||||
const splitPathHash = (path: string) => {
|
||||
const hashIndex = path.indexOf('#')
|
||||
if (hashIndex === -1) {
|
||||
return {
|
||||
pathname: path,
|
||||
hash: '',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
pathname: path.slice(0, hashIndex),
|
||||
hash: path.slice(hashIndex),
|
||||
}
|
||||
}
|
||||
|
||||
const getProductAwarePath = (path: string): string => {
|
||||
const { pathname, hash } = splitPathHash(path)
|
||||
const availableProducts = docPathProductAvailability[pathname]
|
||||
if (!availableProducts?.length)
|
||||
return path
|
||||
|
||||
const currentProduct = getCurrentDocsProduct()
|
||||
const targetProduct = availableProducts.includes(currentProduct)
|
||||
? currentProduct
|
||||
: availableProducts[0]
|
||||
|
||||
if (!targetProduct)
|
||||
return path
|
||||
|
||||
return `/${targetProduct}${pathname}${hash}`
|
||||
}
|
||||
|
||||
export const useDocLink = (baseUrl?: string): ((path?: DocPathWithoutLang, pathMap?: DocPathMap) => string) => {
|
||||
let baseDocUrl = baseUrl || defaultDocBaseUrl
|
||||
baseDocUrl = (baseDocUrl.endsWith('/')) ? baseDocUrl.slice(0, -1) : baseDocUrl
|
||||
@ -83,12 +44,6 @@ export const useDocLink = (baseUrl?: string): ((path?: DocPathWithoutLang, pathM
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (!targetPath) {
|
||||
targetPath = getDocHomePath()
|
||||
}
|
||||
else {
|
||||
targetPath = getProductAwarePath(targetPath)
|
||||
}
|
||||
|
||||
return `${baseDocUrl}${languagePrefix}${targetPath}`
|
||||
},
|
||||
|
||||
@ -11,8 +11,7 @@ import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
const DEFAULT_DOCS_JSON_URL = 'https://raw.githubusercontent.com/langgenius/dify-docs/refs/heads/main/docs.json'
|
||||
const DOCS_JSON_URL = process.env.DOCS_JSON_URL || DEFAULT_DOCS_JSON_URL
|
||||
const DOCS_JSON_URL = 'https://raw.githubusercontent.com/langgenius/dify-docs/refs/heads/main/docs.json'
|
||||
const OUTPUT_PATH = path.resolve(__dirname, '../types/doc-paths.ts')
|
||||
|
||||
type NavItem = string | NavObject | NavItem[]
|
||||
@ -22,9 +21,6 @@ type NavObject = {
|
||||
groups?: NavItem[]
|
||||
dropdowns?: NavItem[]
|
||||
languages?: NavItem[]
|
||||
products?: NavItem[]
|
||||
tabs?: NavItem[]
|
||||
menu?: NavItem[]
|
||||
versions?: NavItem[]
|
||||
openapi?: string
|
||||
[key: string]: unknown
|
||||
@ -62,15 +58,7 @@ type DocsJson = {
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
const OPENAPI_BASE_URL = (process.env.DOCS_OPENAPI_BASE_URL || new URL('.', DOCS_JSON_URL).toString()).replace(/\/?$/, '/')
|
||||
const DOCS_PRODUCTS = ['cloud', 'self-host'] as const
|
||||
|
||||
type DocsProduct = typeof DOCS_PRODUCTS[number]
|
||||
type ProductAvailability = Record<string, Set<DocsProduct>>
|
||||
|
||||
function isDocsProduct(segment: string): segment is DocsProduct {
|
||||
return DOCS_PRODUCTS.includes(segment as DocsProduct)
|
||||
}
|
||||
const OPENAPI_BASE_URL = 'https://raw.githubusercontent.com/langgenius/dify-docs/refs/heads/main/'
|
||||
|
||||
/**
|
||||
* Convert summary to URL slug
|
||||
@ -124,15 +112,6 @@ function extractOpenAPIPaths(item: NavItem | undefined, paths: Set<string> = new
|
||||
if (item.languages)
|
||||
extractOpenAPIPaths(item.languages, paths)
|
||||
|
||||
if (item.products)
|
||||
extractOpenAPIPaths(item.products, paths)
|
||||
|
||||
if (item.tabs)
|
||||
extractOpenAPIPaths(item.tabs, paths)
|
||||
|
||||
if (item.menu)
|
||||
extractOpenAPIPaths(item.menu, paths)
|
||||
|
||||
if (item.versions)
|
||||
extractOpenAPIPaths(item.versions, paths)
|
||||
}
|
||||
@ -220,15 +199,6 @@ function extractPaths(item: NavItem | undefined, paths: Set<string> = new Set())
|
||||
if (item.languages)
|
||||
extractPaths(item.languages, paths)
|
||||
|
||||
if (item.products)
|
||||
extractPaths(item.products, paths)
|
||||
|
||||
if (item.tabs)
|
||||
extractPaths(item.tabs, paths)
|
||||
|
||||
if (item.menu)
|
||||
extractPaths(item.menu, paths)
|
||||
|
||||
// Handle versions in navigation
|
||||
if (item.versions)
|
||||
extractPaths(item.versions, paths)
|
||||
@ -237,68 +207,33 @@ function extractPaths(item: NavItem | undefined, paths: Set<string> = new Set())
|
||||
return paths
|
||||
}
|
||||
|
||||
function addPathToGroup(groups: Record<string, Set<string>>, pathWithoutLang: string): void {
|
||||
const parts = pathWithoutLang.split('/')
|
||||
const section = parts[0]
|
||||
if (!section)
|
||||
return
|
||||
|
||||
if (!groups[section])
|
||||
groups[section] = new Set()
|
||||
|
||||
groups[section]!.add(pathWithoutLang)
|
||||
}
|
||||
|
||||
function getProductPathInfo(pathWithoutLang: string): { product: DocsProduct, pathWithoutProduct: string } | undefined {
|
||||
const parts = pathWithoutLang.split('/')
|
||||
const [product, ...rest] = parts
|
||||
|
||||
if (!product || !isDocsProduct(product) || rest.length === 0)
|
||||
return undefined
|
||||
|
||||
return {
|
||||
product,
|
||||
pathWithoutProduct: rest.join('/'),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Group paths by their prefix structure
|
||||
*/
|
||||
function groupPathsBySection(paths: Set<string>): { groups: Record<string, Set<string>>, productAvailability: ProductAvailability } {
|
||||
function groupPathsBySection(paths: Set<string>): Record<string, Set<string>> {
|
||||
const groups: Record<string, Set<string>> = {}
|
||||
const productAvailability: ProductAvailability = {}
|
||||
|
||||
for (const fullPath of paths) {
|
||||
// Skip non-doc paths (like .json files for OpenAPI)
|
||||
if (fullPath.endsWith('.json'))
|
||||
continue
|
||||
|
||||
// Remove language prefix (en/, zh/, ja/)
|
||||
const withoutLang = fullPath.replace(/^(en|zh|ja)\//, '')
|
||||
if (!withoutLang || withoutLang === fullPath)
|
||||
continue
|
||||
|
||||
// Skip non-doc paths (like .json files for OpenAPI)
|
||||
if (withoutLang.endsWith('.json') || withoutLang === 'None')
|
||||
continue
|
||||
// Get section (first part of path)
|
||||
const parts = withoutLang.split('/')
|
||||
const section = parts[0]
|
||||
|
||||
addPathToGroup(groups, withoutLang)
|
||||
if (!groups[section!])
|
||||
groups[section!] = new Set()
|
||||
|
||||
const productPathInfo = getProductPathInfo(withoutLang)
|
||||
if (productPathInfo) {
|
||||
const productlessPath = productPathInfo.pathWithoutProduct
|
||||
const normalizedPath = `/${productlessPath}`
|
||||
|
||||
addPathToGroup(groups, productlessPath)
|
||||
|
||||
if (!productAvailability[normalizedPath])
|
||||
productAvailability[normalizedPath] = new Set()
|
||||
|
||||
productAvailability[normalizedPath]!.add(productPathInfo.product)
|
||||
}
|
||||
groups[section!]!.add(withoutLang)
|
||||
}
|
||||
|
||||
return {
|
||||
groups,
|
||||
productAvailability,
|
||||
}
|
||||
return groups
|
||||
}
|
||||
|
||||
/**
|
||||
@ -316,7 +251,6 @@ function sectionToTypeName(section: string): string {
|
||||
*/
|
||||
function generateTypeDefinitions(
|
||||
groups: Record<string, Set<string>>,
|
||||
productAvailability: ProductAvailability,
|
||||
apiReferencePaths: string[],
|
||||
apiPathTranslations: Record<string, { zh?: string, ja?: string }>,
|
||||
): string {
|
||||
@ -324,12 +258,11 @@ function generateTypeDefinitions(
|
||||
'// GENERATE BY script',
|
||||
'// DON NOT EDIT IT MANUALLY',
|
||||
'//',
|
||||
`// Generated from: ${DOCS_JSON_URL}`,
|
||||
'// Generated from: https://raw.githubusercontent.com/langgenius/dify-docs/refs/heads/main/docs.json',
|
||||
`// Generated at: ${new Date().toISOString()}`,
|
||||
'',
|
||||
'// Language prefixes',
|
||||
'export type DocLanguage = \'en\' | \'zh\' | \'ja\'',
|
||||
'export type DocsProduct = \'cloud\' | \'self-host\'',
|
||||
'',
|
||||
]
|
||||
|
||||
@ -388,14 +321,10 @@ function generateTypeDefinitions(
|
||||
lines.push(' | `${DocPathWithoutLangBase}#${string}`')
|
||||
lines.push('')
|
||||
|
||||
// Generate product availability map for productless runtime links.
|
||||
lines.push('// Product availability for productless docs paths')
|
||||
lines.push('export const docPathProductAvailability: Record<string, readonly DocsProduct[]> = {')
|
||||
for (const path of Object.keys(productAvailability).sort()) {
|
||||
const products = [...productAvailability[path]!].sort((a, b) => DOCS_PRODUCTS.indexOf(a) - DOCS_PRODUCTS.indexOf(b))
|
||||
lines.push(` '${path}': [${products.map(product => `'${product}'`).join(', ')}],`)
|
||||
}
|
||||
lines.push('}')
|
||||
// Generate full path type with language prefix
|
||||
lines.push('// Full documentation path with language prefix')
|
||||
// eslint-disable-next-line no-template-curly-in-string
|
||||
lines.push('export type DifyDocPath = `${DocLanguage}/${DocPathWithoutLang}`')
|
||||
lines.push('')
|
||||
|
||||
// Generate API reference path translations map
|
||||
@ -494,13 +423,12 @@ async function main(): Promise<void> {
|
||||
console.log(`Generated ${Object.keys(apiPathTranslations).length} API path translations`)
|
||||
|
||||
// Group by section
|
||||
const { groups, productAvailability } = groupPathsBySection(allPaths)
|
||||
const groups = groupPathsBySection(allPaths)
|
||||
|
||||
console.log(`Grouped into ${Object.keys(groups).length} sections:`, Object.keys(groups))
|
||||
console.log(`Found ${Object.keys(productAvailability).length} product-aware paths`)
|
||||
|
||||
// Generate TypeScript
|
||||
const tsContent = generateTypeDefinitions(groups, productAvailability, uniqueEnApiPaths, apiPathTranslations)
|
||||
const tsContent = generateTypeDefinitions(groups, uniqueEnApiPaths, apiPathTranslations)
|
||||
|
||||
// Write to file
|
||||
await writeFile(OUTPUT_PATH, tsContent, 'utf-8')
|
||||
|
||||
@ -1,127 +1,28 @@
|
||||
// GENERATE BY script
|
||||
// DON NOT EDIT IT MANUALLY
|
||||
//
|
||||
// Generated from: https://raw.githubusercontent.com/langgenius/dify-docs/feat/audience-products/docs.json
|
||||
// Generated at: 2026-06-17T04:42:51.293Z
|
||||
// Generated from: https://raw.githubusercontent.com/langgenius/dify-docs/refs/heads/main/docs.json
|
||||
// Generated at: 2026-03-25T03:18:49.626Z
|
||||
|
||||
// Language prefixes
|
||||
export type DocLanguage = 'en' | 'zh' | 'ja'
|
||||
export type DocsProduct = 'cloud' | 'self-host'
|
||||
|
||||
// Cloud paths
|
||||
type CloudPath =
|
||||
| '/cloud/use-dify/build/additional-features'
|
||||
| '/cloud/use-dify/build/agent'
|
||||
| '/cloud/use-dify/build/chatbot'
|
||||
| '/cloud/use-dify/build/mcp'
|
||||
| '/cloud/use-dify/build/orchestrate-node'
|
||||
| '/cloud/use-dify/build/predefined-error-handling-logic'
|
||||
| '/cloud/use-dify/build/shortcut-key'
|
||||
| '/cloud/use-dify/build/text-generator'
|
||||
| '/cloud/use-dify/build/version-control'
|
||||
| '/cloud/use-dify/build/workflow-chatflow'
|
||||
| '/cloud/use-dify/build/workflow-collaboration'
|
||||
| '/cloud/use-dify/debug/error-type'
|
||||
| '/cloud/use-dify/debug/history-and-logs'
|
||||
| '/cloud/use-dify/debug/step-run'
|
||||
| '/cloud/use-dify/debug/variable-inspect'
|
||||
| '/cloud/use-dify/getting-started/introduction'
|
||||
| '/cloud/use-dify/knowledge/connect-external-knowledge-base'
|
||||
| '/cloud/use-dify/knowledge/create-knowledge/chunking-and-cleaning-text'
|
||||
| '/cloud/use-dify/knowledge/create-knowledge/import-text-data/readme'
|
||||
| '/cloud/use-dify/knowledge/create-knowledge/import-text-data/sync-from-notion'
|
||||
| '/cloud/use-dify/knowledge/create-knowledge/import-text-data/sync-from-website'
|
||||
| '/cloud/use-dify/knowledge/create-knowledge/introduction'
|
||||
| '/cloud/use-dify/knowledge/create-knowledge/setting-indexing-methods'
|
||||
| '/cloud/use-dify/knowledge/external-knowledge-api'
|
||||
| '/cloud/use-dify/knowledge/integrate-knowledge-within-application'
|
||||
| '/cloud/use-dify/knowledge/knowledge-pipeline/authorize-data-source'
|
||||
| '/cloud/use-dify/knowledge/knowledge-pipeline/create-knowledge-pipeline'
|
||||
| '/cloud/use-dify/knowledge/knowledge-pipeline/knowledge-pipeline-orchestration'
|
||||
| '/cloud/use-dify/knowledge/knowledge-pipeline/manage-knowledge-base'
|
||||
| '/cloud/use-dify/knowledge/knowledge-pipeline/publish-knowledge-pipeline'
|
||||
| '/cloud/use-dify/knowledge/knowledge-pipeline/readme'
|
||||
| '/cloud/use-dify/knowledge/knowledge-pipeline/upload-files'
|
||||
| '/cloud/use-dify/knowledge/knowledge-request-rate-limit'
|
||||
| '/cloud/use-dify/knowledge/manage-knowledge/introduction'
|
||||
| '/cloud/use-dify/knowledge/manage-knowledge/maintain-dataset-via-api'
|
||||
| '/cloud/use-dify/knowledge/manage-knowledge/maintain-knowledge-documents'
|
||||
| '/cloud/use-dify/knowledge/metadata'
|
||||
| '/cloud/use-dify/knowledge/readme'
|
||||
| '/cloud/use-dify/knowledge/test-retrieval'
|
||||
| '/cloud/use-dify/monitor/analysis'
|
||||
| '/cloud/use-dify/monitor/annotation-reply'
|
||||
| '/cloud/use-dify/monitor/integrations/integrate-aliyun'
|
||||
| '/cloud/use-dify/monitor/integrations/integrate-arize'
|
||||
| '/cloud/use-dify/monitor/integrations/integrate-langfuse'
|
||||
| '/cloud/use-dify/monitor/integrations/integrate-langsmith'
|
||||
| '/cloud/use-dify/monitor/integrations/integrate-opik'
|
||||
| '/cloud/use-dify/monitor/integrations/integrate-phoenix'
|
||||
| '/cloud/use-dify/monitor/integrations/integrate-weave'
|
||||
| '/cloud/use-dify/monitor/logs'
|
||||
| '/cloud/use-dify/nodes/agent'
|
||||
| '/cloud/use-dify/nodes/answer'
|
||||
| '/cloud/use-dify/nodes/code'
|
||||
| '/cloud/use-dify/nodes/doc-extractor'
|
||||
| '/cloud/use-dify/nodes/http-request'
|
||||
| '/cloud/use-dify/nodes/human-input'
|
||||
| '/cloud/use-dify/nodes/ifelse'
|
||||
| '/cloud/use-dify/nodes/iteration'
|
||||
| '/cloud/use-dify/nodes/knowledge-retrieval'
|
||||
| '/cloud/use-dify/nodes/list-operator'
|
||||
| '/cloud/use-dify/nodes/llm'
|
||||
| '/cloud/use-dify/nodes/loop'
|
||||
| '/cloud/use-dify/nodes/output'
|
||||
| '/cloud/use-dify/nodes/parameter-extractor'
|
||||
| '/cloud/use-dify/nodes/question-classifier'
|
||||
| '/cloud/use-dify/nodes/template'
|
||||
| '/cloud/use-dify/nodes/tools'
|
||||
| '/cloud/use-dify/nodes/trigger/overview'
|
||||
| '/cloud/use-dify/nodes/trigger/plugin-trigger'
|
||||
| '/cloud/use-dify/nodes/trigger/schedule-trigger'
|
||||
| '/cloud/use-dify/nodes/trigger/webhook-trigger'
|
||||
| '/cloud/use-dify/nodes/user-input'
|
||||
| '/cloud/use-dify/nodes/variable-aggregator'
|
||||
| '/cloud/use-dify/nodes/variable-assigner'
|
||||
| '/cloud/use-dify/publish/README'
|
||||
| '/cloud/use-dify/publish/developing-with-apis'
|
||||
| '/cloud/use-dify/publish/publish-mcp'
|
||||
| '/cloud/use-dify/publish/publish-to-marketplace'
|
||||
| '/cloud/use-dify/publish/webapp/chatflow-webapp'
|
||||
| '/cloud/use-dify/publish/webapp/embedding-in-websites'
|
||||
| '/cloud/use-dify/publish/webapp/web-app-settings'
|
||||
| '/cloud/use-dify/publish/webapp/workflow-webapp'
|
||||
| '/cloud/use-dify/workspace/api-extension/api-extension'
|
||||
| '/cloud/use-dify/workspace/api-extension/cloudflare-worker'
|
||||
| '/cloud/use-dify/workspace/api-extension/external-data-tool-api-extension'
|
||||
| '/cloud/use-dify/workspace/api-extension/moderation-api-extension'
|
||||
| '/cloud/use-dify/workspace/app-management'
|
||||
| '/cloud/use-dify/workspace/model-providers'
|
||||
| '/cloud/use-dify/workspace/personal-account-management'
|
||||
| '/cloud/use-dify/workspace/plugins'
|
||||
| '/cloud/use-dify/workspace/readme'
|
||||
| '/cloud/use-dify/workspace/subscription-management'
|
||||
| '/cloud/use-dify/workspace/team-members-management'
|
||||
| '/cloud/use-dify/workspace/tools'
|
||||
|
||||
// UseDify paths
|
||||
type UseDifyPath =
|
||||
| '/use-dify/build/additional-features'
|
||||
| '/use-dify/build/agent'
|
||||
| '/use-dify/build/chatbot'
|
||||
| '/use-dify/build/goto-anything'
|
||||
| '/use-dify/build/mcp'
|
||||
| '/use-dify/build/orchestrate-node'
|
||||
| '/use-dify/build/predefined-error-handling-logic'
|
||||
| '/use-dify/build/shortcut-key'
|
||||
| '/use-dify/build/text-generator'
|
||||
| '/use-dify/build/version-control'
|
||||
| '/use-dify/build/workflow-chatflow'
|
||||
| '/use-dify/build/workflow-collaboration'
|
||||
| '/use-dify/debug/error-type'
|
||||
| '/use-dify/debug/history-and-logs'
|
||||
| '/use-dify/debug/step-run'
|
||||
| '/use-dify/debug/variable-inspect'
|
||||
| '/use-dify/getting-started/introduction'
|
||||
| '/use-dify/getting-started/key-concepts'
|
||||
| '/use-dify/getting-started/quick-start'
|
||||
| '/use-dify/knowledge/connect-external-knowledge-base'
|
||||
| '/use-dify/knowledge/create-knowledge/chunking-and-cleaning-text'
|
||||
| '/use-dify/knowledge/create-knowledge/import-text-data/readme'
|
||||
@ -185,8 +86,24 @@ type UseDifyPath =
|
||||
| '/use-dify/publish/publish-to-marketplace'
|
||||
| '/use-dify/publish/webapp/chatflow-webapp'
|
||||
| '/use-dify/publish/webapp/embedding-in-websites'
|
||||
| '/use-dify/publish/webapp/web-app-access'
|
||||
| '/use-dify/publish/webapp/web-app-settings'
|
||||
| '/use-dify/publish/webapp/workflow-webapp'
|
||||
| '/use-dify/tutorials/article-reader'
|
||||
| '/use-dify/tutorials/build-ai-image-generation-app'
|
||||
| '/use-dify/tutorials/customer-service-bot'
|
||||
| '/use-dify/tutorials/simple-chatbot'
|
||||
| '/use-dify/tutorials/twitter-chatflow'
|
||||
| '/use-dify/tutorials/workflow-101/lesson-01'
|
||||
| '/use-dify/tutorials/workflow-101/lesson-02'
|
||||
| '/use-dify/tutorials/workflow-101/lesson-03'
|
||||
| '/use-dify/tutorials/workflow-101/lesson-04'
|
||||
| '/use-dify/tutorials/workflow-101/lesson-05'
|
||||
| '/use-dify/tutorials/workflow-101/lesson-06'
|
||||
| '/use-dify/tutorials/workflow-101/lesson-07'
|
||||
| '/use-dify/tutorials/workflow-101/lesson-08'
|
||||
| '/use-dify/tutorials/workflow-101/lesson-09'
|
||||
| '/use-dify/tutorials/workflow-101/lesson-10'
|
||||
| '/use-dify/workspace/api-extension/api-extension'
|
||||
| '/use-dify/workspace/api-extension/cloudflare-worker'
|
||||
| '/use-dify/workspace/api-extension/external-data-tool-api-extension'
|
||||
@ -198,42 +115,25 @@ type UseDifyPath =
|
||||
| '/use-dify/workspace/readme'
|
||||
| '/use-dify/workspace/subscription-management'
|
||||
| '/use-dify/workspace/team-members-management'
|
||||
| '/use-dify/workspace/tools'
|
||||
|
||||
// UseDify node paths (without prefix)
|
||||
type ExtractNodesPath<T> = T extends `/use-dify/nodes/${infer Path}` ? Path : never
|
||||
export type UseDifyNodesPath = ExtractNodesPath<UseDifyPath>
|
||||
|
||||
// Home paths
|
||||
type HomePath =
|
||||
| '/home'
|
||||
|
||||
// Learn paths
|
||||
type LearnPath =
|
||||
| '/learn/key-concepts'
|
||||
| '/learn/tutorials/article-reader'
|
||||
| '/learn/tutorials/build-ai-image-generation-app'
|
||||
| '/learn/tutorials/customer-service-bot'
|
||||
| '/learn/tutorials/simple-chatbot'
|
||||
| '/learn/tutorials/twitter-chatflow'
|
||||
| '/learn/tutorials/workflow-101/lesson-01'
|
||||
| '/learn/tutorials/workflow-101/lesson-02'
|
||||
| '/learn/tutorials/workflow-101/lesson-03'
|
||||
| '/learn/tutorials/workflow-101/lesson-04'
|
||||
| '/learn/tutorials/workflow-101/lesson-05'
|
||||
| '/learn/tutorials/workflow-101/lesson-06'
|
||||
| '/learn/tutorials/workflow-101/lesson-07'
|
||||
| '/learn/tutorials/workflow-101/lesson-08'
|
||||
| '/learn/tutorials/workflow-101/lesson-09'
|
||||
| '/learn/tutorials/workflow-101/lesson-10'
|
||||
|
||||
// QuickStart paths
|
||||
type QuickStartPath =
|
||||
| '/quick-start'
|
||||
|
||||
// Cli paths
|
||||
type CliPath =
|
||||
| '/cli/coming-soon'
|
||||
// SelfHost paths
|
||||
type SelfHostPath =
|
||||
| '/self-host/advanced-deployments/local-source-code'
|
||||
| '/self-host/advanced-deployments/start-the-frontend-docker-container'
|
||||
| '/self-host/configuration/environments'
|
||||
| '/self-host/platform-guides/bt-panel'
|
||||
| '/self-host/platform-guides/dify-premium'
|
||||
| '/self-host/quick-start/docker-compose'
|
||||
| '/self-host/quick-start/faqs'
|
||||
| '/self-host/troubleshooting/common-issues'
|
||||
| '/self-host/troubleshooting/docker-issues'
|
||||
| '/self-host/troubleshooting/integrations'
|
||||
| '/self-host/troubleshooting/storage-and-migration'
|
||||
| '/self-host/troubleshooting/weaviate-v4-migration'
|
||||
|
||||
// DevelopPlugin paths
|
||||
type DevelopPluginPath =
|
||||
@ -265,7 +165,6 @@ type DevelopPluginPath =
|
||||
| '/develop-plugin/features-and-specs/plugin-types/plugin-logging'
|
||||
| '/develop-plugin/features-and-specs/plugin-types/remote-debug-a-plugin'
|
||||
| '/develop-plugin/features-and-specs/plugin-types/tool'
|
||||
| '/develop-plugin/getting-started/choose-plugin-type'
|
||||
| '/develop-plugin/getting-started/cli'
|
||||
| '/develop-plugin/getting-started/getting-started-dify-plugin'
|
||||
| '/develop-plugin/publishing/faq/faq'
|
||||
@ -278,129 +177,6 @@ type DevelopPluginPath =
|
||||
| '/develop-plugin/publishing/standards/privacy-protection-guidelines'
|
||||
| '/develop-plugin/publishing/standards/third-party-signature-verification'
|
||||
|
||||
// SelfHost paths
|
||||
type SelfHostPath =
|
||||
| '/self-host/deploy/advanced-deployments/local-source-code'
|
||||
| '/self-host/deploy/advanced-deployments/start-the-frontend-docker-container'
|
||||
| '/self-host/deploy/configuration/environments'
|
||||
| '/self-host/deploy/overview'
|
||||
| '/self-host/deploy/platform-guides/bt-panel'
|
||||
| '/self-host/deploy/platform-guides/dify-premium'
|
||||
| '/self-host/deploy/quick-start/docker-compose'
|
||||
| '/self-host/deploy/quick-start/faqs'
|
||||
| '/self-host/deploy/troubleshooting/common-issues'
|
||||
| '/self-host/deploy/troubleshooting/docker-issues'
|
||||
| '/self-host/deploy/troubleshooting/integrations'
|
||||
| '/self-host/deploy/troubleshooting/storage-and-migration'
|
||||
| '/self-host/deploy/troubleshooting/weaviate-v4-migration'
|
||||
| '/self-host/use-dify/build/additional-features'
|
||||
| '/self-host/use-dify/build/agent'
|
||||
| '/self-host/use-dify/build/chatbot'
|
||||
| '/self-host/use-dify/build/mcp'
|
||||
| '/self-host/use-dify/build/orchestrate-node'
|
||||
| '/self-host/use-dify/build/predefined-error-handling-logic'
|
||||
| '/self-host/use-dify/build/shortcut-key'
|
||||
| '/self-host/use-dify/build/text-generator'
|
||||
| '/self-host/use-dify/build/version-control'
|
||||
| '/self-host/use-dify/build/workflow-chatflow'
|
||||
| '/self-host/use-dify/build/workflow-collaboration'
|
||||
| '/self-host/use-dify/debug/error-type'
|
||||
| '/self-host/use-dify/debug/history-and-logs'
|
||||
| '/self-host/use-dify/debug/step-run'
|
||||
| '/self-host/use-dify/debug/variable-inspect'
|
||||
| '/self-host/use-dify/getting-started/introduction'
|
||||
| '/self-host/use-dify/knowledge/connect-external-knowledge-base'
|
||||
| '/self-host/use-dify/knowledge/create-knowledge/chunking-and-cleaning-text'
|
||||
| '/self-host/use-dify/knowledge/create-knowledge/import-text-data/readme'
|
||||
| '/self-host/use-dify/knowledge/create-knowledge/import-text-data/sync-from-notion'
|
||||
| '/self-host/use-dify/knowledge/create-knowledge/import-text-data/sync-from-website'
|
||||
| '/self-host/use-dify/knowledge/create-knowledge/introduction'
|
||||
| '/self-host/use-dify/knowledge/create-knowledge/setting-indexing-methods'
|
||||
| '/self-host/use-dify/knowledge/external-knowledge-api'
|
||||
| '/self-host/use-dify/knowledge/integrate-knowledge-within-application'
|
||||
| '/self-host/use-dify/knowledge/knowledge-pipeline/authorize-data-source'
|
||||
| '/self-host/use-dify/knowledge/knowledge-pipeline/create-knowledge-pipeline'
|
||||
| '/self-host/use-dify/knowledge/knowledge-pipeline/knowledge-pipeline-orchestration'
|
||||
| '/self-host/use-dify/knowledge/knowledge-pipeline/manage-knowledge-base'
|
||||
| '/self-host/use-dify/knowledge/knowledge-pipeline/publish-knowledge-pipeline'
|
||||
| '/self-host/use-dify/knowledge/knowledge-pipeline/readme'
|
||||
| '/self-host/use-dify/knowledge/knowledge-pipeline/upload-files'
|
||||
| '/self-host/use-dify/knowledge/manage-knowledge/introduction'
|
||||
| '/self-host/use-dify/knowledge/manage-knowledge/maintain-dataset-via-api'
|
||||
| '/self-host/use-dify/knowledge/manage-knowledge/maintain-knowledge-documents'
|
||||
| '/self-host/use-dify/knowledge/metadata'
|
||||
| '/self-host/use-dify/knowledge/readme'
|
||||
| '/self-host/use-dify/knowledge/test-retrieval'
|
||||
| '/self-host/use-dify/monitor/analysis'
|
||||
| '/self-host/use-dify/monitor/annotation-reply'
|
||||
| '/self-host/use-dify/monitor/integrations/integrate-aliyun'
|
||||
| '/self-host/use-dify/monitor/integrations/integrate-arize'
|
||||
| '/self-host/use-dify/monitor/integrations/integrate-langfuse'
|
||||
| '/self-host/use-dify/monitor/integrations/integrate-langsmith'
|
||||
| '/self-host/use-dify/monitor/integrations/integrate-opik'
|
||||
| '/self-host/use-dify/monitor/integrations/integrate-phoenix'
|
||||
| '/self-host/use-dify/monitor/integrations/integrate-weave'
|
||||
| '/self-host/use-dify/monitor/logs'
|
||||
| '/self-host/use-dify/nodes/agent'
|
||||
| '/self-host/use-dify/nodes/answer'
|
||||
| '/self-host/use-dify/nodes/code'
|
||||
| '/self-host/use-dify/nodes/doc-extractor'
|
||||
| '/self-host/use-dify/nodes/http-request'
|
||||
| '/self-host/use-dify/nodes/human-input'
|
||||
| '/self-host/use-dify/nodes/ifelse'
|
||||
| '/self-host/use-dify/nodes/iteration'
|
||||
| '/self-host/use-dify/nodes/knowledge-retrieval'
|
||||
| '/self-host/use-dify/nodes/list-operator'
|
||||
| '/self-host/use-dify/nodes/llm'
|
||||
| '/self-host/use-dify/nodes/loop'
|
||||
| '/self-host/use-dify/nodes/output'
|
||||
| '/self-host/use-dify/nodes/parameter-extractor'
|
||||
| '/self-host/use-dify/nodes/question-classifier'
|
||||
| '/self-host/use-dify/nodes/template'
|
||||
| '/self-host/use-dify/nodes/tools'
|
||||
| '/self-host/use-dify/nodes/trigger/overview'
|
||||
| '/self-host/use-dify/nodes/trigger/plugin-trigger'
|
||||
| '/self-host/use-dify/nodes/trigger/schedule-trigger'
|
||||
| '/self-host/use-dify/nodes/trigger/webhook-trigger'
|
||||
| '/self-host/use-dify/nodes/user-input'
|
||||
| '/self-host/use-dify/nodes/variable-aggregator'
|
||||
| '/self-host/use-dify/nodes/variable-assigner'
|
||||
| '/self-host/use-dify/publish/README'
|
||||
| '/self-host/use-dify/publish/developing-with-apis'
|
||||
| '/self-host/use-dify/publish/publish-mcp'
|
||||
| '/self-host/use-dify/publish/publish-to-marketplace'
|
||||
| '/self-host/use-dify/publish/webapp/chatflow-webapp'
|
||||
| '/self-host/use-dify/publish/webapp/embedding-in-websites'
|
||||
| '/self-host/use-dify/publish/webapp/web-app-settings'
|
||||
| '/self-host/use-dify/publish/webapp/workflow-webapp'
|
||||
| '/self-host/use-dify/workspace/api-extension/api-extension'
|
||||
| '/self-host/use-dify/workspace/api-extension/cloudflare-worker'
|
||||
| '/self-host/use-dify/workspace/api-extension/external-data-tool-api-extension'
|
||||
| '/self-host/use-dify/workspace/api-extension/moderation-api-extension'
|
||||
| '/self-host/use-dify/workspace/app-management'
|
||||
| '/self-host/use-dify/workspace/model-providers'
|
||||
| '/self-host/use-dify/workspace/personal-account-management'
|
||||
| '/self-host/use-dify/workspace/plugins'
|
||||
| '/self-host/use-dify/workspace/readme'
|
||||
| '/self-host/use-dify/workspace/team-members-management'
|
||||
| '/self-host/use-dify/workspace/tools'
|
||||
|
||||
// Deploy paths
|
||||
type DeployPath =
|
||||
| '/deploy/advanced-deployments/local-source-code'
|
||||
| '/deploy/advanced-deployments/start-the-frontend-docker-container'
|
||||
| '/deploy/configuration/environments'
|
||||
| '/deploy/overview'
|
||||
| '/deploy/platform-guides/bt-panel'
|
||||
| '/deploy/platform-guides/dify-premium'
|
||||
| '/deploy/quick-start/docker-compose'
|
||||
| '/deploy/quick-start/faqs'
|
||||
| '/deploy/troubleshooting/common-issues'
|
||||
| '/deploy/troubleshooting/docker-issues'
|
||||
| '/deploy/troubleshooting/integrations'
|
||||
| '/deploy/troubleshooting/storage-and-migration'
|
||||
| '/deploy/troubleshooting/weaviate-v4-migration'
|
||||
|
||||
// API Reference paths (English, use apiReferencePathTranslations for other languages)
|
||||
type ApiReferencePath =
|
||||
| '/api-reference/annotations/configure-annotation-reply'
|
||||
@ -413,12 +189,6 @@ type ApiReferencePath =
|
||||
| '/api-reference/applications/get-app-meta'
|
||||
| '/api-reference/applications/get-app-parameters'
|
||||
| '/api-reference/applications/get-app-webapp-settings'
|
||||
| '/api-reference/chatflows/get-next-suggested-questions'
|
||||
| '/api-reference/chatflows/get-workflow-run-detail'
|
||||
| '/api-reference/chatflows/list-workflow-logs'
|
||||
| '/api-reference/chatflows/send-chat-message'
|
||||
| '/api-reference/chatflows/stop-chat-message-generation'
|
||||
| '/api-reference/chatflows/stream-workflow-events'
|
||||
| '/api-reference/chats/get-next-suggested-questions'
|
||||
| '/api-reference/chats/send-chat-message'
|
||||
| '/api-reference/chats/stop-chat-message-generation'
|
||||
@ -455,8 +225,6 @@ type ApiReferencePath =
|
||||
| '/api-reference/feedback/submit-message-feedback'
|
||||
| '/api-reference/files/download-file'
|
||||
| '/api-reference/files/upload-file'
|
||||
| '/api-reference/human-input/get-human-input-form'
|
||||
| '/api-reference/human-input/submit-human-input-form'
|
||||
| '/api-reference/knowledge-bases/create-an-empty-knowledge-base'
|
||||
| '/api-reference/knowledge-bases/delete-knowledge-base'
|
||||
| '/api-reference/knowledge-bases/get-knowledge-base'
|
||||
@ -484,24 +252,19 @@ type ApiReferencePath =
|
||||
| '/api-reference/tags/update-knowledge-tag'
|
||||
| '/api-reference/tts/convert-audio-to-text'
|
||||
| '/api-reference/tts/convert-text-to-audio'
|
||||
| '/api-reference/workflow-runs/get-workflow-run-detail'
|
||||
| '/api-reference/workflow-runs/list-workflow-logs'
|
||||
| '/api-reference/workflows/get-workflow-run-detail'
|
||||
| '/api-reference/workflows/list-workflow-logs'
|
||||
| '/api-reference/workflows/run-workflow'
|
||||
| '/api-reference/workflows/run-workflow-by-id'
|
||||
| '/api-reference/workflows/stop-workflow-task'
|
||||
| '/api-reference/workflows/stream-workflow-events'
|
||||
|
||||
// Base path without language prefix
|
||||
type DocPathWithoutLangBase =
|
||||
| CloudPath
|
||||
| UseDifyPath
|
||||
| HomePath
|
||||
| LearnPath
|
||||
| QuickStartPath
|
||||
| CliPath
|
||||
| DevelopPluginPath
|
||||
| SelfHostPath
|
||||
| DeployPath
|
||||
| DevelopPluginPath
|
||||
| ApiReferencePath
|
||||
|
||||
// Combined path without language prefix (supports optional #anchor)
|
||||
@ -509,116 +272,6 @@ export type DocPathWithoutLang =
|
||||
| DocPathWithoutLangBase
|
||||
| `${DocPathWithoutLangBase}#${string}`
|
||||
|
||||
// Product availability for productless docs paths
|
||||
export const docPathProductAvailability: Record<string, readonly DocsProduct[]> = {
|
||||
'/deploy/advanced-deployments/local-source-code': ['self-host'],
|
||||
'/deploy/advanced-deployments/start-the-frontend-docker-container': ['self-host'],
|
||||
'/deploy/configuration/environments': ['self-host'],
|
||||
'/deploy/overview': ['self-host'],
|
||||
'/deploy/platform-guides/bt-panel': ['self-host'],
|
||||
'/deploy/platform-guides/dify-premium': ['self-host'],
|
||||
'/deploy/quick-start/docker-compose': ['self-host'],
|
||||
'/deploy/quick-start/faqs': ['self-host'],
|
||||
'/deploy/troubleshooting/common-issues': ['self-host'],
|
||||
'/deploy/troubleshooting/docker-issues': ['self-host'],
|
||||
'/deploy/troubleshooting/integrations': ['self-host'],
|
||||
'/deploy/troubleshooting/storage-and-migration': ['self-host'],
|
||||
'/deploy/troubleshooting/weaviate-v4-migration': ['self-host'],
|
||||
'/use-dify/build/additional-features': ['cloud', 'self-host'],
|
||||
'/use-dify/build/agent': ['cloud', 'self-host'],
|
||||
'/use-dify/build/chatbot': ['cloud', 'self-host'],
|
||||
'/use-dify/build/mcp': ['cloud', 'self-host'],
|
||||
'/use-dify/build/orchestrate-node': ['cloud', 'self-host'],
|
||||
'/use-dify/build/predefined-error-handling-logic': ['cloud', 'self-host'],
|
||||
'/use-dify/build/shortcut-key': ['cloud', 'self-host'],
|
||||
'/use-dify/build/text-generator': ['cloud', 'self-host'],
|
||||
'/use-dify/build/version-control': ['cloud', 'self-host'],
|
||||
'/use-dify/build/workflow-chatflow': ['cloud', 'self-host'],
|
||||
'/use-dify/build/workflow-collaboration': ['cloud', 'self-host'],
|
||||
'/use-dify/debug/error-type': ['cloud', 'self-host'],
|
||||
'/use-dify/debug/history-and-logs': ['cloud', 'self-host'],
|
||||
'/use-dify/debug/step-run': ['cloud', 'self-host'],
|
||||
'/use-dify/debug/variable-inspect': ['cloud', 'self-host'],
|
||||
'/use-dify/getting-started/introduction': ['cloud', 'self-host'],
|
||||
'/use-dify/knowledge/connect-external-knowledge-base': ['cloud', 'self-host'],
|
||||
'/use-dify/knowledge/create-knowledge/chunking-and-cleaning-text': ['cloud', 'self-host'],
|
||||
'/use-dify/knowledge/create-knowledge/import-text-data/readme': ['cloud', 'self-host'],
|
||||
'/use-dify/knowledge/create-knowledge/import-text-data/sync-from-notion': ['cloud', 'self-host'],
|
||||
'/use-dify/knowledge/create-knowledge/import-text-data/sync-from-website': ['cloud', 'self-host'],
|
||||
'/use-dify/knowledge/create-knowledge/introduction': ['cloud', 'self-host'],
|
||||
'/use-dify/knowledge/create-knowledge/setting-indexing-methods': ['cloud', 'self-host'],
|
||||
'/use-dify/knowledge/external-knowledge-api': ['cloud', 'self-host'],
|
||||
'/use-dify/knowledge/integrate-knowledge-within-application': ['cloud', 'self-host'],
|
||||
'/use-dify/knowledge/knowledge-pipeline/authorize-data-source': ['cloud', 'self-host'],
|
||||
'/use-dify/knowledge/knowledge-pipeline/create-knowledge-pipeline': ['cloud', 'self-host'],
|
||||
'/use-dify/knowledge/knowledge-pipeline/knowledge-pipeline-orchestration': ['cloud', 'self-host'],
|
||||
'/use-dify/knowledge/knowledge-pipeline/manage-knowledge-base': ['cloud', 'self-host'],
|
||||
'/use-dify/knowledge/knowledge-pipeline/publish-knowledge-pipeline': ['cloud', 'self-host'],
|
||||
'/use-dify/knowledge/knowledge-pipeline/readme': ['cloud', 'self-host'],
|
||||
'/use-dify/knowledge/knowledge-pipeline/upload-files': ['cloud', 'self-host'],
|
||||
'/use-dify/knowledge/knowledge-request-rate-limit': ['cloud'],
|
||||
'/use-dify/knowledge/manage-knowledge/introduction': ['cloud', 'self-host'],
|
||||
'/use-dify/knowledge/manage-knowledge/maintain-dataset-via-api': ['cloud', 'self-host'],
|
||||
'/use-dify/knowledge/manage-knowledge/maintain-knowledge-documents': ['cloud', 'self-host'],
|
||||
'/use-dify/knowledge/metadata': ['cloud', 'self-host'],
|
||||
'/use-dify/knowledge/readme': ['cloud', 'self-host'],
|
||||
'/use-dify/knowledge/test-retrieval': ['cloud', 'self-host'],
|
||||
'/use-dify/monitor/analysis': ['cloud', 'self-host'],
|
||||
'/use-dify/monitor/annotation-reply': ['cloud', 'self-host'],
|
||||
'/use-dify/monitor/integrations/integrate-aliyun': ['cloud', 'self-host'],
|
||||
'/use-dify/monitor/integrations/integrate-arize': ['cloud', 'self-host'],
|
||||
'/use-dify/monitor/integrations/integrate-langfuse': ['cloud', 'self-host'],
|
||||
'/use-dify/monitor/integrations/integrate-langsmith': ['cloud', 'self-host'],
|
||||
'/use-dify/monitor/integrations/integrate-opik': ['cloud', 'self-host'],
|
||||
'/use-dify/monitor/integrations/integrate-phoenix': ['cloud', 'self-host'],
|
||||
'/use-dify/monitor/integrations/integrate-weave': ['cloud', 'self-host'],
|
||||
'/use-dify/monitor/logs': ['cloud', 'self-host'],
|
||||
'/use-dify/nodes/agent': ['cloud', 'self-host'],
|
||||
'/use-dify/nodes/answer': ['cloud', 'self-host'],
|
||||
'/use-dify/nodes/code': ['cloud', 'self-host'],
|
||||
'/use-dify/nodes/doc-extractor': ['cloud', 'self-host'],
|
||||
'/use-dify/nodes/http-request': ['cloud', 'self-host'],
|
||||
'/use-dify/nodes/human-input': ['cloud', 'self-host'],
|
||||
'/use-dify/nodes/ifelse': ['cloud', 'self-host'],
|
||||
'/use-dify/nodes/iteration': ['cloud', 'self-host'],
|
||||
'/use-dify/nodes/knowledge-retrieval': ['cloud', 'self-host'],
|
||||
'/use-dify/nodes/list-operator': ['cloud', 'self-host'],
|
||||
'/use-dify/nodes/llm': ['cloud', 'self-host'],
|
||||
'/use-dify/nodes/loop': ['cloud', 'self-host'],
|
||||
'/use-dify/nodes/output': ['cloud', 'self-host'],
|
||||
'/use-dify/nodes/parameter-extractor': ['cloud', 'self-host'],
|
||||
'/use-dify/nodes/question-classifier': ['cloud', 'self-host'],
|
||||
'/use-dify/nodes/template': ['cloud', 'self-host'],
|
||||
'/use-dify/nodes/tools': ['cloud', 'self-host'],
|
||||
'/use-dify/nodes/trigger/overview': ['cloud', 'self-host'],
|
||||
'/use-dify/nodes/trigger/plugin-trigger': ['cloud', 'self-host'],
|
||||
'/use-dify/nodes/trigger/schedule-trigger': ['cloud', 'self-host'],
|
||||
'/use-dify/nodes/trigger/webhook-trigger': ['cloud', 'self-host'],
|
||||
'/use-dify/nodes/user-input': ['cloud', 'self-host'],
|
||||
'/use-dify/nodes/variable-aggregator': ['cloud', 'self-host'],
|
||||
'/use-dify/nodes/variable-assigner': ['cloud', 'self-host'],
|
||||
'/use-dify/publish/README': ['cloud', 'self-host'],
|
||||
'/use-dify/publish/developing-with-apis': ['cloud', 'self-host'],
|
||||
'/use-dify/publish/publish-mcp': ['cloud', 'self-host'],
|
||||
'/use-dify/publish/publish-to-marketplace': ['cloud', 'self-host'],
|
||||
'/use-dify/publish/webapp/chatflow-webapp': ['cloud', 'self-host'],
|
||||
'/use-dify/publish/webapp/embedding-in-websites': ['cloud', 'self-host'],
|
||||
'/use-dify/publish/webapp/web-app-settings': ['cloud', 'self-host'],
|
||||
'/use-dify/publish/webapp/workflow-webapp': ['cloud', 'self-host'],
|
||||
'/use-dify/workspace/api-extension/api-extension': ['cloud', 'self-host'],
|
||||
'/use-dify/workspace/api-extension/cloudflare-worker': ['cloud', 'self-host'],
|
||||
'/use-dify/workspace/api-extension/external-data-tool-api-extension': ['cloud', 'self-host'],
|
||||
'/use-dify/workspace/api-extension/moderation-api-extension': ['cloud', 'self-host'],
|
||||
'/use-dify/workspace/app-management': ['cloud', 'self-host'],
|
||||
'/use-dify/workspace/model-providers': ['cloud', 'self-host'],
|
||||
'/use-dify/workspace/personal-account-management': ['cloud', 'self-host'],
|
||||
'/use-dify/workspace/plugins': ['cloud', 'self-host'],
|
||||
'/use-dify/workspace/readme': ['cloud', 'self-host'],
|
||||
'/use-dify/workspace/subscription-management': ['cloud'],
|
||||
'/use-dify/workspace/team-members-management': ['cloud', 'self-host'],
|
||||
'/use-dify/workspace/tools': ['cloud', 'self-host'],
|
||||
}
|
||||
|
||||
// API Reference path translations (English -> other languages)
|
||||
export const apiReferencePathTranslations: Record<string, { zh?: string; ja?: string }> = {
|
||||
'/api-reference/annotations/configure-annotation-reply': { zh: '/api-reference/标注管理/配置标注回复', ja: '/api-reference/アノテーション管理/アノテーション返信を設定' },
|
||||
@ -631,12 +284,6 @@ export const apiReferencePathTranslations: Record<string, { zh?: string; ja?: st
|
||||
'/api-reference/applications/get-app-meta': { zh: '/api-reference/应用配置/获取应用元数据', ja: '/api-reference/アプリケーション設定/アプリケーションのメタ情報を取得' },
|
||||
'/api-reference/applications/get-app-parameters': { zh: '/api-reference/应用配置/获取应用参数', ja: '/api-reference/アプリケーション設定/アプリケーションのパラメータ情報を取得' },
|
||||
'/api-reference/applications/get-app-webapp-settings': { zh: '/api-reference/应用配置/获取应用-webapp-设置', ja: '/api-reference/アプリケーション設定/アプリの-webapp-設定を取得' },
|
||||
'/api-reference/chatflows/get-next-suggested-questions': { zh: '/api-reference/对话流/获取下一轮建议问题列表', ja: '/api-reference/チャットフロー/次の推奨質問を取得' },
|
||||
'/api-reference/chatflows/get-workflow-run-detail': { zh: '/api-reference/对话流/获取工作流执行情况', ja: '/api-reference/チャットフロー/ワークフロー実行詳細を取得' },
|
||||
'/api-reference/chatflows/list-workflow-logs': { zh: '/api-reference/对话流/获取工作流日志', ja: '/api-reference/チャットフロー/ワークフローログ一覧を取得' },
|
||||
'/api-reference/chatflows/send-chat-message': { zh: '/api-reference/对话流/发送对话消息', ja: '/api-reference/チャットフロー/チャットメッセージを送信' },
|
||||
'/api-reference/chatflows/stop-chat-message-generation': { zh: '/api-reference/对话流/停止响应', ja: '/api-reference/チャットフロー/生成を停止' },
|
||||
'/api-reference/chatflows/stream-workflow-events': { zh: '/api-reference/对话流/流式获取工作流事件', ja: '/api-reference/チャットフロー/ワークフローイベントをストリーム' },
|
||||
'/api-reference/chats/get-next-suggested-questions': { zh: '/api-reference/对话消息/获取下一轮建议问题列表', ja: '/api-reference/チャットメッセージ/次の推奨質問を取得' },
|
||||
'/api-reference/chats/send-chat-message': { zh: '/api-reference/对话消息/发送对话消息', ja: '/api-reference/チャットメッセージ/チャットメッセージを送信' },
|
||||
'/api-reference/chats/stop-chat-message-generation': { zh: '/api-reference/对话消息/停止响应', ja: '/api-reference/チャットメッセージ/生成を停止' },
|
||||
@ -673,8 +320,6 @@ export const apiReferencePathTranslations: Record<string, { zh?: string; ja?: st
|
||||
'/api-reference/feedback/submit-message-feedback': { zh: '/api-reference/消息反馈/提交消息反馈', ja: '/api-reference/メッセージフィードバック/メッセージフィードバックを送信' },
|
||||
'/api-reference/files/download-file': { zh: '/api-reference/文件操作/下载文件', ja: '/api-reference/ファイル操作/ファイルをダウンロード' },
|
||||
'/api-reference/files/upload-file': { zh: '/api-reference/文件操作/上传文件', ja: '/api-reference/ファイル操作/ファイルをアップロード' },
|
||||
'/api-reference/human-input/get-human-input-form': { zh: '/api-reference/人工介入/获取人工介入表单', ja: '/api-reference/人間の入力/人間の入力フォームを取得' },
|
||||
'/api-reference/human-input/submit-human-input-form': { zh: '/api-reference/人工介入/提交人工介入表单', ja: '/api-reference/人間の入力/人間の入力フォームを送信' },
|
||||
'/api-reference/knowledge-bases/create-an-empty-knowledge-base': { zh: '/api-reference/知识库/创建空知识库', ja: '/api-reference/データセット/空のナレッジベースを作成' },
|
||||
'/api-reference/knowledge-bases/delete-knowledge-base': { zh: '/api-reference/知识库/删除知识库', ja: '/api-reference/データセット/ナレッジベースを削除' },
|
||||
'/api-reference/knowledge-bases/get-knowledge-base': { zh: '/api-reference/知识库/获取知识库详情', ja: '/api-reference/データセット/ナレッジベース詳細を取得' },
|
||||
@ -702,10 +347,11 @@ export const apiReferencePathTranslations: Record<string, { zh?: string; ja?: st
|
||||
'/api-reference/tags/update-knowledge-tag': { zh: '/api-reference/标签/修改知识库标签', ja: '/api-reference/タグ管理/ナレッジベースタグを変更' },
|
||||
'/api-reference/tts/convert-audio-to-text': { zh: '/api-reference/语音与文字转换/语音转文字', ja: '/api-reference/音声・テキスト変換/音声をテキストに変換' },
|
||||
'/api-reference/tts/convert-text-to-audio': { zh: '/api-reference/语音与文字转换/文字转语音', ja: '/api-reference/音声・テキスト変換/テキストを音声に変換' },
|
||||
'/api-reference/workflow-runs/get-workflow-run-detail': { zh: '/api-reference/工作流执行/获取工作流执行情况', ja: '/api-reference/ワークフロー実行/ワークフロー実行詳細を取得' },
|
||||
'/api-reference/workflow-runs/list-workflow-logs': { zh: '/api-reference/工作流执行/获取工作流日志', ja: '/api-reference/ワークフロー実行/ワークフローログ一覧を取得' },
|
||||
'/api-reference/workflows/get-workflow-run-detail': { zh: '/api-reference/工作流/获取工作流执行情况', ja: '/api-reference/ワークフロー/ワークフロー実行詳細を取得' },
|
||||
'/api-reference/workflows/list-workflow-logs': { zh: '/api-reference/工作流/获取工作流日志', ja: '/api-reference/ワークフロー/ワークフローログ一覧を取得' },
|
||||
'/api-reference/workflows/run-workflow': { zh: '/api-reference/工作流/执行工作流', ja: '/api-reference/ワークフロー/ワークフローを実行' },
|
||||
'/api-reference/workflows/run-workflow-by-id': { zh: '/api-reference/工作流/按-id-执行工作流', ja: '/api-reference/ワークフロー/id-でワークフローを実行' },
|
||||
'/api-reference/workflows/stop-workflow-task': { zh: '/api-reference/工作流/停止工作流任务', ja: '/api-reference/ワークフロー/ワークフロータスクを停止' },
|
||||
'/api-reference/workflows/stream-workflow-events': { zh: '/api-reference/工作流/流式获取工作流事件', ja: '/api-reference/ワークフロー/ワークフローイベントをストリーム' },
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user