mirror of
https://github.com/langgenius/dify.git
synced 2026-06-18 05:38:17 +08:00
Compare commits
6 Commits
deploy/dev
...
codex/adap
| Author | SHA1 | Date | |
|---|---|---|---|
| ddd5f4b323 | |||
| cc74f3b1fb | |||
| 5f1f55de4c | |||
| bedbbda43e | |||
| b868c7bbc0 | |||
| b7972c95da |
@ -1,12 +1,11 @@
|
||||
from uuid import UUID
|
||||
|
||||
from flask import abort, request
|
||||
from flask import request
|
||||
from flask_restx import Resource
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models
|
||||
from controllers.console import console_ns
|
||||
from controllers.console.agent.app_helpers import resolve_agent_app_model
|
||||
from controllers.console.app.app import (
|
||||
AppDetailWithSite,
|
||||
AppListQuery,
|
||||
@ -28,22 +27,14 @@ from fields.agent_fields import (
|
||||
AgentConfigSnapshotDetailResponse,
|
||||
AgentConfigSnapshotListResponse,
|
||||
AgentInviteOptionsResponse,
|
||||
AgentLogListResponse,
|
||||
AgentPublishedReferenceResponse,
|
||||
AgentRosterListResponse,
|
||||
AgentStatisticSummaryEnvelopeResponse,
|
||||
)
|
||||
from libs.datetime_utils import parse_time_range
|
||||
from libs.helper import dump_response
|
||||
from libs.login import login_required
|
||||
from models import Account
|
||||
from models.model import IconType
|
||||
from services.agent.errors import AgentNotFoundError
|
||||
from services.agent.observability_service import (
|
||||
AgentLogQueryParams,
|
||||
AgentObservabilityService,
|
||||
AgentStatisticsQueryParams,
|
||||
)
|
||||
from services.agent.roster_service import AgentRosterService
|
||||
from services.app_service import AppListParams, AppService, CreateAppParams
|
||||
from services.enterprise.enterprise_service import EnterpriseService
|
||||
@ -72,49 +63,11 @@ class AgentAppUpdatePayload(UpdateAppPayload):
|
||||
role: str | None = Field(default=None, description="Agent role", max_length=255)
|
||||
|
||||
|
||||
class AgentLogsQuery(BaseModel):
|
||||
page: int = Field(default=1, ge=1, description="Page number")
|
||||
limit: int = Field(default=20, ge=1, le=100, description="Page size")
|
||||
keyword: str | None = Field(default=None, description="Search query, answer, or conversation name")
|
||||
status: str | None = Field(default=None, description="Filter by success, failed, or paused")
|
||||
source: str | None = Field(
|
||||
default=None,
|
||||
description="Filter by all, console/explore, api/service-api, web-app, debugger, openapi, or trigger",
|
||||
)
|
||||
start: str | None = Field(default=None, description="Start date (YYYY-MM-DD HH:MM)")
|
||||
end: str | None = Field(default=None, description="End date (YYYY-MM-DD HH:MM)")
|
||||
|
||||
@field_validator("keyword", "status", "source", "start", "end", mode="before")
|
||||
@classmethod
|
||||
def empty_string_to_none(cls, value: str | None) -> str | None:
|
||||
if value == "":
|
||||
return None
|
||||
return value
|
||||
|
||||
|
||||
class AgentStatisticsQuery(BaseModel):
|
||||
source: str | None = Field(
|
||||
default=None,
|
||||
description="Filter by all, console/explore, api/service-api, web-app, debugger, openapi, or trigger",
|
||||
)
|
||||
start: str | None = Field(default=None, description="Start date (YYYY-MM-DD HH:MM)")
|
||||
end: str | None = Field(default=None, description="End date (YYYY-MM-DD HH:MM)")
|
||||
|
||||
@field_validator("source", "start", "end", mode="before")
|
||||
@classmethod
|
||||
def empty_string_to_none(cls, value: str | None) -> str | None:
|
||||
if value == "":
|
||||
return None
|
||||
return value
|
||||
|
||||
|
||||
register_schema_models(
|
||||
console_ns,
|
||||
AgentAppCreatePayload,
|
||||
AgentAppUpdatePayload,
|
||||
AgentInviteOptionsQuery,
|
||||
AgentLogsQuery,
|
||||
AgentStatisticsQuery,
|
||||
AgentIdPath,
|
||||
AppListQuery,
|
||||
UpdateAppPayload,
|
||||
@ -127,10 +80,8 @@ register_response_schema_models(
|
||||
AgentConfigSnapshotDetailResponse,
|
||||
AgentConfigSnapshotListResponse,
|
||||
AgentInviteOptionsResponse,
|
||||
AgentLogListResponse,
|
||||
AgentPublishedReferenceResponse,
|
||||
AgentRosterListResponse,
|
||||
AgentStatisticSummaryEnvelopeResponse,
|
||||
)
|
||||
|
||||
|
||||
@ -185,19 +136,7 @@ def _serialize_agent_app_pagination(app_pagination, *, tenant_id: str) -> dict:
|
||||
|
||||
|
||||
def _resolve_agent_app_model(*, tenant_id: str, agent_id: UUID):
|
||||
return resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
|
||||
|
||||
|
||||
def _agent_observability_service() -> AgentObservabilityService:
|
||||
return AgentObservabilityService(db.session)
|
||||
|
||||
|
||||
def _parse_observability_time_range(start: str | None, end: str | None, account: Account):
|
||||
timezone = account.timezone or "UTC"
|
||||
try:
|
||||
return parse_time_range(start, end, timezone)
|
||||
except ValueError as exc:
|
||||
abort(400, description=str(exc))
|
||||
return _agent_roster_service().get_agent_app_model(tenant_id=tenant_id, agent_id=str(agent_id))
|
||||
|
||||
|
||||
@console_ns.route("/agent")
|
||||
@ -328,65 +267,6 @@ class AgentInviteOptionsApi(Resource):
|
||||
)
|
||||
|
||||
|
||||
@console_ns.route("/agent/<uuid:agent_id>/logs")
|
||||
class AgentLogsApi(Resource):
|
||||
@console_ns.doc(params=query_params_from_model(AgentLogsQuery))
|
||||
@console_ns.response(200, "Agent logs", console_ns.models[AgentLogListResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
def get(self, tenant_id: str, current_user: Account, agent_id: UUID):
|
||||
app_model = _resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
|
||||
query = AgentLogsQuery.model_validate(request.args.to_dict(flat=True))
|
||||
start, end = _parse_observability_time_range(query.start, query.end, current_user)
|
||||
try:
|
||||
payload = _agent_observability_service().list_logs(
|
||||
app=app_model,
|
||||
params=AgentLogQueryParams(
|
||||
page=query.page,
|
||||
limit=query.limit,
|
||||
keyword=query.keyword,
|
||||
status=query.status,
|
||||
source=query.source,
|
||||
start=start,
|
||||
end=end,
|
||||
),
|
||||
)
|
||||
except ValueError as exc:
|
||||
abort(400, description=str(exc))
|
||||
return dump_response(AgentLogListResponse, payload)
|
||||
|
||||
|
||||
@console_ns.route("/agent/<uuid:agent_id>/statistics/summary")
|
||||
class AgentStatisticsSummaryApi(Resource):
|
||||
@console_ns.doc(params=query_params_from_model(AgentStatisticsQuery))
|
||||
@console_ns.response(
|
||||
200,
|
||||
"Agent monitoring summary and chart data",
|
||||
console_ns.models[AgentStatisticSummaryEnvelopeResponse.__name__],
|
||||
)
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
def get(self, tenant_id: str, current_user: Account, agent_id: UUID):
|
||||
app_model = _resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
|
||||
query = AgentStatisticsQuery.model_validate(request.args.to_dict(flat=True))
|
||||
timezone = current_user.timezone or "UTC"
|
||||
start, end = _parse_observability_time_range(query.start, query.end, current_user)
|
||||
try:
|
||||
payload = _agent_observability_service().get_statistics_summary(
|
||||
app=app_model,
|
||||
params=AgentStatisticsQueryParams(source=query.source, start=start, end=end, timezone=timezone),
|
||||
)
|
||||
except ValueError as exc:
|
||||
abort(400, description=str(exc))
|
||||
return dump_response(AgentStatisticSummaryEnvelopeResponse, payload)
|
||||
|
||||
|
||||
@console_ns.route("/agent/<uuid:agent_id>/versions")
|
||||
class AgentRosterVersionsApi(Resource):
|
||||
@console_ns.response(200, "Agent versions", console_ns.models[AgentConfigSnapshotListResponse.__name__])
|
||||
|
||||
@ -1,10 +1,8 @@
|
||||
from datetime import datetime
|
||||
from typing import Annotated, Literal
|
||||
|
||||
from pydantic import Field, field_validator
|
||||
from pydantic import Field
|
||||
|
||||
from fields.base import ResponseModel
|
||||
from libs.helper import to_timestamp
|
||||
from models.agent import (
|
||||
AgentConfigRevisionOperation,
|
||||
AgentIconType,
|
||||
@ -107,114 +105,6 @@ class AgentInviteOptionsResponse(ResponseModel):
|
||||
has_more: bool
|
||||
|
||||
|
||||
class AgentLogItemResponse(ResponseModel):
|
||||
id: str
|
||||
message_id: str
|
||||
conversation_id: str
|
||||
conversation_name: str | None = None
|
||||
query: str
|
||||
answer: str
|
||||
status: str
|
||||
error: str | None = None
|
||||
source: str | None = None
|
||||
from_source: str | None = None
|
||||
from_end_user_id: str | None = None
|
||||
from_account_id: str | None = None
|
||||
message_tokens: int
|
||||
answer_tokens: int
|
||||
total_tokens: int
|
||||
total_price: str
|
||||
currency: str
|
||||
latency: float
|
||||
created_at: int | None = None
|
||||
updated_at: int | None = None
|
||||
|
||||
@field_validator("created_at", "updated_at", mode="before")
|
||||
@classmethod
|
||||
def _normalize_timestamp(cls, value: datetime | int | None) -> int | None:
|
||||
return to_timestamp(value)
|
||||
|
||||
|
||||
class AgentLogListResponse(ResponseModel):
|
||||
data: list[AgentLogItemResponse]
|
||||
page: int
|
||||
limit: int
|
||||
total: int
|
||||
has_more: bool
|
||||
|
||||
|
||||
class AgentStatisticSummaryResponse(ResponseModel):
|
||||
total_messages: int
|
||||
total_conversations: int
|
||||
total_end_users: int
|
||||
total_tokens: int
|
||||
total_price: str
|
||||
currency: str
|
||||
average_session_interactions: float
|
||||
average_response_time: float
|
||||
tokens_per_second: float
|
||||
user_satisfaction_rate: float
|
||||
|
||||
|
||||
class AgentDailyMessageStatisticResponse(ResponseModel):
|
||||
date: str
|
||||
message_count: int
|
||||
|
||||
|
||||
class AgentDailyConversationStatisticResponse(ResponseModel):
|
||||
date: str
|
||||
conversation_count: int
|
||||
|
||||
|
||||
class AgentDailyEndUserStatisticResponse(ResponseModel):
|
||||
date: str
|
||||
terminal_count: int
|
||||
|
||||
|
||||
class AgentTokenUsageStatisticResponse(ResponseModel):
|
||||
date: str
|
||||
token_count: int
|
||||
total_price: str
|
||||
currency: str
|
||||
|
||||
|
||||
class AgentAverageSessionInteractionStatisticResponse(ResponseModel):
|
||||
date: str
|
||||
interactions: float
|
||||
|
||||
|
||||
class AgentAverageResponseTimeStatisticResponse(ResponseModel):
|
||||
date: str
|
||||
latency: float
|
||||
|
||||
|
||||
class AgentTokensPerSecondStatisticResponse(ResponseModel):
|
||||
date: str
|
||||
tps: float
|
||||
|
||||
|
||||
class AgentUserSatisfactionRateStatisticResponse(ResponseModel):
|
||||
date: str
|
||||
rate: float
|
||||
|
||||
|
||||
class AgentStatisticChartsResponse(ResponseModel):
|
||||
daily_messages: list[AgentDailyMessageStatisticResponse] = Field(default_factory=list)
|
||||
daily_conversations: list[AgentDailyConversationStatisticResponse] = Field(default_factory=list)
|
||||
daily_end_users: list[AgentDailyEndUserStatisticResponse] = Field(default_factory=list)
|
||||
token_usage: list[AgentTokenUsageStatisticResponse] = Field(default_factory=list)
|
||||
average_session_interactions: list[AgentAverageSessionInteractionStatisticResponse] = Field(default_factory=list)
|
||||
average_response_time: list[AgentAverageResponseTimeStatisticResponse] = Field(default_factory=list)
|
||||
tokens_per_second: list[AgentTokensPerSecondStatisticResponse] = Field(default_factory=list)
|
||||
user_satisfaction_rate: list[AgentUserSatisfactionRateStatisticResponse] = Field(default_factory=list)
|
||||
|
||||
|
||||
class AgentStatisticSummaryEnvelopeResponse(ResponseModel):
|
||||
source: str
|
||||
summary: AgentStatisticSummaryResponse
|
||||
charts: AgentStatisticChartsResponse
|
||||
|
||||
|
||||
class AgentConfigRevisionResponse(ResponseModel):
|
||||
id: str
|
||||
previous_snapshot_id: str | None = None
|
||||
|
||||
@ -638,26 +638,6 @@ Commit an uploaded file into the Agent App drive under files/<name>
|
||||
| ---- | ----------- | ------ |
|
||||
| 201 | File committed into the agent drive | **application/json**: [AgentDriveFileCommitResponse](#agentdrivefilecommitresponse)<br> |
|
||||
|
||||
### [GET] /agent/{agent_id}/logs
|
||||
#### Parameters
|
||||
|
||||
| Name | Located in | Description | Required | Schema |
|
||||
| ---- | ---------- | ----------- | -------- | ------ |
|
||||
| end | query | End date (YYYY-MM-DD HH:MM) | No | string |
|
||||
| keyword | query | Search query, answer, or conversation name | No | string |
|
||||
| limit | query | Page size | No | integer, <br>**Default:** 20 |
|
||||
| page | query | Page number | No | integer, <br>**Default:** 1 |
|
||||
| source | query | Filter by all, console/explore, api/service-api, web-app, debugger, openapi, or trigger | No | string |
|
||||
| start | query | Start date (YYYY-MM-DD HH:MM) | No | string |
|
||||
| status | query | Filter by success, failed, or paused | No | string |
|
||||
| agent_id | path | | Yes | string |
|
||||
|
||||
#### Responses
|
||||
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Agent logs | **application/json**: [AgentLogListResponse](#agentloglistresponse)<br> |
|
||||
|
||||
### [GET] /agent/{agent_id}/messages/{message_id}
|
||||
Get Agent App message details by ID
|
||||
|
||||
@ -810,22 +790,6 @@ Infer CLI tool + ENV suggestions from a standardized Agent App skill
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Inference result (draft suggestions, nothing persisted) | **application/json**: [SkillToolInferenceResult](#skilltoolinferenceresult)<br> |
|
||||
|
||||
### [GET] /agent/{agent_id}/statistics/summary
|
||||
#### Parameters
|
||||
|
||||
| Name | Located in | Description | Required | Schema |
|
||||
| ---- | ---------- | ----------- | -------- | ------ |
|
||||
| end | query | End date (YYYY-MM-DD HH:MM) | No | string |
|
||||
| source | query | Filter by all, console/explore, api/service-api, web-app, debugger, openapi, or trigger | No | string |
|
||||
| start | query | Start date (YYYY-MM-DD HH:MM) | No | string |
|
||||
| agent_id | path | | Yes | string |
|
||||
|
||||
#### Responses
|
||||
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Agent monitoring summary and chart data | **application/json**: [AgentStatisticSummaryEnvelopeResponse](#agentstatisticsummaryenveloperesponse)<br> |
|
||||
|
||||
### [GET] /agent/{agent_id}/versions
|
||||
#### Parameters
|
||||
|
||||
@ -11354,20 +11318,6 @@ default (the config form sends the full desired feature state on save).
|
||||
| role | string | Agent role | No |
|
||||
| use_icon_as_answer_icon | boolean | Use icon as answer icon | No |
|
||||
|
||||
#### AgentAverageResponseTimeStatisticResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| date | string | | Yes |
|
||||
| latency | number | | Yes |
|
||||
|
||||
#### AgentAverageSessionInteractionStatisticResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| date | string | | Yes |
|
||||
| interactions | number | | Yes |
|
||||
|
||||
#### AgentCliToolAuthorizationStatus
|
||||
|
||||
Authorization state for Agent-scoped CLI tools.
|
||||
@ -11608,27 +11558,6 @@ Audit operation recorded for Agent Soul version/revision changes.
|
||||
| version | integer | | Yes |
|
||||
| version_note | string | | No |
|
||||
|
||||
#### AgentDailyConversationStatisticResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| conversation_count | integer | | Yes |
|
||||
| date | string | | Yes |
|
||||
|
||||
#### AgentDailyEndUserStatisticResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| date | string | | Yes |
|
||||
| terminal_count | integer | | Yes |
|
||||
|
||||
#### AgentDailyMessageStatisticResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| date | string | | Yes |
|
||||
| message_count | integer | | Yes |
|
||||
|
||||
#### AgentDriveDeleteFileByAgentQuery
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
@ -11868,41 +11797,6 @@ the current roster/workflow APIs scoped to Dify Agent.
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| AgentKnowledgeQueryMode | string | | |
|
||||
|
||||
#### AgentLogItemResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| answer | string | | Yes |
|
||||
| answer_tokens | integer | | Yes |
|
||||
| conversation_id | string | | Yes |
|
||||
| conversation_name | string | | No |
|
||||
| created_at | integer | | No |
|
||||
| currency | string | | Yes |
|
||||
| error | string | | No |
|
||||
| from_account_id | string | | No |
|
||||
| from_end_user_id | string | | No |
|
||||
| from_source | string | | No |
|
||||
| id | string | | Yes |
|
||||
| latency | number | | Yes |
|
||||
| message_id | string | | Yes |
|
||||
| message_tokens | integer | | Yes |
|
||||
| query | string | | Yes |
|
||||
| source | string | | No |
|
||||
| status | string | | Yes |
|
||||
| total_price | string | | Yes |
|
||||
| total_tokens | integer | | Yes |
|
||||
| updated_at | integer | | No |
|
||||
|
||||
#### AgentLogListResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| data | [ [AgentLogItemResponse](#agentlogitemresponse) ] | | Yes |
|
||||
| has_more | boolean | | Yes |
|
||||
| limit | integer | | Yes |
|
||||
| page | integer | | Yes |
|
||||
| total | integer | | Yes |
|
||||
|
||||
#### AgentLogMetaResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
@ -11930,18 +11824,6 @@ the current roster/workflow APIs scoped to Dify Agent.
|
||||
| iterations | [ [AgentIterationLogResponse](#agentiterationlogresponse) ] | | Yes |
|
||||
| meta | [AgentLogMetaResponse](#agentlogmetaresponse) | | Yes |
|
||||
|
||||
#### AgentLogsQuery
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| end | string | End date (YYYY-MM-DD HH:MM) | No |
|
||||
| keyword | string | Search query, answer, or conversation name | No |
|
||||
| limit | integer, <br>**Default:** 20 | Page size | No |
|
||||
| page | integer, <br>**Default:** 1 | Page number | No |
|
||||
| source | string | Filter by all, console/explore, api/service-api, web-app, debugger, openapi, or trigger | No |
|
||||
| start | string | Start date (YYYY-MM-DD HH:MM) | No |
|
||||
| status | string | Filter by success, failed, or paused | No |
|
||||
|
||||
#### AgentMemoryArtifactConfig
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
@ -12315,50 +12197,6 @@ Origin that created or imported the Agent.
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| AgentSource | string | Origin that created or imported the Agent. | |
|
||||
|
||||
#### AgentStatisticChartsResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| average_response_time | [ [AgentAverageResponseTimeStatisticResponse](#agentaverageresponsetimestatisticresponse) ] | | No |
|
||||
| average_session_interactions | [ [AgentAverageSessionInteractionStatisticResponse](#agentaveragesessioninteractionstatisticresponse) ] | | No |
|
||||
| daily_conversations | [ [AgentDailyConversationStatisticResponse](#agentdailyconversationstatisticresponse) ] | | No |
|
||||
| daily_end_users | [ [AgentDailyEndUserStatisticResponse](#agentdailyenduserstatisticresponse) ] | | No |
|
||||
| daily_messages | [ [AgentDailyMessageStatisticResponse](#agentdailymessagestatisticresponse) ] | | No |
|
||||
| token_usage | [ [AgentTokenUsageStatisticResponse](#agenttokenusagestatisticresponse) ] | | No |
|
||||
| tokens_per_second | [ [AgentTokensPerSecondStatisticResponse](#agenttokenspersecondstatisticresponse) ] | | No |
|
||||
| user_satisfaction_rate | [ [AgentUserSatisfactionRateStatisticResponse](#agentusersatisfactionratestatisticresponse) ] | | No |
|
||||
|
||||
#### AgentStatisticSummaryEnvelopeResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| charts | [AgentStatisticChartsResponse](#agentstatisticchartsresponse) | | Yes |
|
||||
| source | string | | Yes |
|
||||
| summary | [AgentStatisticSummaryResponse](#agentstatisticsummaryresponse) | | Yes |
|
||||
|
||||
#### AgentStatisticSummaryResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| average_response_time | number | | Yes |
|
||||
| average_session_interactions | number | | Yes |
|
||||
| currency | string | | Yes |
|
||||
| tokens_per_second | number | | Yes |
|
||||
| total_conversations | integer | | Yes |
|
||||
| total_end_users | integer | | Yes |
|
||||
| total_messages | integer | | Yes |
|
||||
| total_price | string | | Yes |
|
||||
| total_tokens | integer | | Yes |
|
||||
| user_satisfaction_rate | number | | Yes |
|
||||
|
||||
#### AgentStatisticsQuery
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| end | string | End date (YYYY-MM-DD HH:MM) | No |
|
||||
| source | string | Filter by all, console/explore, api/service-api, web-app, debugger, openapi, or trigger | No |
|
||||
| start | string | Start date (YYYY-MM-DD HH:MM) | No |
|
||||
|
||||
#### AgentStatus
|
||||
|
||||
Soft lifecycle state for Agent records.
|
||||
@ -12401,22 +12239,6 @@ Soft lifecycle state for Agent records.
|
||||
| tool_input | string | | No |
|
||||
| tool_labels | [JSONValue](#jsonvalue) | | Yes |
|
||||
|
||||
#### AgentTokenUsageStatisticResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| currency | string | | Yes |
|
||||
| date | string | | Yes |
|
||||
| token_count | integer | | Yes |
|
||||
| total_price | string | | Yes |
|
||||
|
||||
#### AgentTokensPerSecondStatisticResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| date | string | | Yes |
|
||||
| tps | number | | Yes |
|
||||
|
||||
#### AgentToolCallResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
@ -12431,13 +12253,6 @@ Soft lifecycle state for Agent records.
|
||||
| tool_output | object | | Yes |
|
||||
| tool_parameters | object | | Yes |
|
||||
|
||||
#### AgentUserSatisfactionRateStatisticResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| date | string | | Yes |
|
||||
| rate | number | | Yes |
|
||||
|
||||
#### AllowedExtensionsResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
|
||||
@ -35,7 +35,6 @@ Example:
|
||||
"""
|
||||
|
||||
from collections.abc import Callable, Sequence
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Protocol, TypedDict
|
||||
|
||||
@ -66,21 +65,6 @@ 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.
|
||||
@ -302,36 +286,6 @@ 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,
|
||||
@ -416,19 +370,6 @@ 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,
|
||||
@ -476,19 +417,6 @@ 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,11 +44,7 @@ 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,
|
||||
WorkflowRunCleanupRef,
|
||||
)
|
||||
from repositories.api_workflow_run_repository import APIWorkflowRunRepository, RunsWithRelatedCountsDict
|
||||
from repositories.entities.workflow_pause import WorkflowPauseEntity
|
||||
from repositories.types import (
|
||||
AverageInteractionStats,
|
||||
@ -424,71 +420,6 @@ 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,
|
||||
@ -599,56 +530,6 @@ 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,
|
||||
@ -830,72 +711,6 @@ 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,
|
||||
|
||||
@ -1,300 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import func, or_, select
|
||||
|
||||
from core.app.entities.app_invoke_entities import InvokeFrom
|
||||
from libs.helper import convert_datetime_to_date, escape_like_pattern, to_timestamp
|
||||
from models.enums import MessageStatus
|
||||
from models.model import App, Conversation, Message
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AgentLogQueryParams:
|
||||
page: int = 1
|
||||
limit: int = 20
|
||||
keyword: str | None = None
|
||||
status: str | None = None
|
||||
source: str | None = None
|
||||
start: datetime | None = None
|
||||
end: datetime | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AgentStatisticsQueryParams:
|
||||
source: str | None = None
|
||||
start: datetime | None = None
|
||||
end: datetime | None = None
|
||||
timezone: str = "UTC"
|
||||
|
||||
|
||||
class AgentObservabilityService:
|
||||
_SOURCE_ALIASES: dict[str, InvokeFrom] = {
|
||||
"api": InvokeFrom.SERVICE_API,
|
||||
"service-api": InvokeFrom.SERVICE_API,
|
||||
"service_api": InvokeFrom.SERVICE_API,
|
||||
"console": InvokeFrom.EXPLORE,
|
||||
"explore": InvokeFrom.EXPLORE,
|
||||
"explore-app": InvokeFrom.EXPLORE,
|
||||
"explore_app": InvokeFrom.EXPLORE,
|
||||
"web": InvokeFrom.WEB_APP,
|
||||
"web-app": InvokeFrom.WEB_APP,
|
||||
"web_app": InvokeFrom.WEB_APP,
|
||||
"debugger": InvokeFrom.DEBUGGER,
|
||||
"dev": InvokeFrom.DEBUGGER,
|
||||
"openapi": InvokeFrom.OPENAPI,
|
||||
"trigger": InvokeFrom.TRIGGER,
|
||||
}
|
||||
|
||||
def __init__(self, session: Any):
|
||||
self._session = session
|
||||
|
||||
@classmethod
|
||||
def resolve_source(cls, source: str | None) -> InvokeFrom | None:
|
||||
if not source or source == "all":
|
||||
return None
|
||||
normalized = source.strip().lower()
|
||||
if not normalized or normalized == "all":
|
||||
return None
|
||||
try:
|
||||
return cls._SOURCE_ALIASES[normalized]
|
||||
except KeyError as exc:
|
||||
raise ValueError(f"Unsupported source: {source}") from exc
|
||||
|
||||
@staticmethod
|
||||
def _message_status(message: Message) -> str:
|
||||
if message.error or message.status == MessageStatus.ERROR:
|
||||
return "failed"
|
||||
if message.status == MessageStatus.PAUSED:
|
||||
return "paused"
|
||||
return "success"
|
||||
|
||||
@staticmethod
|
||||
def _total_tokens(message: Message) -> int:
|
||||
return int(message.message_tokens or 0) + int(message.answer_tokens or 0)
|
||||
|
||||
@classmethod
|
||||
def serialize_log_message(cls, message: Message, conversation: Conversation | None = None) -> dict[str, Any]:
|
||||
invoke_from = message.invoke_from.value if message.invoke_from else None
|
||||
return {
|
||||
"id": message.id,
|
||||
"message_id": message.id,
|
||||
"conversation_id": message.conversation_id,
|
||||
"conversation_name": conversation.name if conversation else None,
|
||||
"query": message.query,
|
||||
"answer": message.answer,
|
||||
"status": cls._message_status(message),
|
||||
"error": message.error,
|
||||
"source": invoke_from,
|
||||
"from_source": message.from_source.value if message.from_source else None,
|
||||
"from_end_user_id": message.from_end_user_id,
|
||||
"from_account_id": message.from_account_id,
|
||||
"message_tokens": int(message.message_tokens or 0),
|
||||
"answer_tokens": int(message.answer_tokens or 0),
|
||||
"total_tokens": cls._total_tokens(message),
|
||||
"total_price": str(message.total_price or Decimal(0)),
|
||||
"currency": message.currency,
|
||||
"latency": float(message.provider_response_latency or 0),
|
||||
"created_at": to_timestamp(message.created_at),
|
||||
"updated_at": to_timestamp(message.updated_at),
|
||||
}
|
||||
|
||||
def list_logs(self, *, app: App, params: AgentLogQueryParams) -> dict[str, Any]:
|
||||
source = self.resolve_source(params.source)
|
||||
stmt = (
|
||||
select(Message, Conversation)
|
||||
.join(Conversation, Conversation.id == Message.conversation_id)
|
||||
.where(Message.app_id == app.id, Conversation.app_id == app.id)
|
||||
)
|
||||
stmt = self._apply_source_filter(stmt, source)
|
||||
|
||||
if params.start:
|
||||
stmt = stmt.where(Message.created_at >= params.start)
|
||||
if params.end:
|
||||
stmt = stmt.where(Message.created_at < params.end)
|
||||
if params.keyword:
|
||||
escaped_keyword = escape_like_pattern(params.keyword)
|
||||
pattern = f"%{escaped_keyword}%"
|
||||
stmt = stmt.where(
|
||||
or_(
|
||||
Message.query.ilike(pattern, escape="\\"),
|
||||
Message.answer.ilike(pattern, escape="\\"),
|
||||
Conversation.name.ilike(pattern, escape="\\"),
|
||||
)
|
||||
)
|
||||
if params.status:
|
||||
stmt = self._apply_status_filter(stmt, params.status)
|
||||
|
||||
total = self._session.scalar(select(func.count()).select_from(stmt.subquery())) or 0
|
||||
rows = list(
|
||||
self._session.execute(
|
||||
stmt.order_by(Message.created_at.desc(), Message.id.desc())
|
||||
.offset((params.page - 1) * params.limit)
|
||||
.limit(params.limit)
|
||||
).all()
|
||||
)
|
||||
data = []
|
||||
for message, conversation in rows:
|
||||
data.append(self.serialize_log_message(message, conversation))
|
||||
return {
|
||||
"data": data,
|
||||
"page": params.page,
|
||||
"limit": params.limit,
|
||||
"total": total,
|
||||
"has_more": params.page * params.limit < total,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def _apply_source_filter(cls, stmt, source: InvokeFrom | None):
|
||||
if source is None:
|
||||
return stmt.where(Message.invoke_from != InvokeFrom.DEBUGGER)
|
||||
return stmt.where(Message.invoke_from == source)
|
||||
|
||||
@staticmethod
|
||||
def _apply_status_filter(stmt, status: str):
|
||||
normalized = status.strip().lower()
|
||||
if normalized in {"success", "normal"}:
|
||||
return stmt.where(Message.error.is_(None), Message.status == MessageStatus.NORMAL)
|
||||
if normalized in {"failed", "error"}:
|
||||
return stmt.where(or_(Message.error.is_not(None), Message.status == MessageStatus.ERROR))
|
||||
if normalized == "paused":
|
||||
return stmt.where(Message.status == MessageStatus.PAUSED)
|
||||
raise ValueError(f"Unsupported status: {status}")
|
||||
|
||||
def get_statistics_summary(self, *, app: App, params: AgentStatisticsQueryParams) -> dict[str, Any]:
|
||||
source = self.resolve_source(params.source)
|
||||
rows = self._load_daily_statistics(app=app, params=params, source=source)
|
||||
charts = self._build_charts(rows)
|
||||
summary = self._build_summary(rows)
|
||||
return {
|
||||
"source": source.value if source else "all",
|
||||
"summary": summary,
|
||||
"charts": charts,
|
||||
}
|
||||
|
||||
def _load_daily_statistics(
|
||||
self, *, app: App, params: AgentStatisticsQueryParams, source: InvokeFrom | None
|
||||
) -> list[dict[str, Any]]:
|
||||
converted_created_at = convert_datetime_to_date("m.created_at")
|
||||
source_condition = "AND m.invoke_from != :debugger" if source is None else "AND m.invoke_from = :source"
|
||||
sql_query = f"""SELECT
|
||||
{converted_created_at} AS date,
|
||||
COUNT(m.id) AS message_count,
|
||||
COUNT(DISTINCT m.conversation_id) AS conversation_count,
|
||||
COUNT(DISTINCT m.from_end_user_id) AS end_user_count,
|
||||
COALESCE(SUM(COALESCE(m.message_tokens, 0) + COALESCE(m.answer_tokens, 0)), 0) AS token_count,
|
||||
COALESCE(SUM(COALESCE(m.total_price, 0)), 0) AS total_price,
|
||||
COALESCE(AVG(m.provider_response_latency), 0) AS avg_latency,
|
||||
COALESCE(SUM(m.provider_response_latency), 0) AS latency_sum,
|
||||
COALESCE(SUM(m.answer_tokens), 0) AS answer_tokens,
|
||||
COUNT(mf.id) AS like_count
|
||||
FROM messages m
|
||||
LEFT JOIN message_feedbacks mf
|
||||
ON mf.message_id = m.id AND mf.rating = 'like'
|
||||
WHERE
|
||||
m.app_id = :app_id
|
||||
{source_condition}"""
|
||||
args: dict[str, Any] = {
|
||||
"tz": params.timezone,
|
||||
"app_id": app.id,
|
||||
"debugger": InvokeFrom.DEBUGGER,
|
||||
}
|
||||
if source is not None:
|
||||
args["source"] = source
|
||||
if params.start:
|
||||
sql_query += " AND m.created_at >= :start"
|
||||
args["start"] = params.start
|
||||
if params.end:
|
||||
sql_query += " AND m.created_at < :end"
|
||||
args["end"] = params.end
|
||||
sql_query += " GROUP BY date ORDER BY date"
|
||||
|
||||
return [dict(row._mapping) for row in self._session.execute(sa.text(sql_query), args).all()]
|
||||
|
||||
@staticmethod
|
||||
def _build_charts(rows: list[dict[str, Any]]) -> dict[str, list[dict[str, Any]]]:
|
||||
messages = []
|
||||
conversations = []
|
||||
end_users = []
|
||||
token_usage = []
|
||||
average_session_interactions = []
|
||||
average_response_time = []
|
||||
tokens_per_second = []
|
||||
user_satisfaction_rate = []
|
||||
|
||||
for row in rows:
|
||||
date = str(row["date"])
|
||||
message_count = int(row["message_count"] or 0)
|
||||
conversation_count = int(row["conversation_count"] or 0)
|
||||
token_count = int(row["token_count"] or 0)
|
||||
total_price = row["total_price"] or Decimal(0)
|
||||
avg_latency = float(row["avg_latency"] or 0)
|
||||
latency_sum = float(row["latency_sum"] or 0)
|
||||
answer_tokens = int(row["answer_tokens"] or 0)
|
||||
like_count = int(row["like_count"] or 0)
|
||||
|
||||
messages.append({"date": date, "message_count": message_count})
|
||||
conversations.append({"date": date, "conversation_count": conversation_count})
|
||||
end_users.append({"date": date, "terminal_count": int(row["end_user_count"] or 0)})
|
||||
token_usage.append(
|
||||
{
|
||||
"date": date,
|
||||
"token_count": token_count,
|
||||
"total_price": str(total_price),
|
||||
"currency": "USD",
|
||||
}
|
||||
)
|
||||
average_session_interactions.append(
|
||||
{
|
||||
"date": date,
|
||||
"interactions": round(message_count / conversation_count, 2) if conversation_count else 0,
|
||||
}
|
||||
)
|
||||
average_response_time.append({"date": date, "latency": round(avg_latency * 1000, 4)})
|
||||
tokens_per_second.append({"date": date, "tps": round(answer_tokens / latency_sum, 4) if latency_sum else 0})
|
||||
user_satisfaction_rate.append(
|
||||
{"date": date, "rate": round(like_count * 100 / message_count, 2) if message_count else 0}
|
||||
)
|
||||
|
||||
return {
|
||||
"daily_messages": messages,
|
||||
"daily_conversations": conversations,
|
||||
"daily_end_users": end_users,
|
||||
"token_usage": token_usage,
|
||||
"average_session_interactions": average_session_interactions,
|
||||
"average_response_time": average_response_time,
|
||||
"tokens_per_second": tokens_per_second,
|
||||
"user_satisfaction_rate": user_satisfaction_rate,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _build_summary(rows: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
total_messages = sum(int(row["message_count"] or 0) for row in rows)
|
||||
total_conversations = sum(int(row["conversation_count"] or 0) for row in rows)
|
||||
total_end_users = sum(int(row["end_user_count"] or 0) for row in rows)
|
||||
total_tokens = sum(int(row["token_count"] or 0) for row in rows)
|
||||
total_price = sum(Decimal(str(row["total_price"] or 0)) for row in rows)
|
||||
total_answer_tokens = sum(int(row["answer_tokens"] or 0) for row in rows)
|
||||
total_latency = sum(float(row["latency_sum"] or 0) for row in rows)
|
||||
weighted_latency = sum(float(row["avg_latency"] or 0) * int(row["message_count"] or 0) for row in rows)
|
||||
total_likes = sum(int(row["like_count"] or 0) for row in rows)
|
||||
|
||||
return {
|
||||
"total_messages": total_messages,
|
||||
"total_conversations": total_conversations,
|
||||
"total_end_users": total_end_users,
|
||||
"total_tokens": total_tokens,
|
||||
"total_price": str(total_price),
|
||||
"currency": "USD",
|
||||
"average_session_interactions": round(total_messages / total_conversations, 2)
|
||||
if total_conversations
|
||||
else 0,
|
||||
"average_response_time": round((weighted_latency / total_messages) * 1000, 4) if total_messages else 0,
|
||||
"tokens_per_second": round(total_answer_tokens / total_latency, 4) if total_latency else 0,
|
||||
"user_satisfaction_rate": round(total_likes * 100 / total_messages, 2) if total_messages else 0,
|
||||
}
|
||||
@ -1,10 +1,3 @@
|
||||
"""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
|
||||
@ -18,11 +11,8 @@ from sqlalchemy.orm import Session, sessionmaker
|
||||
from configs import dify_config
|
||||
from enums.cloud_plan import CloudPlan
|
||||
from extensions.ext_database import db
|
||||
from repositories.api_workflow_run_repository import (
|
||||
APIWorkflowRunRepository,
|
||||
RunsWithRelatedCountsDict,
|
||||
WorkflowRunCleanupRef,
|
||||
)
|
||||
from models.workflow import WorkflowRun
|
||||
from repositories.api_workflow_run_repository import APIWorkflowRunRepository, RunsWithRelatedCountsDict
|
||||
from repositories.factory import DifyAPIRepositoryFactory
|
||||
from repositories.sqlalchemy_workflow_trigger_log_repository import SQLAlchemyWorkflowTriggerLogRepository
|
||||
from services.billing_service import BillingService, SubscriptionPlan
|
||||
@ -196,13 +186,6 @@ _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,
|
||||
@ -271,28 +254,26 @@ class WorkflowRunCleanup:
|
||||
batch_start = time.monotonic()
|
||||
|
||||
fetch_start = time.monotonic()
|
||||
candidate_last_seen = last_seen
|
||||
candidate_refs = self.workflow_run_repo.get_cleanup_refs_batch_by_time_range(
|
||||
run_rows = self.workflow_run_repo.get_runs_batch_by_time_range(
|
||||
start_from=self.window_start,
|
||||
end_before=self.window_end,
|
||||
last_seen=candidate_last_seen,
|
||||
last_seen=last_seen,
|
||||
batch_size=self.batch_size,
|
||||
)
|
||||
if not candidate_refs:
|
||||
if not run_rows:
|
||||
logger.info("workflow_run_cleanup (batch #%s): no more rows to process", batch_index + 1)
|
||||
break
|
||||
|
||||
batch_index += 1
|
||||
candidate_high_water = self._cursor_from_ref(candidate_refs[-1])
|
||||
last_seen = candidate_high_water
|
||||
last_seen = (run_rows[-1].created_at, run_rows[-1].id)
|
||||
logger.info(
|
||||
"workflow_run_cleanup (batch #%s): fetched %s candidate refs in %sms",
|
||||
"workflow_run_cleanup (batch #%s): fetched %s rows in %sms",
|
||||
batch_index,
|
||||
len(candidate_refs),
|
||||
len(run_rows),
|
||||
int((time.monotonic() - fetch_start) * 1000),
|
||||
)
|
||||
|
||||
tenant_ids = {ref.tenant_id for ref in candidate_refs}
|
||||
tenant_ids = {row.tenant_id for row in run_rows}
|
||||
|
||||
filter_start = time.monotonic()
|
||||
free_tenants = self._filter_free_tenants(tenant_ids)
|
||||
@ -304,28 +285,10 @@ class WorkflowRunCleanup:
|
||||
int((time.monotonic() - filter_start) * 1000),
|
||||
)
|
||||
|
||||
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),
|
||||
)
|
||||
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_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:
|
||||
if not free_runs:
|
||||
skipped_message = (
|
||||
f"[batch #{batch_index}] skipped (no sandbox runs in batch, {paid_or_skipped} paid/unknown)"
|
||||
)
|
||||
@ -336,7 +299,7 @@ class WorkflowRunCleanup:
|
||||
)
|
||||
)
|
||||
self._metrics.record_batch(
|
||||
batch_rows=len(candidate_refs),
|
||||
batch_rows=len(run_rows),
|
||||
targeted_runs=0,
|
||||
skipped_runs=paid_or_skipped,
|
||||
deleted_runs=0,
|
||||
@ -346,13 +309,13 @@ class WorkflowRunCleanup:
|
||||
)
|
||||
continue
|
||||
|
||||
total_runs_targeted += len(target_run_ids)
|
||||
total_runs_targeted += len(free_runs)
|
||||
|
||||
if self.dry_run:
|
||||
count_start = time.monotonic()
|
||||
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,
|
||||
batch_counts = self.workflow_run_repo.count_runs_with_related(
|
||||
free_runs,
|
||||
count_node_executions=self._count_node_executions,
|
||||
count_trigger_logs=self._count_trigger_logs,
|
||||
)
|
||||
logger.info(
|
||||
@ -362,10 +325,10 @@ class WorkflowRunCleanup:
|
||||
)
|
||||
if related_totals is not None:
|
||||
self._accumulate_related_counts(related_totals, batch_counts)
|
||||
sample_ids = ", ".join(target_run_ids[:5])
|
||||
sample_ids = ", ".join(run.id for run in free_runs[:5])
|
||||
click.echo(
|
||||
click.style(
|
||||
f"[batch #{batch_index}] would delete {len(target_run_ids)} runs "
|
||||
f"[batch #{batch_index}] would delete {len(free_runs)} runs "
|
||||
f"(sample ids: {sample_ids}) and skip {paid_or_skipped} paid/unknown",
|
||||
fg="yellow",
|
||||
)
|
||||
@ -376,8 +339,8 @@ class WorkflowRunCleanup:
|
||||
int((time.monotonic() - batch_start) * 1000),
|
||||
)
|
||||
self._metrics.record_batch(
|
||||
batch_rows=len(candidate_refs),
|
||||
targeted_runs=len(target_run_ids),
|
||||
batch_rows=len(run_rows),
|
||||
targeted_runs=len(free_runs),
|
||||
skipped_runs=paid_or_skipped,
|
||||
deleted_runs=0,
|
||||
related_counts={
|
||||
@ -391,14 +354,14 @@ class WorkflowRunCleanup:
|
||||
|
||||
try:
|
||||
delete_start = time.monotonic()
|
||||
counts = self.workflow_run_repo.delete_runs_with_related_by_ids(
|
||||
target_run_ids,
|
||||
delete_node_executions=self._delete_node_executions_by_run_ids,
|
||||
counts = self.workflow_run_repo.delete_runs_with_related(
|
||||
free_runs,
|
||||
delete_node_executions=self._delete_node_executions,
|
||||
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", candidate_high_water[0])
|
||||
logger.exception("Failed to delete workflow runs batch ending at %s", last_seen[0])
|
||||
raise
|
||||
|
||||
total_runs_deleted += counts["runs"]
|
||||
@ -419,8 +382,8 @@ class WorkflowRunCleanup:
|
||||
int((time.monotonic() - batch_start) * 1000),
|
||||
)
|
||||
self._metrics.record_batch(
|
||||
batch_rows=len(candidate_refs),
|
||||
targeted_runs=len(target_run_ids),
|
||||
batch_rows=len(run_rows),
|
||||
targeted_runs=len(free_runs),
|
||||
skipped_runs=paid_or_skipped,
|
||||
deleted_runs=counts["runs"],
|
||||
related_counts={
|
||||
@ -476,7 +439,7 @@ class WorkflowRunCleanup:
|
||||
)
|
||||
|
||||
def _filter_free_tenants(self, tenant_ids: Iterable[str]) -> set[str]:
|
||||
tenant_id_list = sorted(set(tenant_ids))
|
||||
tenant_id_list = list(tenant_ids)
|
||||
|
||||
if not dify_config.BILLING_ENABLED:
|
||||
return set(tenant_id_list)
|
||||
@ -590,17 +553,15 @@ class WorkflowRunCleanup:
|
||||
totals["pauses"] += batch.get("pauses", 0)
|
||||
totals["pause_reasons"] += batch.get("pause_reasons", 0)
|
||||
|
||||
@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]:
|
||||
def _count_node_executions(self, session: Session, runs: Sequence[WorkflowRun]) -> tuple[int, int]:
|
||||
run_ids = [run.id for run in runs]
|
||||
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_by_run_ids(self, session: Session, run_ids: Sequence[str]) -> tuple[int, int]:
|
||||
def _delete_node_executions(self, session: Session, runs: Sequence[WorkflowRun]) -> tuple[int, int]:
|
||||
run_ids = [run.id for run in runs]
|
||||
repo = DifyAPIRepositoryFactory.create_api_workflow_node_execution_repository(
|
||||
session_maker=sessionmaker(bind=session.get_bind(), expire_on_commit=False)
|
||||
)
|
||||
|
||||
@ -1,320 +0,0 @@
|
||||
"""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, _scope: _TestScope, 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, scope, 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, scope, 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,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from unittest.mock import patch
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
@ -258,8 +259,9 @@ class TestEndUserServiceGetOrCreateEndUserByType:
|
||||
|
||||
assert len(matching_logs) == 1
|
||||
|
||||
@patch("services.end_user_service.logger")
|
||||
def test_get_existing_end_user_matching_type(
|
||||
self, db_session_with_containers: Session, factory: TestEndUserServiceFactory, caplog
|
||||
self, mock_logger, db_session_with_containers: Session, factory: TestEndUserServiceFactory
|
||||
):
|
||||
"""Test retrieving existing end user with matching type."""
|
||||
# Arrange
|
||||
@ -277,19 +279,17 @@ class TestEndUserServiceGetOrCreateEndUserByType:
|
||||
)
|
||||
|
||||
# Act - Request with same type
|
||||
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,
|
||||
)
|
||||
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
|
||||
# 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] == []
|
||||
mock_logger.info.assert_not_called()
|
||||
|
||||
def test_create_anonymous_user_with_default_session(
|
||||
self, db_session_with_containers: Session, factory: TestEndUserServiceFactory
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
import logging
|
||||
import uuid
|
||||
from unittest.mock import call, patch
|
||||
from unittest.mock import ANY, call, patch
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import delete, func, select
|
||||
@ -147,7 +146,10 @@ 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")
|
||||
def test_delete_draft_variables_batch_logs_progress(self, mock_offload_cleanup, db_session_with_containers, caplog):
|
||||
@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
|
||||
):
|
||||
"""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)
|
||||
@ -161,15 +163,14 @@ class TestDeleteDraftVariablesBatch:
|
||||
|
||||
mock_offload_cleanup.return_value = len(file_id_by_index)
|
||||
|
||||
with caplog.at_level(logging.INFO, logger="tasks.remove_app_and_related_data_task"):
|
||||
result = delete_draft_variables_batch(app.id, 50)
|
||||
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()}
|
||||
info_records = [record for record in caplog.records if record.levelno == logging.INFO]
|
||||
assert len(info_records) == 2
|
||||
assert mock_logger.info.call_count == 2
|
||||
mock_logger.info.assert_any_call(ANY)
|
||||
|
||||
|
||||
class TestDeleteDraftVariableOffloadData:
|
||||
@ -203,7 +204,10 @@ class TestDeleteDraftVariableOffloadData:
|
||||
assert remaining_upload_files_count == 0
|
||||
|
||||
@patch("extensions.ext_storage.storage")
|
||||
def test_delete_draft_variable_offload_data_storage_failure(self, mock_storage, db_session_with_containers, caplog):
|
||||
@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
|
||||
):
|
||||
"""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)
|
||||
@ -213,12 +217,11 @@ class TestDeleteDraftVariableOffloadData:
|
||||
|
||||
mock_storage.delete.side_effect = [Exception("Storage error"), None]
|
||||
|
||||
with caplog.at_level(logging.ERROR):
|
||||
with session_factory.create_session() as session, session.begin():
|
||||
result = _delete_draft_variable_offload_data(session, file_ids)
|
||||
with session_factory.create_session() as session, session.begin():
|
||||
result = _delete_draft_variable_offload_data(session, file_ids)
|
||||
|
||||
assert result == 1
|
||||
assert f"Failed to delete storage object {storage_keys[0]}" in caplog.text
|
||||
mock_logging.exception.assert_called_once_with("Failed to delete storage object %s", storage_keys[0])
|
||||
|
||||
remaining_var_files_count = db_session_with_containers.scalar(
|
||||
select(func.count())
|
||||
|
||||
@ -23,10 +23,8 @@ from controllers.console.agent.roster import (
|
||||
AgentAppApi,
|
||||
AgentAppListApi,
|
||||
AgentInviteOptionsApi,
|
||||
AgentLogsApi,
|
||||
AgentRosterVersionDetailApi,
|
||||
AgentRosterVersionsApi,
|
||||
AgentStatisticsSummaryApi,
|
||||
)
|
||||
from controllers.console.app import completion as completion_controller
|
||||
from controllers.console.app import message as message_controller
|
||||
@ -150,8 +148,6 @@ def test_agent_v2_console_routes_are_agent_id_first() -> None:
|
||||
"/agent/<uuid:agent_id>/feedbacks",
|
||||
"/agent/<uuid:agent_id>/chat-messages/<uuid:message_id>/suggested-questions",
|
||||
"/agent/<uuid:agent_id>/messages/<uuid:message_id>",
|
||||
"/agent/<uuid:agent_id>/logs",
|
||||
"/agent/<uuid:agent_id>/statistics/summary",
|
||||
"/agent/invite-options",
|
||||
):
|
||||
assert route in paths
|
||||
@ -375,108 +371,6 @@ def test_agent_versions_call_services(app: Flask, monkeypatch: pytest.MonkeyPatc
|
||||
assert version_detail["agent_id"] == agent_id
|
||||
|
||||
|
||||
def test_agent_observability_routes_resolve_app_from_agent_id(
|
||||
app: Flask, monkeypatch: pytest.MonkeyPatch, account_id: str
|
||||
) -> None:
|
||||
agent_id = "00000000-0000-0000-0000-000000000001"
|
||||
app_model = SimpleNamespace(id="app-1")
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
class FakeObservabilityService:
|
||||
def list_logs(self, *, app, params):
|
||||
captured["logs"] = {"app": app, "params": params}
|
||||
return {
|
||||
"data": [
|
||||
{
|
||||
"id": "message-1",
|
||||
"message_id": "message-1",
|
||||
"conversation_id": "conversation-1",
|
||||
"conversation_name": "Debug",
|
||||
"query": "hello",
|
||||
"answer": "hi",
|
||||
"status": "success",
|
||||
"error": None,
|
||||
"source": "explore",
|
||||
"from_source": "console",
|
||||
"from_end_user_id": None,
|
||||
"from_account_id": account_id,
|
||||
"message_tokens": 1,
|
||||
"answer_tokens": 2,
|
||||
"total_tokens": 3,
|
||||
"total_price": "0",
|
||||
"currency": "USD",
|
||||
"latency": 1.2,
|
||||
"created_at": 1,
|
||||
"updated_at": 2,
|
||||
}
|
||||
],
|
||||
"page": 2,
|
||||
"limit": 5,
|
||||
"total": 6,
|
||||
"has_more": False,
|
||||
}
|
||||
|
||||
def get_statistics_summary(self, *, app, params):
|
||||
captured["statistics"] = {"app": app, "params": params}
|
||||
return {
|
||||
"source": "all",
|
||||
"summary": {
|
||||
"total_messages": 1,
|
||||
"total_conversations": 1,
|
||||
"total_end_users": 1,
|
||||
"total_tokens": 3,
|
||||
"total_price": "0",
|
||||
"currency": "USD",
|
||||
"average_session_interactions": 1,
|
||||
"average_response_time": 1200,
|
||||
"tokens_per_second": 2,
|
||||
"user_satisfaction_rate": 100,
|
||||
},
|
||||
"charts": {
|
||||
"daily_messages": [{"date": "2026-06-17", "message_count": 1}],
|
||||
"daily_conversations": [{"date": "2026-06-17", "conversation_count": 1}],
|
||||
"daily_end_users": [{"date": "2026-06-17", "terminal_count": 1}],
|
||||
"token_usage": [{"date": "2026-06-17", "token_count": 3, "total_price": "0", "currency": "USD"}],
|
||||
"average_session_interactions": [{"date": "2026-06-17", "interactions": 1}],
|
||||
"average_response_time": [{"date": "2026-06-17", "latency": 1200}],
|
||||
"tokens_per_second": [{"date": "2026-06-17", "tps": 2}],
|
||||
"user_satisfaction_rate": [{"date": "2026-06-17", "rate": 100}],
|
||||
},
|
||||
}
|
||||
|
||||
monkeypatch.setattr(roster_controller, "_resolve_agent_app_model", lambda **kwargs: app_model)
|
||||
monkeypatch.setattr(roster_controller, "_agent_observability_service", lambda: FakeObservabilityService())
|
||||
|
||||
account = SimpleNamespace(id=account_id, timezone="UTC")
|
||||
with app.test_request_context(
|
||||
"/console/api/agent/00000000-0000-0000-0000-000000000001/logs"
|
||||
"?page=2&limit=5&keyword=hello&status=success&source=console"
|
||||
):
|
||||
logs = unwrap(AgentLogsApi.get)(AgentLogsApi(), "tenant-1", account, agent_id)
|
||||
|
||||
assert logs["data"][0]["id"] == "message-1"
|
||||
logs_call = cast(dict[str, object], captured["logs"])
|
||||
assert logs_call["app"] is app_model
|
||||
logs_params = cast(Any, logs_call["params"])
|
||||
assert logs_params.page == 2
|
||||
assert logs_params.limit == 5
|
||||
assert logs_params.keyword == "hello"
|
||||
assert logs_params.status == "success"
|
||||
assert logs_params.source == "console"
|
||||
|
||||
with app.test_request_context(
|
||||
"/console/api/agent/00000000-0000-0000-0000-000000000001/statistics/summary?source=api"
|
||||
):
|
||||
statistics = unwrap(AgentStatisticsSummaryApi.get)(AgentStatisticsSummaryApi(), "tenant-1", account, agent_id)
|
||||
|
||||
assert statistics["summary"]["total_messages"] == 1
|
||||
stats_call = cast(dict[str, object], captured["statistics"])
|
||||
assert stats_call["app"] is app_model
|
||||
stats_params = cast(Any, stats_call["params"])
|
||||
assert stats_params.source == "api"
|
||||
assert stats_params.timezone == "UTC"
|
||||
|
||||
|
||||
def test_workflow_composer_get_put_validate_candidates_impact_and_save(
|
||||
app: Flask, monkeypatch: pytest.MonkeyPatch, account_id: str
|
||||
) -> None:
|
||||
|
||||
@ -2,7 +2,6 @@
|
||||
Unit tests for Service API File Preview endpoint
|
||||
"""
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
@ -349,7 +348,8 @@ class TestFilePreviewApi:
|
||||
|
||||
assert "Storage error" in str(exc_info.value)
|
||||
|
||||
def test_validate_file_ownership_unexpected_error_logging(self, file_preview_api: FilePreviewApi, caplog):
|
||||
@patch("controllers.service_api.app.file_preview.logger")
|
||||
def test_validate_file_ownership_unexpected_error_logging(self, mock_logger, file_preview_api: FilePreviewApi):
|
||||
"""Test that unexpected errors are logged properly"""
|
||||
file_id = str(uuid.uuid4())
|
||||
app_id = str(uuid.uuid4())
|
||||
@ -359,18 +359,14 @@ class TestFilePreviewApi:
|
||||
mock_db.session.scalar.side_effect = Exception("Unexpected database error")
|
||||
|
||||
# Execute and assert exception
|
||||
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)
|
||||
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 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"
|
||||
# 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"},
|
||||
)
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
import logging
|
||||
import queue
|
||||
import time
|
||||
from concurrent.futures import Future, ThreadPoolExecutor
|
||||
@ -512,8 +511,10 @@ def test_receive_loop_http_error_unknown_id(streams):
|
||||
|
||||
|
||||
@pytest.mark.timeout(10)
|
||||
def test_receive_loop_validation_error_notification(streams, caplog):
|
||||
with caplog.at_level(logging.WARNING, logger="core.mcp.session.base_session"):
|
||||
def test_receive_loop_validation_error_notification(streams):
|
||||
from core.mcp.session.base_session import logger
|
||||
|
||||
with patch.object(logger, "warning") as mock_warning:
|
||||
read_stream, write_stream = streams
|
||||
session = MockSession(read_stream, write_stream, ReceiveRequest, RootModel[MockNotification])
|
||||
|
||||
@ -522,7 +523,7 @@ def test_receive_loop_validation_error_notification(streams, caplog):
|
||||
read_stream.put(SessionMessage(message=JSONRPCMessage.model_validate(notif_payload)))
|
||||
time.sleep(1.0)
|
||||
|
||||
assert "Failed to validate notification" in caplog.text
|
||||
assert mock_warning.called
|
||||
|
||||
|
||||
@pytest.mark.timeout(5)
|
||||
@ -570,16 +571,16 @@ def test_session_exit_timeout(streams):
|
||||
|
||||
|
||||
@pytest.mark.timeout(10)
|
||||
def test_receive_loop_fatal_exception(streams, caplog):
|
||||
def test_receive_loop_fatal_exception(streams):
|
||||
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 caplog.at_level(logging.ERROR, logger="core.mcp.session.base_session"):
|
||||
with patch("core.mcp.session.base_session.logger") as mock_logger:
|
||||
with pytest.raises(RuntimeError, match="Fatal loop error"):
|
||||
with session:
|
||||
pass
|
||||
assert "Error in message processing loop" in caplog.text
|
||||
mock_logger.exception.assert_called_with("Error in message processing loop")
|
||||
|
||||
|
||||
@pytest.mark.timeout(5)
|
||||
|
||||
@ -2,7 +2,6 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import UTC, datetime
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
@ -89,10 +88,11 @@ 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, caplog
|
||||
mock_metric_exporter: MagicMock, mock_span_exporter: MagicMock, mock_logger: MagicMock
|
||||
) -> None:
|
||||
"""Test that API key overrides conflicting authorization header and logs warning."""
|
||||
mock_config = SimpleNamespace(
|
||||
@ -105,8 +105,7 @@ def test_api_key_overrides_conflicting_header(
|
||||
ENTERPRISE_OTLP_API_KEY="test-key",
|
||||
)
|
||||
|
||||
with caplog.at_level(logging.WARNING, logger="enterprise.telemetry.exporter"):
|
||||
EnterpriseExporter(mock_config)
|
||||
EnterpriseExporter(mock_config)
|
||||
|
||||
# Verify Bearer header takes precedence
|
||||
assert mock_span_exporter.call_args is not None
|
||||
@ -117,8 +116,11 @@ def test_api_key_overrides_conflicting_header(
|
||||
assert ("authorization", "Basic old") not in headers
|
||||
|
||||
# Verify warning was logged
|
||||
assert "ENTERPRISE_OTLP_API_KEY is set" in caplog.text
|
||||
assert "authorization" in caplog.text
|
||||
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
|
||||
|
||||
|
||||
@patch("enterprise.telemetry.exporter.GRPCSpanExporter")
|
||||
@ -533,33 +535,33 @@ def test_export_span_cross_workflow_parent_context() -> None:
|
||||
assert kwargs["context"] is not None
|
||||
|
||||
|
||||
def test_export_span_logs_exception_on_error(caplog) -> None:
|
||||
@patch("enterprise.telemetry.exporter.logger")
|
||||
def test_export_span_logs_exception_on_error(mock_logger: MagicMock) -> 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")
|
||||
|
||||
with caplog.at_level(logging.ERROR, logger="enterprise.telemetry.exporter"):
|
||||
exporter.export_span(name="bad.span", attributes={}) # must not raise
|
||||
exporter.export_span(name="bad.span", attributes={}) # must not raise
|
||||
|
||||
assert "Failed to export span" in caplog.text
|
||||
assert "bad.span" in caplog.text
|
||||
mock_logger.exception.assert_called_once()
|
||||
assert "bad.span" in mock_logger.exception.call_args[0][1]
|
||||
|
||||
|
||||
def test_export_span_invalid_trace_correlation_logs_warning(caplog) -> None:
|
||||
@patch("enterprise.telemetry.exporter.logger")
|
||||
def test_export_span_invalid_trace_correlation_logs_warning(mock_logger: MagicMock) -> 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"
|
||||
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,
|
||||
)
|
||||
exporter.export_span(
|
||||
name="link.span",
|
||||
attributes={},
|
||||
correlation_id="not-a-valid-uuid",
|
||||
parent_span_id_source=parent_uid,
|
||||
)
|
||||
|
||||
assert "Invalid trace correlation UUID for cross-workflow link" in caplog.text
|
||||
mock_logger.warning.assert_called()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@ -1,123 +0,0 @@
|
||||
from datetime import UTC, datetime
|
||||
from decimal import Decimal
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from core.app.entities.app_invoke_entities import InvokeFrom
|
||||
from models.enums import ConversationFromSource, MessageStatus
|
||||
from services.agent.observability_service import AgentObservabilityService
|
||||
|
||||
|
||||
def test_resolve_source_accepts_frontend_aliases() -> None:
|
||||
assert AgentObservabilityService.resolve_source(None) is None
|
||||
assert AgentObservabilityService.resolve_source("all") is None
|
||||
assert AgentObservabilityService.resolve_source("console") == InvokeFrom.EXPLORE
|
||||
assert AgentObservabilityService.resolve_source("api") == InvokeFrom.SERVICE_API
|
||||
assert AgentObservabilityService.resolve_source("web_app") == InvokeFrom.WEB_APP
|
||||
|
||||
with pytest.raises(ValueError, match="Unsupported source"):
|
||||
AgentObservabilityService.resolve_source("unknown")
|
||||
|
||||
|
||||
def test_serialize_log_message_returns_frontend_log_shape() -> None:
|
||||
created_at = datetime(2026, 6, 17, 1, 2, 3, tzinfo=UTC)
|
||||
updated_at = datetime(2026, 6, 17, 1, 3, 3, tzinfo=UTC)
|
||||
message = SimpleNamespace(
|
||||
id="message-1",
|
||||
conversation_id="conversation-1",
|
||||
query="hello",
|
||||
answer="hi",
|
||||
error=None,
|
||||
status=MessageStatus.NORMAL,
|
||||
invoke_from=InvokeFrom.EXPLORE,
|
||||
from_source=ConversationFromSource.CONSOLE,
|
||||
from_end_user_id=None,
|
||||
from_account_id="account-1",
|
||||
message_tokens=3,
|
||||
answer_tokens=4,
|
||||
total_price=Decimal("0.0001"),
|
||||
currency="USD",
|
||||
provider_response_latency=1.25,
|
||||
created_at=created_at,
|
||||
updated_at=updated_at,
|
||||
)
|
||||
conversation = SimpleNamespace(name="Debug conversation")
|
||||
|
||||
payload = AgentObservabilityService.serialize_log_message(message, conversation) # type: ignore[arg-type]
|
||||
|
||||
assert payload == {
|
||||
"id": "message-1",
|
||||
"message_id": "message-1",
|
||||
"conversation_id": "conversation-1",
|
||||
"conversation_name": "Debug conversation",
|
||||
"query": "hello",
|
||||
"answer": "hi",
|
||||
"status": "success",
|
||||
"error": None,
|
||||
"source": "explore",
|
||||
"from_source": "console",
|
||||
"from_end_user_id": None,
|
||||
"from_account_id": "account-1",
|
||||
"message_tokens": 3,
|
||||
"answer_tokens": 4,
|
||||
"total_tokens": 7,
|
||||
"total_price": "0.0001",
|
||||
"currency": "USD",
|
||||
"latency": 1.25,
|
||||
"created_at": int(created_at.timestamp()),
|
||||
"updated_at": int(updated_at.timestamp()),
|
||||
}
|
||||
|
||||
|
||||
def test_build_charts_and_summary_match_monitoring_metrics() -> None:
|
||||
rows = [
|
||||
{
|
||||
"date": "2026-06-16",
|
||||
"message_count": 2,
|
||||
"conversation_count": 1,
|
||||
"end_user_count": 1,
|
||||
"token_count": 30,
|
||||
"total_price": Decimal("0.003"),
|
||||
"avg_latency": 1.5,
|
||||
"latency_sum": 3,
|
||||
"answer_tokens": 12,
|
||||
"like_count": 1,
|
||||
},
|
||||
{
|
||||
"date": "2026-06-17",
|
||||
"message_count": 1,
|
||||
"conversation_count": 1,
|
||||
"end_user_count": 1,
|
||||
"token_count": 20,
|
||||
"total_price": Decimal("0.002"),
|
||||
"avg_latency": 2,
|
||||
"latency_sum": 2,
|
||||
"answer_tokens": 8,
|
||||
"like_count": 1,
|
||||
},
|
||||
]
|
||||
|
||||
charts = AgentObservabilityService._build_charts(rows)
|
||||
summary = AgentObservabilityService._build_summary(rows)
|
||||
|
||||
assert charts["token_usage"] == [
|
||||
{"date": "2026-06-16", "token_count": 30, "total_price": "0.003", "currency": "USD"},
|
||||
{"date": "2026-06-17", "token_count": 20, "total_price": "0.002", "currency": "USD"},
|
||||
]
|
||||
assert charts["average_response_time"] == [
|
||||
{"date": "2026-06-16", "latency": 1500.0},
|
||||
{"date": "2026-06-17", "latency": 2000.0},
|
||||
]
|
||||
assert summary == {
|
||||
"total_messages": 3,
|
||||
"total_conversations": 2,
|
||||
"total_end_users": 2,
|
||||
"total_tokens": 50,
|
||||
"total_price": "0.005",
|
||||
"currency": "USD",
|
||||
"average_session_interactions": 1.5,
|
||||
"average_response_time": 1666.6667,
|
||||
"tokens_per_second": 4.0,
|
||||
"user_satisfaction_rate": 66.67,
|
||||
}
|
||||
@ -7,16 +7,15 @@ 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_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),
|
||||
)
|
||||
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
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@ -342,28 +341,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_cleanup_refs_batch_by_time_range.return_value = []
|
||||
mock_repo.get_runs_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_by_ids.assert_not_called()
|
||||
mock_repo.delete_runs_with_related.assert_not_called()
|
||||
|
||||
def test_all_paid_skips_delete(self, mock_repo):
|
||||
ref = make_ref("t1")
|
||||
mock_repo.get_cleanup_refs_batch_by_time_range.side_effect = [[ref], []]
|
||||
run = make_run("t1")
|
||||
mock_repo.get_runs_batch_by_time_range.side_effect = [[run], []]
|
||||
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_by_ids.assert_not_called()
|
||||
mock_repo.delete_runs_with_related.assert_not_called()
|
||||
|
||||
def test_runs_deleted_successfully(self, mock_repo):
|
||||
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 = {
|
||||
run = make_run("t1")
|
||||
mock_repo.get_runs_batch_by_time_range.side_effect = [[run], []]
|
||||
mock_repo.delete_runs_with_related.return_value = {
|
||||
"runs": 1,
|
||||
"node_executions": 0,
|
||||
"offloads": 0,
|
||||
@ -377,12 +376,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_by_ids.assert_called_once()
|
||||
mock_repo.delete_runs_with_related.assert_called_once()
|
||||
|
||||
def test_delete_exception_reraises(self, mock_repo):
|
||||
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")
|
||||
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")
|
||||
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
|
||||
@ -390,7 +389,7 @@ class TestRunDeleteMode:
|
||||
c.run()
|
||||
|
||||
def test_summary_with_window_start(self, mock_repo):
|
||||
mock_repo.get_cleanup_refs_batch_by_time_range.return_value = []
|
||||
mock_repo.get_runs_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
|
||||
@ -422,10 +421,9 @@ class TestRunDryRunMode:
|
||||
)
|
||||
|
||||
def test_dry_run_no_delete_called(self, mock_repo):
|
||||
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,
|
||||
run = make_run("t1")
|
||||
mock_repo.get_runs_batch_by_time_range.side_effect = [[run], []]
|
||||
mock_repo.count_runs_with_related.return_value = {
|
||||
"node_executions": 2,
|
||||
"offloads": 0,
|
||||
"app_logs": 0,
|
||||
@ -437,11 +435,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_by_ids.assert_not_called()
|
||||
mock_repo.count_runs_with_related_by_ids.assert_called_once()
|
||||
mock_repo.delete_runs_with_related.assert_not_called()
|
||||
mock_repo.count_runs_with_related.assert_called_once()
|
||||
|
||||
def test_dry_run_summary_with_window_start(self, mock_repo):
|
||||
mock_repo.get_cleanup_refs_batch_by_time_range.return_value = []
|
||||
mock_repo.get_runs_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
|
||||
@ -456,14 +454,14 @@ class TestRunDryRunMode:
|
||||
c.run()
|
||||
|
||||
def test_dry_run_all_paid_skips_count(self, mock_repo):
|
||||
ref = make_ref("t1")
|
||||
mock_repo.get_cleanup_refs_batch_by_time_range.side_effect = [[ref], []]
|
||||
run = make_run("t1")
|
||||
mock_repo.get_runs_batch_by_time_range.side_effect = [[run], []]
|
||||
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_by_ids.assert_not_called()
|
||||
mock_repo.count_runs_with_related.assert_not_called()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@ -494,7 +492,7 @@ class TestTriggerLogMethods:
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _count_node_executions_by_run_ids / _delete_node_executions_by_run_ids
|
||||
# _count_node_executions / _delete_node_executions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@ -502,23 +500,25 @@ 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_by_run_ids(session, ["r1"])
|
||||
result = cleanup._count_node_executions(session, runs)
|
||||
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_by_run_ids(session, ["r1"])
|
||||
result = cleanup._delete_node_executions(session, runs)
|
||||
assert result == (5, 1)
|
||||
|
||||
@ -3,27 +3,38 @@ 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
|
||||
|
||||
|
||||
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 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
|
||||
|
||||
|
||||
class FakeRepo:
|
||||
def __init__(
|
||||
self,
|
||||
batches: list[list[WorkflowRunCleanupRef]],
|
||||
batches: list[list[FakeRun]],
|
||||
delete_result: dict[str, int] | None = None,
|
||||
count_result: dict[str, int] | None = None,
|
||||
) -> None:
|
||||
self.batches = batches
|
||||
self.candidate_call_idx = 0
|
||||
self.last_candidate_batch: list[WorkflowRunCleanupRef] = []
|
||||
self.cleanup_ref_calls: list[dict[str, object]] = []
|
||||
self.call_idx = 0
|
||||
self.deleted: list[list[str]] = []
|
||||
self.counted: list[list[str]] = []
|
||||
self.delete_result = delete_result or {
|
||||
@ -45,7 +56,7 @@ class FakeRepo:
|
||||
"pause_reasons": 0,
|
||||
}
|
||||
|
||||
def get_cleanup_refs_batch_by_time_range(
|
||||
def get_runs_batch_by_time_range(
|
||||
self,
|
||||
start_from: datetime.datetime | None,
|
||||
end_before: datetime.datetime,
|
||||
@ -54,50 +65,27 @@ class FakeRepo:
|
||||
run_types=None,
|
||||
tenant_ids=None,
|
||||
workflow_ids=None,
|
||||
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):
|
||||
) -> list[FakeRun]:
|
||||
if self.call_idx >= len(self.batches):
|
||||
return []
|
||||
batch = self.batches[self.candidate_call_idx]
|
||||
self.candidate_call_idx += 1
|
||||
self.last_candidate_batch = batch
|
||||
batch = self.batches[self.call_idx]
|
||||
self.call_idx += 1
|
||||
return batch
|
||||
|
||||
def delete_runs_with_related_by_ids(
|
||||
self, run_ids: list[str], delete_node_executions=None, delete_trigger_logs=None
|
||||
def delete_runs_with_related(
|
||||
self, runs: list[FakeRun], delete_node_executions=None, delete_trigger_logs=None
|
||||
) -> dict[str, int]:
|
||||
self.deleted.append(list(run_ids))
|
||||
self.deleted.append([run.id for run in runs])
|
||||
result = self.delete_result.copy()
|
||||
result["runs"] = len(run_ids)
|
||||
result["runs"] = len(runs)
|
||||
return result
|
||||
|
||||
def count_runs_with_related_by_ids(
|
||||
self, run_ids: list[str], count_node_executions=None, count_trigger_logs=None
|
||||
def count_runs_with_related(
|
||||
self, runs: list[FakeRun], count_node_executions=None, count_trigger_logs=None
|
||||
) -> dict[str, int]:
|
||||
self.counted.append(list(run_ids))
|
||||
self.counted.append([run.id for run in runs])
|
||||
result = self.count_result.copy()
|
||||
result["runs"] = len(run_ids)
|
||||
result["runs"] = len(runs)
|
||||
return result
|
||||
|
||||
|
||||
@ -230,8 +218,8 @@ def test_run_deletes_only_free_tenants(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
repo = FakeRepo(
|
||||
batches=[
|
||||
[
|
||||
make_ref("run-free", "t_free", cutoff),
|
||||
make_ref("run-paid", "t_paid", cutoff),
|
||||
FakeRun("run-free", "t_free", cutoff),
|
||||
FakeRun("run-paid", "t_paid", cutoff),
|
||||
]
|
||||
]
|
||||
)
|
||||
@ -252,43 +240,11 @@ 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=[[make_ref("run-paid", "t_paid", cutoff)]])
|
||||
repo = FakeRepo(batches=[[FakeRun("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)
|
||||
@ -301,53 +257,6 @@ 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:
|
||||
@ -359,7 +268,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=[[make_ref("run-free", "t_free", cutoff)]],
|
||||
batches=[[FakeRun("run-free", "t_free", cutoff)]],
|
||||
delete_result={
|
||||
"runs": 0,
|
||||
"node_executions": 2,
|
||||
@ -391,13 +300,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_by_ids(
|
||||
self, run_ids: list[str], delete_node_executions=None, delete_trigger_logs=None
|
||||
def delete_runs_with_related(
|
||||
self, runs: list[FakeRun], delete_node_executions=None, delete_trigger_logs=None
|
||||
) -> dict[str, int]:
|
||||
raise RuntimeError("delete failed")
|
||||
|
||||
cutoff = datetime.datetime.now()
|
||||
repo = FailingRepo(batches=[[make_ref("run-free", "t_free", cutoff)]])
|
||||
repo = FailingRepo(batches=[[FakeRun("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)
|
||||
|
||||
@ -414,7 +323,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=[[make_ref("run-free", "t_free", cutoff)]],
|
||||
batches=[[FakeRun("run-free", "t_free", cutoff)]],
|
||||
count_result={
|
||||
"runs": 0,
|
||||
"node_executions": 2,
|
||||
|
||||
@ -50,7 +50,8 @@ class TestDeleteDraftVariableOffloadData:
|
||||
assert result == 0
|
||||
mock_conn.execute.assert_not_called()
|
||||
|
||||
def test_delete_draft_variable_offload_data_database_failure(self, caplog):
|
||||
@patch("tasks.remove_app_and_related_data_task.logging")
|
||||
def test_delete_draft_variable_offload_data_database_failure(self, mock_logging):
|
||||
"""Test handling of database operation failures."""
|
||||
mock_conn = MagicMock()
|
||||
file_ids = ["file-1"]
|
||||
@ -59,14 +60,13 @@ class TestDeleteDraftVariableOffloadData:
|
||||
mock_conn.execute.side_effect = Exception("Database error")
|
||||
|
||||
# Execute function - should not raise, but log error
|
||||
with caplog.at_level(logging.ERROR):
|
||||
result = _delete_draft_variable_offload_data(mock_conn, file_ids)
|
||||
result = _delete_draft_variable_offload_data(mock_conn, file_ids)
|
||||
|
||||
# Should return 0 when error occurs
|
||||
assert result == 0
|
||||
|
||||
# Verify error was logged
|
||||
assert "Error deleting draft variable offload data:" in caplog.text
|
||||
mock_logging.exception.assert_called_once_with("Error deleting draft variable offload data:")
|
||||
|
||||
|
||||
class TestDeleteWorkflowArchiveLogs:
|
||||
|
||||
@ -29,9 +29,6 @@ import {
|
||||
zGetAgentByAgentIdDriveFilesPreviewResponse,
|
||||
zGetAgentByAgentIdDriveFilesQuery,
|
||||
zGetAgentByAgentIdDriveFilesResponse,
|
||||
zGetAgentByAgentIdLogsPath,
|
||||
zGetAgentByAgentIdLogsQuery,
|
||||
zGetAgentByAgentIdLogsResponse,
|
||||
zGetAgentByAgentIdMessagesByMessageIdPath,
|
||||
zGetAgentByAgentIdMessagesByMessageIdResponse,
|
||||
zGetAgentByAgentIdPath,
|
||||
@ -44,9 +41,6 @@ import {
|
||||
zGetAgentByAgentIdSandboxFilesReadQuery,
|
||||
zGetAgentByAgentIdSandboxFilesReadResponse,
|
||||
zGetAgentByAgentIdSandboxFilesResponse,
|
||||
zGetAgentByAgentIdStatisticsSummaryPath,
|
||||
zGetAgentByAgentIdStatisticsSummaryQuery,
|
||||
zGetAgentByAgentIdStatisticsSummaryResponse,
|
||||
zGetAgentByAgentIdVersionsByVersionIdPath,
|
||||
zGetAgentByAgentIdVersionsByVersionIdResponse,
|
||||
zGetAgentByAgentIdVersionsPath,
|
||||
@ -397,27 +391,10 @@ export const files2 = {
|
||||
post: post5,
|
||||
}
|
||||
|
||||
export const get9 = oc
|
||||
.route({
|
||||
inputStructure: 'detailed',
|
||||
method: 'GET',
|
||||
operationId: 'getAgentByAgentIdLogs',
|
||||
path: '/agent/{agent_id}/logs',
|
||||
tags: ['console'],
|
||||
})
|
||||
.input(
|
||||
z.object({ params: zGetAgentByAgentIdLogsPath, query: zGetAgentByAgentIdLogsQuery.optional() }),
|
||||
)
|
||||
.output(zGetAgentByAgentIdLogsResponse)
|
||||
|
||||
export const logs = {
|
||||
get: get9,
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Agent App message details by ID
|
||||
*/
|
||||
export const get10 = oc
|
||||
export const get9 = oc
|
||||
.route({
|
||||
description: 'Get Agent App message details by ID',
|
||||
inputStructure: 'detailed',
|
||||
@ -430,7 +407,7 @@ export const get10 = oc
|
||||
.output(zGetAgentByAgentIdMessagesByMessageIdResponse)
|
||||
|
||||
export const byMessageId2 = {
|
||||
get: get10,
|
||||
get: get9,
|
||||
}
|
||||
|
||||
export const messages = {
|
||||
@ -440,7 +417,7 @@ export const messages = {
|
||||
/**
|
||||
* List workflow apps that reference this Agent App's bound Agent (read-only)
|
||||
*/
|
||||
export const get11 = oc
|
||||
export const get10 = oc
|
||||
.route({
|
||||
description: 'List workflow apps that reference this Agent App\'s bound Agent (read-only)',
|
||||
inputStructure: 'detailed',
|
||||
@ -453,13 +430,13 @@ export const get11 = oc
|
||||
.output(zGetAgentByAgentIdReferencingWorkflowsResponse)
|
||||
|
||||
export const referencingWorkflows = {
|
||||
get: get11,
|
||||
get: get10,
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a text/binary preview file in an Agent App conversation sandbox
|
||||
*/
|
||||
export const get12 = oc
|
||||
export const get11 = oc
|
||||
.route({
|
||||
description: 'Read a text/binary preview file in an Agent App conversation sandbox',
|
||||
inputStructure: 'detailed',
|
||||
@ -477,7 +454,7 @@ export const get12 = oc
|
||||
.output(zGetAgentByAgentIdSandboxFilesReadResponse)
|
||||
|
||||
export const read = {
|
||||
get: get12,
|
||||
get: get11,
|
||||
}
|
||||
|
||||
/**
|
||||
@ -507,7 +484,7 @@ export const upload = {
|
||||
/**
|
||||
* List a directory in an Agent App conversation sandbox
|
||||
*/
|
||||
export const get13 = oc
|
||||
export const get12 = oc
|
||||
.route({
|
||||
description: 'List a directory in an Agent App conversation sandbox',
|
||||
inputStructure: 'detailed',
|
||||
@ -525,7 +502,7 @@ export const get13 = oc
|
||||
.output(zGetAgentByAgentIdSandboxFilesResponse)
|
||||
|
||||
export const files3 = {
|
||||
get: get13,
|
||||
get: get12,
|
||||
read,
|
||||
upload,
|
||||
}
|
||||
@ -619,31 +596,7 @@ export const skills = {
|
||||
bySlug,
|
||||
}
|
||||
|
||||
export const get14 = oc
|
||||
.route({
|
||||
inputStructure: 'detailed',
|
||||
method: 'GET',
|
||||
operationId: 'getAgentByAgentIdStatisticsSummary',
|
||||
path: '/agent/{agent_id}/statistics/summary',
|
||||
tags: ['console'],
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
params: zGetAgentByAgentIdStatisticsSummaryPath,
|
||||
query: zGetAgentByAgentIdStatisticsSummaryQuery.optional(),
|
||||
}),
|
||||
)
|
||||
.output(zGetAgentByAgentIdStatisticsSummaryResponse)
|
||||
|
||||
export const summary = {
|
||||
get: get14,
|
||||
}
|
||||
|
||||
export const statistics = {
|
||||
summary,
|
||||
}
|
||||
|
||||
export const get15 = oc
|
||||
export const get13 = oc
|
||||
.route({
|
||||
inputStructure: 'detailed',
|
||||
method: 'GET',
|
||||
@ -655,10 +608,10 @@ export const get15 = oc
|
||||
.output(zGetAgentByAgentIdVersionsByVersionIdResponse)
|
||||
|
||||
export const byVersionId = {
|
||||
get: get15,
|
||||
get: get13,
|
||||
}
|
||||
|
||||
export const get16 = oc
|
||||
export const get14 = oc
|
||||
.route({
|
||||
inputStructure: 'detailed',
|
||||
method: 'GET',
|
||||
@ -670,7 +623,7 @@ export const get16 = oc
|
||||
.output(zGetAgentByAgentIdVersionsResponse)
|
||||
|
||||
export const versions = {
|
||||
get: get16,
|
||||
get: get14,
|
||||
byVersionId,
|
||||
}
|
||||
|
||||
@ -686,7 +639,7 @@ export const delete3 = oc
|
||||
.input(z.object({ params: zDeleteAgentByAgentIdPath }))
|
||||
.output(zDeleteAgentByAgentIdResponse)
|
||||
|
||||
export const get17 = oc
|
||||
export const get15 = oc
|
||||
.route({
|
||||
inputStructure: 'detailed',
|
||||
method: 'GET',
|
||||
@ -710,7 +663,7 @@ export const put2 = oc
|
||||
|
||||
export const byAgentId = {
|
||||
delete: delete3,
|
||||
get: get17,
|
||||
get: get15,
|
||||
put: put2,
|
||||
chatMessages,
|
||||
composer,
|
||||
@ -718,16 +671,14 @@ export const byAgentId = {
|
||||
features,
|
||||
feedbacks,
|
||||
files: files2,
|
||||
logs,
|
||||
messages,
|
||||
referencingWorkflows,
|
||||
sandbox,
|
||||
skills,
|
||||
statistics,
|
||||
versions,
|
||||
}
|
||||
|
||||
export const get18 = oc
|
||||
export const get16 = oc
|
||||
.route({
|
||||
inputStructure: 'detailed',
|
||||
method: 'GET',
|
||||
@ -751,7 +702,7 @@ export const post10 = oc
|
||||
.output(zPostAgentResponse)
|
||||
|
||||
export const agent = {
|
||||
get: get18,
|
||||
get: get16,
|
||||
post: post10,
|
||||
inviteOptions,
|
||||
byAgentId,
|
||||
|
||||
@ -169,14 +169,6 @@ export type AgentDriveFileCommitResponse = {
|
||||
file: AgentDriveFileResponse
|
||||
}
|
||||
|
||||
export type AgentLogListResponse = {
|
||||
data: Array<AgentLogItemResponse>
|
||||
has_more: boolean
|
||||
limit: number
|
||||
page: number
|
||||
total: number
|
||||
}
|
||||
|
||||
export type MessageDetailResponse = {
|
||||
agent_thoughts?: Array<AgentThought>
|
||||
annotation?: ConversationAnnotation | null
|
||||
@ -250,12 +242,6 @@ export type SkillToolInferenceResult = {
|
||||
reason?: string | null
|
||||
}
|
||||
|
||||
export type AgentStatisticSummaryEnvelopeResponse = {
|
||||
charts: AgentStatisticChartsResponse
|
||||
source: string
|
||||
summary: AgentStatisticSummaryResponse
|
||||
}
|
||||
|
||||
export type AgentConfigSnapshotListResponse = {
|
||||
data: Array<AgentConfigSnapshotSummaryResponse>
|
||||
}
|
||||
@ -544,29 +530,6 @@ export type AgentDriveFileResponse = {
|
||||
size?: number | null
|
||||
}
|
||||
|
||||
export type AgentLogItemResponse = {
|
||||
answer: string
|
||||
answer_tokens: number
|
||||
conversation_id: string
|
||||
conversation_name?: string | null
|
||||
created_at?: number | null
|
||||
currency: string
|
||||
error?: string | null
|
||||
from_account_id?: string | null
|
||||
from_end_user_id?: string | null
|
||||
from_source?: string | null
|
||||
id: string
|
||||
latency: number
|
||||
message_id: string
|
||||
message_tokens: number
|
||||
query: string
|
||||
source?: string | null
|
||||
status: string
|
||||
total_price: string
|
||||
total_tokens: number
|
||||
updated_at?: number | null
|
||||
}
|
||||
|
||||
export type AgentThought = {
|
||||
chain_id?: string | null
|
||||
created_at?: number | null
|
||||
@ -681,30 +644,6 @@ export type CliToolSuggestion = {
|
||||
name: string
|
||||
}
|
||||
|
||||
export type AgentStatisticChartsResponse = {
|
||||
average_response_time?: Array<AgentAverageResponseTimeStatisticResponse>
|
||||
average_session_interactions?: Array<AgentAverageSessionInteractionStatisticResponse>
|
||||
daily_conversations?: Array<AgentDailyConversationStatisticResponse>
|
||||
daily_end_users?: Array<AgentDailyEndUserStatisticResponse>
|
||||
daily_messages?: Array<AgentDailyMessageStatisticResponse>
|
||||
token_usage?: Array<AgentTokenUsageStatisticResponse>
|
||||
tokens_per_second?: Array<AgentTokensPerSecondStatisticResponse>
|
||||
user_satisfaction_rate?: Array<AgentUserSatisfactionRateStatisticResponse>
|
||||
}
|
||||
|
||||
export type AgentStatisticSummaryResponse = {
|
||||
average_response_time: number
|
||||
average_session_interactions: number
|
||||
currency: string
|
||||
tokens_per_second: number
|
||||
total_conversations: number
|
||||
total_end_users: number
|
||||
total_messages: number
|
||||
total_price: string
|
||||
total_tokens: number
|
||||
user_satisfaction_rate: number
|
||||
}
|
||||
|
||||
export type AgentConfigRevisionResponse = {
|
||||
created_at?: number | null
|
||||
created_by?: string | null
|
||||
@ -1014,48 +953,6 @@ export type EnvSuggestion = {
|
||||
secret_likely?: boolean
|
||||
}
|
||||
|
||||
export type AgentAverageResponseTimeStatisticResponse = {
|
||||
date: string
|
||||
latency: number
|
||||
}
|
||||
|
||||
export type AgentAverageSessionInteractionStatisticResponse = {
|
||||
date: string
|
||||
interactions: number
|
||||
}
|
||||
|
||||
export type AgentDailyConversationStatisticResponse = {
|
||||
conversation_count: number
|
||||
date: string
|
||||
}
|
||||
|
||||
export type AgentDailyEndUserStatisticResponse = {
|
||||
date: string
|
||||
terminal_count: number
|
||||
}
|
||||
|
||||
export type AgentDailyMessageStatisticResponse = {
|
||||
date: string
|
||||
message_count: number
|
||||
}
|
||||
|
||||
export type AgentTokenUsageStatisticResponse = {
|
||||
currency: string
|
||||
date: string
|
||||
token_count: number
|
||||
total_price: string
|
||||
}
|
||||
|
||||
export type AgentTokensPerSecondStatisticResponse = {
|
||||
date: string
|
||||
tps: number
|
||||
}
|
||||
|
||||
export type AgentUserSatisfactionRateStatisticResponse = {
|
||||
date: string
|
||||
rate: number
|
||||
}
|
||||
|
||||
export type AgentConfigRevisionOperation
|
||||
= | 'create_version'
|
||||
| 'save_current_version'
|
||||
@ -1820,30 +1717,6 @@ export type PostAgentByAgentIdFilesResponses = {
|
||||
export type PostAgentByAgentIdFilesResponse
|
||||
= PostAgentByAgentIdFilesResponses[keyof PostAgentByAgentIdFilesResponses]
|
||||
|
||||
export type GetAgentByAgentIdLogsData = {
|
||||
body?: never
|
||||
path: {
|
||||
agent_id: string
|
||||
}
|
||||
query?: {
|
||||
end?: string
|
||||
keyword?: string
|
||||
limit?: number
|
||||
page?: number
|
||||
source?: string
|
||||
start?: string
|
||||
status?: string
|
||||
}
|
||||
url: '/agent/{agent_id}/logs'
|
||||
}
|
||||
|
||||
export type GetAgentByAgentIdLogsResponses = {
|
||||
200: AgentLogListResponse
|
||||
}
|
||||
|
||||
export type GetAgentByAgentIdLogsResponse
|
||||
= GetAgentByAgentIdLogsResponses[keyof GetAgentByAgentIdLogsResponses]
|
||||
|
||||
export type GetAgentByAgentIdMessagesByMessageIdData = {
|
||||
body?: never
|
||||
path: {
|
||||
@ -2013,26 +1886,6 @@ export type PostAgentByAgentIdSkillsBySlugInferToolsResponses = {
|
||||
export type PostAgentByAgentIdSkillsBySlugInferToolsResponse
|
||||
= PostAgentByAgentIdSkillsBySlugInferToolsResponses[keyof PostAgentByAgentIdSkillsBySlugInferToolsResponses]
|
||||
|
||||
export type GetAgentByAgentIdStatisticsSummaryData = {
|
||||
body?: never
|
||||
path: {
|
||||
agent_id: string
|
||||
}
|
||||
query?: {
|
||||
end?: string
|
||||
source?: string
|
||||
start?: string
|
||||
}
|
||||
url: '/agent/{agent_id}/statistics/summary'
|
||||
}
|
||||
|
||||
export type GetAgentByAgentIdStatisticsSummaryResponses = {
|
||||
200: AgentStatisticSummaryEnvelopeResponse
|
||||
}
|
||||
|
||||
export type GetAgentByAgentIdStatisticsSummaryResponse
|
||||
= GetAgentByAgentIdStatisticsSummaryResponses[keyof GetAgentByAgentIdStatisticsSummaryResponses]
|
||||
|
||||
export type GetAgentByAgentIdVersionsData = {
|
||||
body?: never
|
||||
path: {
|
||||
|
||||
@ -321,43 +321,6 @@ export const zAgentDriveFileCommitResponse = z.object({
|
||||
file: zAgentDriveFileResponse,
|
||||
})
|
||||
|
||||
/**
|
||||
* AgentLogItemResponse
|
||||
*/
|
||||
export const zAgentLogItemResponse = z.object({
|
||||
answer: z.string(),
|
||||
answer_tokens: z.int(),
|
||||
conversation_id: z.string(),
|
||||
conversation_name: z.string().nullish(),
|
||||
created_at: z.int().nullish(),
|
||||
currency: z.string(),
|
||||
error: z.string().nullish(),
|
||||
from_account_id: z.string().nullish(),
|
||||
from_end_user_id: z.string().nullish(),
|
||||
from_source: z.string().nullish(),
|
||||
id: z.string(),
|
||||
latency: z.number(),
|
||||
message_id: z.string(),
|
||||
message_tokens: z.int(),
|
||||
query: z.string(),
|
||||
source: z.string().nullish(),
|
||||
status: z.string(),
|
||||
total_price: z.string(),
|
||||
total_tokens: z.int(),
|
||||
updated_at: z.int().nullish(),
|
||||
})
|
||||
|
||||
/**
|
||||
* AgentLogListResponse
|
||||
*/
|
||||
export const zAgentLogListResponse = z.object({
|
||||
data: z.array(zAgentLogItemResponse),
|
||||
has_more: z.boolean(),
|
||||
limit: z.int(),
|
||||
page: z.int(),
|
||||
total: z.int(),
|
||||
})
|
||||
|
||||
/**
|
||||
* AgentThought
|
||||
*/
|
||||
@ -495,22 +458,6 @@ export const zAgentSkillUploadResponse = z.object({
|
||||
skill: zAgentSkillRefConfig,
|
||||
})
|
||||
|
||||
/**
|
||||
* AgentStatisticSummaryResponse
|
||||
*/
|
||||
export const zAgentStatisticSummaryResponse = z.object({
|
||||
average_response_time: z.number(),
|
||||
average_session_interactions: z.number(),
|
||||
currency: z.string(),
|
||||
tokens_per_second: z.number(),
|
||||
total_conversations: z.int(),
|
||||
total_end_users: z.int(),
|
||||
total_messages: z.int(),
|
||||
total_price: z.string(),
|
||||
total_tokens: z.int(),
|
||||
user_satisfaction_rate: z.number(),
|
||||
})
|
||||
|
||||
/**
|
||||
* ModelConfigPartial
|
||||
*/
|
||||
@ -938,97 +885,6 @@ export const zSkillToolInferenceResult = z.object({
|
||||
reason: z.string().nullish(),
|
||||
})
|
||||
|
||||
/**
|
||||
* AgentAverageResponseTimeStatisticResponse
|
||||
*/
|
||||
export const zAgentAverageResponseTimeStatisticResponse = z.object({
|
||||
date: z.string(),
|
||||
latency: z.number(),
|
||||
})
|
||||
|
||||
/**
|
||||
* AgentAverageSessionInteractionStatisticResponse
|
||||
*/
|
||||
export const zAgentAverageSessionInteractionStatisticResponse = z.object({
|
||||
date: z.string(),
|
||||
interactions: z.number(),
|
||||
})
|
||||
|
||||
/**
|
||||
* AgentDailyConversationStatisticResponse
|
||||
*/
|
||||
export const zAgentDailyConversationStatisticResponse = z.object({
|
||||
conversation_count: z.int(),
|
||||
date: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
* AgentDailyEndUserStatisticResponse
|
||||
*/
|
||||
export const zAgentDailyEndUserStatisticResponse = z.object({
|
||||
date: z.string(),
|
||||
terminal_count: z.int(),
|
||||
})
|
||||
|
||||
/**
|
||||
* AgentDailyMessageStatisticResponse
|
||||
*/
|
||||
export const zAgentDailyMessageStatisticResponse = z.object({
|
||||
date: z.string(),
|
||||
message_count: z.int(),
|
||||
})
|
||||
|
||||
/**
|
||||
* AgentTokenUsageStatisticResponse
|
||||
*/
|
||||
export const zAgentTokenUsageStatisticResponse = z.object({
|
||||
currency: z.string(),
|
||||
date: z.string(),
|
||||
token_count: z.int(),
|
||||
total_price: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
* AgentTokensPerSecondStatisticResponse
|
||||
*/
|
||||
export const zAgentTokensPerSecondStatisticResponse = z.object({
|
||||
date: z.string(),
|
||||
tps: z.number(),
|
||||
})
|
||||
|
||||
/**
|
||||
* AgentUserSatisfactionRateStatisticResponse
|
||||
*/
|
||||
export const zAgentUserSatisfactionRateStatisticResponse = z.object({
|
||||
date: z.string(),
|
||||
rate: z.number(),
|
||||
})
|
||||
|
||||
/**
|
||||
* AgentStatisticChartsResponse
|
||||
*/
|
||||
export const zAgentStatisticChartsResponse = z.object({
|
||||
average_response_time: z.array(zAgentAverageResponseTimeStatisticResponse).optional(),
|
||||
average_session_interactions: z
|
||||
.array(zAgentAverageSessionInteractionStatisticResponse)
|
||||
.optional(),
|
||||
daily_conversations: z.array(zAgentDailyConversationStatisticResponse).optional(),
|
||||
daily_end_users: z.array(zAgentDailyEndUserStatisticResponse).optional(),
|
||||
daily_messages: z.array(zAgentDailyMessageStatisticResponse).optional(),
|
||||
token_usage: z.array(zAgentTokenUsageStatisticResponse).optional(),
|
||||
tokens_per_second: z.array(zAgentTokensPerSecondStatisticResponse).optional(),
|
||||
user_satisfaction_rate: z.array(zAgentUserSatisfactionRateStatisticResponse).optional(),
|
||||
})
|
||||
|
||||
/**
|
||||
* AgentStatisticSummaryEnvelopeResponse
|
||||
*/
|
||||
export const zAgentStatisticSummaryEnvelopeResponse = z.object({
|
||||
charts: zAgentStatisticChartsResponse,
|
||||
source: z.string(),
|
||||
summary: zAgentStatisticSummaryResponse,
|
||||
})
|
||||
|
||||
/**
|
||||
* AgentConfigRevisionOperation
|
||||
*
|
||||
@ -2250,25 +2106,6 @@ export const zPostAgentByAgentIdFilesPath = z.object({
|
||||
*/
|
||||
export const zPostAgentByAgentIdFilesResponse = zAgentDriveFileCommitResponse
|
||||
|
||||
export const zGetAgentByAgentIdLogsPath = z.object({
|
||||
agent_id: z.string(),
|
||||
})
|
||||
|
||||
export const zGetAgentByAgentIdLogsQuery = z.object({
|
||||
end: z.string().optional(),
|
||||
keyword: z.string().optional(),
|
||||
limit: z.int().gte(1).lte(100).optional().default(20),
|
||||
page: z.int().gte(1).optional().default(1),
|
||||
source: z.string().optional(),
|
||||
start: z.string().optional(),
|
||||
status: z.string().optional(),
|
||||
})
|
||||
|
||||
/**
|
||||
* Agent logs
|
||||
*/
|
||||
export const zGetAgentByAgentIdLogsResponse = zAgentLogListResponse
|
||||
|
||||
export const zGetAgentByAgentIdMessagesByMessageIdPath = z.object({
|
||||
agent_id: z.string(),
|
||||
message_id: z.string(),
|
||||
@ -2365,21 +2202,6 @@ export const zPostAgentByAgentIdSkillsBySlugInferToolsPath = z.object({
|
||||
*/
|
||||
export const zPostAgentByAgentIdSkillsBySlugInferToolsResponse = zSkillToolInferenceResult
|
||||
|
||||
export const zGetAgentByAgentIdStatisticsSummaryPath = z.object({
|
||||
agent_id: z.string(),
|
||||
})
|
||||
|
||||
export const zGetAgentByAgentIdStatisticsSummaryQuery = z.object({
|
||||
end: z.string().optional(),
|
||||
source: z.string().optional(),
|
||||
start: z.string().optional(),
|
||||
})
|
||||
|
||||
/**
|
||||
* Agent monitoring summary and chart data
|
||||
*/
|
||||
export const zGetAgentByAgentIdStatisticsSummaryResponse = zAgentStatisticSummaryEnvelopeResponse
|
||||
|
||||
export const zGetAgentByAgentIdVersionsPath = z.object({
|
||||
agent_id: z.string(),
|
||||
})
|
||||
|
||||
@ -17,7 +17,7 @@ vi.mock('@/next/navigation', () => ({
|
||||
|
||||
// Mock useDocLink hook
|
||||
vi.mock('@/context/i18n', () => ({
|
||||
useDocLink: () => (path?: string) => `https://docs.dify.ai/en${path || ''}`,
|
||||
useDocLink: () => (path?: string) => `https://docs.dify.ai/en${path?.startsWith('/use-dify/') ? `/cloud${path}` : 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/use-dify/knowledge/connect-external-knowledge-base')
|
||||
expect(docLink)!.toHaveAttribute('href', 'https://docs.dify.ai/en/cloud/use-dify/knowledge/connect-external-knowledge-base')
|
||||
expect(docLink)!.toHaveAttribute('target', '_blank')
|
||||
expect(docLink)!.toHaveAttribute('rel', 'noopener noreferrer')
|
||||
})
|
||||
|
||||
@ -22,6 +22,7 @@ vi.mock('react-i18next', () => ({
|
||||
|
||||
vi.mock('@/context/i18n', () => ({
|
||||
defaultDocBaseUrl: 'https://docs.dify.ai',
|
||||
getDocHomePath: () => '/home',
|
||||
}))
|
||||
|
||||
vi.mock('@/i18n-config/language', () => ({
|
||||
@ -45,7 +46,7 @@ describe('docsCommand', () => {
|
||||
docsCommand.execute?.()
|
||||
|
||||
expect(openSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('https://docs.dify.ai'),
|
||||
'https://docs.dify.ai/en/home',
|
||||
'_blank',
|
||||
'noopener,noreferrer',
|
||||
)
|
||||
@ -85,7 +86,11 @@ describe('docsCommand', () => {
|
||||
const handlers = vi.mocked(registerCommands).mock.calls[0]![0]
|
||||
await handlers['navigation.doc']!()
|
||||
|
||||
expect(openSpy).toHaveBeenCalledWith('https://docs.dify.ai/en', '_blank', 'noopener,noreferrer')
|
||||
expect(openSpy).toHaveBeenCalledWith(
|
||||
'https://docs.dify.ai/en/home',
|
||||
'_blank',
|
||||
'noopener,noreferrer',
|
||||
)
|
||||
openSpy.mockRestore()
|
||||
})
|
||||
|
||||
|
||||
@ -2,13 +2,20 @@ import type { SlashCommandHandler } from './types'
|
||||
import { RiBookOpenLine } from '@remixicon/react'
|
||||
import * as React from 'react'
|
||||
import { getI18n } from 'react-i18next'
|
||||
import { defaultDocBaseUrl } from '@/context/i18n'
|
||||
import { defaultDocBaseUrl, getDocHomePath } 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
|
||||
*/
|
||||
@ -19,11 +26,7 @@ export const docsCommand: SlashCommandHandler<DocDeps> = {
|
||||
|
||||
// Direct execution function
|
||||
execute: () => {
|
||||
const i18n = getI18n()
|
||||
const currentLocale = i18n.language
|
||||
const docLanguage = getDocLanguage(currentLocale)
|
||||
const url = `${defaultDocBaseUrl}/${docLanguage}`
|
||||
window.open(url, '_blank', 'noopener,noreferrer')
|
||||
window.open(getDocsHomeUrl(), '_blank', 'noopener,noreferrer')
|
||||
},
|
||||
|
||||
async search(args: string, locale: string = 'en') {
|
||||
@ -43,14 +46,9 @@ export const docsCommand: SlashCommandHandler<DocDeps> = {
|
||||
},
|
||||
|
||||
register(_deps: DocDeps) {
|
||||
const i18n = getI18n()
|
||||
registerCommands({
|
||||
'navigation.doc': async (_args) => {
|
||||
// Get the current language from i18n
|
||||
const currentLocale = i18n.language
|
||||
const docLanguage = getDocLanguage(currentLocale)
|
||||
const url = `${defaultDocBaseUrl}/${docLanguage}`
|
||||
window.open(url, '_blank', 'noopener,noreferrer')
|
||||
window.open(getDocsHomeUrl(), '_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 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')
|
||||
// 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')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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/use-dify/workspace/tools')
|
||||
expect(screen.getByRole('link', { name: /common\.modelProvider\.learnMore/i })).toHaveAttribute('href', 'https://docs.dify.ai/en/self-host/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/use-dify/build/mcp')
|
||||
expect(screen.getByRole('link', { name: /common\.modelProvider\.learnMore/i })).toHaveAttribute('href', 'https://docs.dify.ai/en/self-host/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/use-dify/workspace/tools#custom-tool')
|
||||
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.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/use-dify/workspace/api-extension/api-extension')
|
||||
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.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/use-dify/workspace/tools#workflow-tool')
|
||||
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.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('/') ? path.slice(1) : path}`,
|
||||
useDocLink: () => (path?: string) => `https://docs.dify.ai/en${path?.startsWith('/use-dify/') ? `/cloud${path}` : 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/use-dify/workspace/tools#custom-tool')
|
||||
expect(docLink).toHaveAttribute('href', 'https://docs.dify.ai/en/cloud/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/use-dify/workspace/tools#workflow-tool')
|
||||
expect(screen.getByRole('link', { name: /tools\.workflowToolEmpty\.learnMore/i })).toHaveAttribute('href', 'https://docs.dify.ai/en/self-host/use-dify/workspace/tools#workflow-tool')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -47,4 +47,16 @@ 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,8 +12,20 @@ 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()
|
||||
@ -49,14 +61,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 = `/use-dify/nodes/${metaData.helpLinkUri}` as DocPathWithoutLang
|
||||
const helpLinkPath = getNodeHelpLinkPath(metaData.helpLinkUri)
|
||||
return {
|
||||
...node,
|
||||
metaData: {
|
||||
...metaData,
|
||||
title,
|
||||
description,
|
||||
helpLinkUri: docLink(helpLinkPath),
|
||||
helpLinkUri: helpLinkPath ? docLink(helpLinkPath) : undefined,
|
||||
},
|
||||
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/use-dify/nodes/trigger/overview',
|
||||
'https://docs.dify.ai/en/self-host/use-dify/nodes/trigger/overview',
|
||||
)
|
||||
expect(screen.getByPlaceholderText('workflow.tabs.searchBlock')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
@ -6,6 +6,7 @@ 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,6 +5,10 @@ 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(() => ({
|
||||
@ -12,6 +16,12 @@ 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> = {
|
||||
@ -28,6 +38,7 @@ 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>)
|
||||
@ -45,28 +56,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`)
|
||||
expect(url).toBe(`${defaultDocBaseUrl}/en/home`)
|
||||
})
|
||||
|
||||
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`)
|
||||
expect(url).toBe(`${customBaseUrl}/en/home`)
|
||||
})
|
||||
|
||||
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/use-dify/getting-started/introduction')
|
||||
expect(url).toBe('https://docs.dify.ai/en/cloud/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/use-dify/getting-started/introduction')
|
||||
expect(url).toBe('https://docs.dify.ai/en/cloud/use-dify/getting-started/introduction')
|
||||
})
|
||||
})
|
||||
|
||||
@ -74,19 +85,31 @@ 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/use-dify/getting-started/introduction`)
|
||||
expect(url).toBe(`${defaultDocBaseUrl}/en/cloud/use-dify/getting-started/introduction`)
|
||||
})
|
||||
|
||||
it('should handle empty path', () => {
|
||||
const { result } = renderHook(() => useDocLink())
|
||||
const url = result.current()
|
||||
expect(url).toBe(`${defaultDocBaseUrl}/en`)
|
||||
expect(url).toBe(`${defaultDocBaseUrl}/en/home`)
|
||||
})
|
||||
|
||||
it('should handle undefined path', () => {
|
||||
const { result } = renderHook(() => useDocLink())
|
||||
const url = result.current(undefined)
|
||||
expect(url).toBe(`${defaultDocBaseUrl}/en`)
|
||||
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`)
|
||||
})
|
||||
})
|
||||
|
||||
@ -99,12 +122,12 @@ describe('useDocLink', () => {
|
||||
|
||||
const pathMap: DocPathMap = {
|
||||
'zh-Hans': '/use-dify/getting-started/introduction',
|
||||
'en-US': '/use-dify/getting-started/quick-start',
|
||||
'en-US': '/use-dify/build/mcp',
|
||||
}
|
||||
|
||||
const { result } = renderHook(() => useDocLink())
|
||||
const url = result.current('/use-dify/getting-started/quick-start' as DocPathWithoutLang, pathMap)
|
||||
expect(url).toBe(`${defaultDocBaseUrl}/zh/use-dify/getting-started/introduction`)
|
||||
const url = result.current('/use-dify/build/mcp', pathMap)
|
||||
expect(url).toBe(`${defaultDocBaseUrl}/zh/cloud/use-dify/getting-started/introduction`)
|
||||
})
|
||||
|
||||
it('should use default path when locale not in pathMap', () => {
|
||||
@ -115,18 +138,76 @@ describe('useDocLink', () => {
|
||||
|
||||
const pathMap: DocPathMap = {
|
||||
'zh-Hans': '/use-dify/getting-started/introduction',
|
||||
'en-US': '/use-dify/getting-started/quick-start',
|
||||
'en-US': '/use-dify/build/mcp',
|
||||
}
|
||||
|
||||
const { result } = renderHook(() => useDocLink())
|
||||
const url = result.current('/use-dify/getting-started/quick-start' as DocPathWithoutLang, pathMap)
|
||||
expect(url).toBe(`${defaultDocBaseUrl}/ja/use-dify/getting-started/quick-start`)
|
||||
const url = result.current('/use-dify/build/mcp', pathMap)
|
||||
expect(url).toBe(`${defaultDocBaseUrl}/ja/cloud/use-dify/build/mcp`)
|
||||
})
|
||||
|
||||
it('should handle undefined pathMap', () => {
|
||||
const { result } = renderHook(() => useDocLink())
|
||||
const url = result.current('/use-dify/getting-started/introduction', undefined)
|
||||
expect(url).toBe(`${defaultDocBaseUrl}/en/use-dify/getting-started/introduction`)
|
||||
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`)
|
||||
})
|
||||
})
|
||||
|
||||
@ -232,7 +313,7 @@ describe('useDocLink', () => {
|
||||
|
||||
const { result } = renderHook(() => useDocLink())
|
||||
const url = result.current('/use-dify/getting-started/introduction')
|
||||
expect(url).toBe(`${defaultDocBaseUrl}/zh/use-dify/getting-started/introduction`)
|
||||
expect(url).toBe(`${defaultDocBaseUrl}/zh/cloud/use-dify/getting-started/introduction`)
|
||||
})
|
||||
})
|
||||
|
||||
@ -240,15 +321,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/use-dify/getting-started/introduction#overview`)
|
||||
expect(url).toBe(`${defaultDocBaseUrl}/en/cloud/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/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`)
|
||||
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`)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,9 +1,10 @@
|
||||
import type { Locale } from '@/i18n-config/language'
|
||||
import type { DocPathWithoutLang } from '@/types/doc-paths'
|
||||
import type { DocPathWithoutLang, DocsProduct } 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 } from '@/types/doc-paths'
|
||||
import { apiReferencePathTranslations, docPathProductAvailability } from '@/types/doc-paths'
|
||||
|
||||
export const useLocale = () => {
|
||||
const { i18n } = useTranslation()
|
||||
@ -24,6 +25,44 @@ 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
|
||||
@ -44,6 +83,12 @@ export const useDocLink = (baseUrl?: string): ((path?: DocPathWithoutLang, pathM
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (!targetPath) {
|
||||
targetPath = getDocHomePath()
|
||||
}
|
||||
else {
|
||||
targetPath = getProductAwarePath(targetPath)
|
||||
}
|
||||
|
||||
return `${baseDocUrl}${languagePrefix}${targetPath}`
|
||||
},
|
||||
|
||||
@ -11,7 +11,8 @@ import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
const DOCS_JSON_URL = 'https://raw.githubusercontent.com/langgenius/dify-docs/refs/heads/main/docs.json'
|
||||
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 OUTPUT_PATH = path.resolve(__dirname, '../types/doc-paths.ts')
|
||||
|
||||
type NavItem = string | NavObject | NavItem[]
|
||||
@ -21,6 +22,9 @@ type NavObject = {
|
||||
groups?: NavItem[]
|
||||
dropdowns?: NavItem[]
|
||||
languages?: NavItem[]
|
||||
products?: NavItem[]
|
||||
tabs?: NavItem[]
|
||||
menu?: NavItem[]
|
||||
versions?: NavItem[]
|
||||
openapi?: string
|
||||
[key: string]: unknown
|
||||
@ -58,7 +62,15 @@ type DocsJson = {
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
const OPENAPI_BASE_URL = 'https://raw.githubusercontent.com/langgenius/dify-docs/refs/heads/main/'
|
||||
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)
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert summary to URL slug
|
||||
@ -112,6 +124,15 @@ 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)
|
||||
}
|
||||
@ -199,6 +220,15 @@ 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)
|
||||
@ -207,33 +237,68 @@ 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>): Record<string, Set<string>> {
|
||||
function groupPathsBySection(paths: Set<string>): { groups: Record<string, Set<string>>, productAvailability: ProductAvailability } {
|
||||
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
|
||||
|
||||
// Get section (first part of path)
|
||||
const parts = withoutLang.split('/')
|
||||
const section = parts[0]
|
||||
// Skip non-doc paths (like .json files for OpenAPI)
|
||||
if (withoutLang.endsWith('.json') || withoutLang === 'None')
|
||||
continue
|
||||
|
||||
if (!groups[section!])
|
||||
groups[section!] = new Set()
|
||||
addPathToGroup(groups, withoutLang)
|
||||
|
||||
groups[section!]!.add(withoutLang)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
return groups
|
||||
return {
|
||||
groups,
|
||||
productAvailability,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@ -251,6 +316,7 @@ function sectionToTypeName(section: string): string {
|
||||
*/
|
||||
function generateTypeDefinitions(
|
||||
groups: Record<string, Set<string>>,
|
||||
productAvailability: ProductAvailability,
|
||||
apiReferencePaths: string[],
|
||||
apiPathTranslations: Record<string, { zh?: string, ja?: string }>,
|
||||
): string {
|
||||
@ -258,11 +324,12 @@ function generateTypeDefinitions(
|
||||
'// GENERATE BY script',
|
||||
'// DON NOT EDIT IT MANUALLY',
|
||||
'//',
|
||||
'// Generated from: https://raw.githubusercontent.com/langgenius/dify-docs/refs/heads/main/docs.json',
|
||||
`// Generated from: ${DOCS_JSON_URL}`,
|
||||
`// Generated at: ${new Date().toISOString()}`,
|
||||
'',
|
||||
'// Language prefixes',
|
||||
'export type DocLanguage = \'en\' | \'zh\' | \'ja\'',
|
||||
'export type DocsProduct = \'cloud\' | \'self-host\'',
|
||||
'',
|
||||
]
|
||||
|
||||
@ -321,10 +388,14 @@ function generateTypeDefinitions(
|
||||
lines.push(' | `${DocPathWithoutLangBase}#${string}`')
|
||||
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}`')
|
||||
// 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('}')
|
||||
lines.push('')
|
||||
|
||||
// Generate API reference path translations map
|
||||
@ -423,12 +494,13 @@ async function main(): Promise<void> {
|
||||
console.log(`Generated ${Object.keys(apiPathTranslations).length} API path translations`)
|
||||
|
||||
// Group by section
|
||||
const groups = groupPathsBySection(allPaths)
|
||||
const { groups, productAvailability } = 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, uniqueEnApiPaths, apiPathTranslations)
|
||||
const tsContent = generateTypeDefinitions(groups, productAvailability, uniqueEnApiPaths, apiPathTranslations)
|
||||
|
||||
// Write to file
|
||||
await writeFile(OUTPUT_PATH, tsContent, 'utf-8')
|
||||
|
||||
@ -1,28 +1,127 @@
|
||||
// GENERATE BY script
|
||||
// DON NOT EDIT IT MANUALLY
|
||||
//
|
||||
// Generated from: https://raw.githubusercontent.com/langgenius/dify-docs/refs/heads/main/docs.json
|
||||
// Generated at: 2026-03-25T03:18:49.626Z
|
||||
// Generated from: https://raw.githubusercontent.com/langgenius/dify-docs/feat/audience-products/docs.json
|
||||
// Generated at: 2026-06-17T04:42:51.293Z
|
||||
|
||||
// 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/goto-anything'
|
||||
| '/use-dify/build/agent'
|
||||
| '/use-dify/build/chatbot'
|
||||
| '/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'
|
||||
@ -86,24 +185,8 @@ 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'
|
||||
@ -115,25 +198,42 @@ 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>
|
||||
|
||||
// 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'
|
||||
// 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'
|
||||
|
||||
// DevelopPlugin paths
|
||||
type DevelopPluginPath =
|
||||
@ -165,6 +265,7 @@ 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'
|
||||
@ -177,6 +278,129 @@ 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'
|
||||
@ -189,6 +413,12 @@ 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'
|
||||
@ -225,6 +455,8 @@ 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'
|
||||
@ -252,19 +484,24 @@ 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
|
||||
| SelfHostPath
|
||||
| HomePath
|
||||
| LearnPath
|
||||
| QuickStartPath
|
||||
| CliPath
|
||||
| DevelopPluginPath
|
||||
| SelfHostPath
|
||||
| DeployPath
|
||||
| ApiReferencePath
|
||||
|
||||
// Combined path without language prefix (supports optional #anchor)
|
||||
@ -272,6 +509,116 @@ 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/アノテーション管理/アノテーション返信を設定' },
|
||||
@ -284,6 +631,12 @@ 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/チャットメッセージ/生成を停止' },
|
||||
@ -320,6 +673,8 @@ 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/データセット/ナレッジベース詳細を取得' },
|
||||
@ -347,11 +702,10 @@ 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