Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1a9a1e821f | ||
|
|
c3e05045bb | ||
|
|
b6420ec6de | ||
|
|
616d4c6fdb | ||
|
|
167058ec51 | ||
|
|
0a27e38170 | ||
|
|
5ab3526845 | ||
|
|
59639ca9b2 | ||
|
|
66b8c42a25 |
@@ -607,19 +607,15 @@ class PublishedRagPipelineApi(Resource):
|
||||
# The role of the current user in the ta table must be admin, owner, or editor
|
||||
current_user, _ = current_account_with_tenant()
|
||||
rag_pipeline_service = RagPipelineService()
|
||||
with Session(db.engine) as session:
|
||||
pipeline = session.merge(pipeline)
|
||||
workflow = rag_pipeline_service.publish_workflow(
|
||||
session=session,
|
||||
pipeline=pipeline,
|
||||
account=current_user,
|
||||
)
|
||||
pipeline.is_published = True
|
||||
pipeline.workflow_id = workflow.id
|
||||
session.add(pipeline)
|
||||
workflow_created_at = TimestampField().format(workflow.created_at)
|
||||
|
||||
session.commit()
|
||||
workflow = rag_pipeline_service.publish_workflow(
|
||||
session=db.session, # type: ignore[reportArgumentType,arg-type]
|
||||
pipeline=pipeline,
|
||||
account=current_user,
|
||||
)
|
||||
pipeline.is_published = True
|
||||
pipeline.workflow_id = workflow.id
|
||||
db.session.commit()
|
||||
workflow_created_at = TimestampField().format(workflow.created_at)
|
||||
|
||||
return {
|
||||
"result": "success",
|
||||
|
||||
@@ -16,12 +16,14 @@ api = ExternalApi(
|
||||
inner_api_ns = Namespace("inner_api", description="Internal API operations", path="/")
|
||||
|
||||
from . import mail as _mail
|
||||
from .app import dsl as _app_dsl
|
||||
from .plugin import plugin as _plugin
|
||||
from .workspace import workspace as _workspace
|
||||
|
||||
api.add_namespace(inner_api_ns)
|
||||
|
||||
__all__ = [
|
||||
"_app_dsl",
|
||||
"_mail",
|
||||
"_plugin",
|
||||
"_workspace",
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
"""Inner API endpoints for app DSL import/export.
|
||||
|
||||
Called by the enterprise admin-api service. Import requires ``creator_email``
|
||||
to attribute the created app; workspace/membership validation is done by the
|
||||
Go admin-api caller.
|
||||
"""
|
||||
|
||||
from flask import request
|
||||
from flask_restx import Resource
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from controllers.common.schema import register_schema_model
|
||||
from controllers.console.wraps import setup_required
|
||||
from controllers.inner_api import inner_api_ns
|
||||
from controllers.inner_api.wraps import enterprise_inner_api_only
|
||||
from extensions.ext_database import db
|
||||
from models import Account, App
|
||||
from models.account import AccountStatus
|
||||
from services.app_dsl_service import AppDslService, ImportMode, ImportStatus
|
||||
|
||||
|
||||
class InnerAppDSLImportPayload(BaseModel):
|
||||
yaml_content: str = Field(description="YAML DSL content")
|
||||
creator_email: str = Field(description="Email of the workspace member who will own the imported app")
|
||||
name: str | None = Field(default=None, description="Override app name from DSL")
|
||||
description: str | None = Field(default=None, description="Override app description from DSL")
|
||||
|
||||
|
||||
register_schema_model(inner_api_ns, InnerAppDSLImportPayload)
|
||||
|
||||
|
||||
@inner_api_ns.route("/enterprise/workspaces/<string:workspace_id>/dsl/import")
|
||||
class EnterpriseAppDSLImport(Resource):
|
||||
@setup_required
|
||||
@enterprise_inner_api_only
|
||||
@inner_api_ns.doc("enterprise_app_dsl_import")
|
||||
@inner_api_ns.expect(inner_api_ns.models[InnerAppDSLImportPayload.__name__])
|
||||
@inner_api_ns.doc(
|
||||
responses={
|
||||
200: "Import completed",
|
||||
202: "Import pending (DSL version mismatch requires confirmation)",
|
||||
400: "Import failed (business error)",
|
||||
404: "Creator account not found or inactive",
|
||||
}
|
||||
)
|
||||
def post(self, workspace_id: str):
|
||||
"""Import a DSL into a workspace on behalf of a specified creator."""
|
||||
args = InnerAppDSLImportPayload.model_validate(inner_api_ns.payload or {})
|
||||
|
||||
account = _get_active_account(args.creator_email)
|
||||
if account is None:
|
||||
return {"message": f"account '{args.creator_email}' not found or inactive"}, 404
|
||||
|
||||
account.set_tenant_id(workspace_id)
|
||||
|
||||
with Session(db.engine) as session:
|
||||
dsl_service = AppDslService(session)
|
||||
result = dsl_service.import_app(
|
||||
account=account,
|
||||
import_mode=ImportMode.YAML_CONTENT,
|
||||
yaml_content=args.yaml_content,
|
||||
name=args.name,
|
||||
description=args.description,
|
||||
)
|
||||
session.commit()
|
||||
|
||||
if result.status == ImportStatus.FAILED:
|
||||
return result.model_dump(mode="json"), 400
|
||||
if result.status == ImportStatus.PENDING:
|
||||
return result.model_dump(mode="json"), 202
|
||||
return result.model_dump(mode="json"), 200
|
||||
|
||||
|
||||
@inner_api_ns.route("/enterprise/apps/<string:app_id>/dsl")
|
||||
class EnterpriseAppDSLExport(Resource):
|
||||
@setup_required
|
||||
@enterprise_inner_api_only
|
||||
@inner_api_ns.doc(
|
||||
"enterprise_app_dsl_export",
|
||||
responses={
|
||||
200: "Export successful",
|
||||
404: "App not found",
|
||||
},
|
||||
)
|
||||
def get(self, app_id: str):
|
||||
"""Export an app's DSL as YAML."""
|
||||
include_secret = request.args.get("include_secret", "false").lower() == "true"
|
||||
|
||||
app_model = db.session.query(App).filter_by(id=app_id).first()
|
||||
if not app_model:
|
||||
return {"message": "app not found"}, 404
|
||||
|
||||
data = AppDslService.export_dsl(
|
||||
app_model=app_model,
|
||||
include_secret=include_secret,
|
||||
)
|
||||
|
||||
return {"data": data}, 200
|
||||
|
||||
|
||||
def _get_active_account(email: str) -> Account | None:
|
||||
"""Look up an active account by email.
|
||||
|
||||
Workspace membership is already validated by the Go admin-api caller.
|
||||
"""
|
||||
account = db.session.query(Account).filter_by(email=email).first()
|
||||
if account is None or account.status != AccountStatus.ACTIVE:
|
||||
return None
|
||||
return account
|
||||
@@ -288,26 +288,27 @@ class TidbOnQdrantVector(BaseVector):
|
||||
if not ids:
|
||||
return
|
||||
|
||||
try:
|
||||
filter = models.Filter(
|
||||
must=[
|
||||
models.FieldCondition(
|
||||
key="metadata.doc_id",
|
||||
match=models.MatchAny(any=ids),
|
||||
),
|
||||
],
|
||||
)
|
||||
self._client.delete(
|
||||
collection_name=self._collection_name,
|
||||
points_selector=FilterSelector(filter=filter),
|
||||
)
|
||||
except UnexpectedResponse as e:
|
||||
# Collection does not exist, so return
|
||||
if e.status_code == 404:
|
||||
return
|
||||
# Some other error occurred, so re-raise the exception
|
||||
else:
|
||||
raise e
|
||||
batch_size = 1000
|
||||
for i in range(0, len(ids), batch_size):
|
||||
batch = ids[i : i + batch_size]
|
||||
|
||||
try:
|
||||
filter = models.Filter(
|
||||
must=[
|
||||
models.FieldCondition(
|
||||
key="metadata.doc_id",
|
||||
match=models.MatchAny(any=batch),
|
||||
),
|
||||
],
|
||||
)
|
||||
self._client.delete(
|
||||
collection_name=self._collection_name,
|
||||
points_selector=FilterSelector(filter=filter),
|
||||
)
|
||||
except UnexpectedResponse as e:
|
||||
# Collection does not exist, so return
|
||||
if e.status_code != 404:
|
||||
raise e
|
||||
|
||||
def text_exists(self, id: str) -> bool:
|
||||
all_collection_name = []
|
||||
|
||||
@@ -113,6 +113,7 @@ class DataSourceType(StrEnum):
|
||||
WEBSITE_CRAWL = "website_crawl"
|
||||
LOCAL_FILE = "local_file"
|
||||
ONLINE_DOCUMENT = "online_document"
|
||||
ONLINE_DRIVE = "online_drive"
|
||||
|
||||
|
||||
class ProcessRuleMode(StrEnum):
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "dify-api"
|
||||
version = "1.13.2"
|
||||
version = "1.13.3"
|
||||
requires-python = ">=3.11,<3.13"
|
||||
|
||||
dependencies = [
|
||||
|
||||
@@ -2,12 +2,11 @@ import json
|
||||
import logging
|
||||
import os
|
||||
from collections.abc import Sequence
|
||||
from typing import Literal
|
||||
from typing import Literal, NotRequired, TypedDict
|
||||
|
||||
import httpx
|
||||
from pydantic import TypeAdapter
|
||||
from tenacity import retry, retry_if_exception_type, stop_before_delay, wait_fixed
|
||||
from typing_extensions import TypedDict
|
||||
from werkzeug.exceptions import InternalServerError
|
||||
|
||||
from enums.cloud_plan import CloudPlan
|
||||
@@ -26,6 +25,103 @@ class SubscriptionPlan(TypedDict):
|
||||
expiration_date: int
|
||||
|
||||
|
||||
class _BillingQuota(TypedDict):
|
||||
size: int
|
||||
limit: int
|
||||
|
||||
|
||||
class _VectorSpaceQuota(TypedDict):
|
||||
size: float
|
||||
limit: int
|
||||
|
||||
|
||||
class _KnowledgeRateLimit(TypedDict):
|
||||
# NOTE (hj24):
|
||||
# 1. Return for sandbox users but is null for other plans, it's defined but never used.
|
||||
# 2. Keep it for compatibility for now, can be deprecated in future versions.
|
||||
size: NotRequired[int]
|
||||
# NOTE END
|
||||
limit: int
|
||||
|
||||
|
||||
class _BillingSubscription(TypedDict):
|
||||
plan: str
|
||||
interval: str
|
||||
education: bool
|
||||
|
||||
|
||||
class BillingInfo(TypedDict):
|
||||
"""Response of /subscription/info.
|
||||
|
||||
NOTE (hj24):
|
||||
- Fields not listed here (e.g. trigger_event, api_rate_limit) are stripped by TypeAdapter.validate_python()
|
||||
- To ensure the precision, billing may convert fields like int as str, be careful when use TypeAdapter:
|
||||
1. validate_python in non-strict mode will coerce it to the expected type
|
||||
2. In strict mode, it will raise ValidationError
|
||||
3. To preserve compatibility, always keep non-strict mode here and avoid strict mode
|
||||
"""
|
||||
|
||||
enabled: bool
|
||||
subscription: _BillingSubscription
|
||||
members: _BillingQuota
|
||||
apps: _BillingQuota
|
||||
vector_space: _VectorSpaceQuota
|
||||
knowledge_rate_limit: _KnowledgeRateLimit
|
||||
documents_upload_quota: _BillingQuota
|
||||
annotation_quota_limit: _BillingQuota
|
||||
docs_processing: str
|
||||
can_replace_logo: bool
|
||||
model_load_balancing_enabled: bool
|
||||
knowledge_pipeline_publish_enabled: bool
|
||||
next_credit_reset_date: NotRequired[int]
|
||||
|
||||
|
||||
_billing_info_adapter = TypeAdapter(BillingInfo)
|
||||
|
||||
|
||||
class KnowledgeRateLimitDict(TypedDict):
|
||||
limit: int
|
||||
subscription_plan: str
|
||||
|
||||
|
||||
class TenantFeaturePlanUsageDict(TypedDict):
|
||||
result: str
|
||||
history_id: str
|
||||
|
||||
|
||||
class LangContentDict(TypedDict):
|
||||
lang: str
|
||||
title: str
|
||||
subtitle: str
|
||||
body: str
|
||||
title_pic_url: str
|
||||
|
||||
|
||||
class NotificationDict(TypedDict):
|
||||
notification_id: str
|
||||
contents: dict[str, LangContentDict]
|
||||
frequency: Literal["once", "every_page_load"]
|
||||
|
||||
|
||||
class AccountNotificationDict(TypedDict, total=False):
|
||||
should_show: bool
|
||||
notification: NotificationDict
|
||||
shouldShow: bool
|
||||
notifications: list[dict]
|
||||
|
||||
|
||||
class UpsertNotificationDict(TypedDict):
|
||||
notification_id: str
|
||||
|
||||
|
||||
class BatchAddNotificationAccountsDict(TypedDict):
|
||||
count: int
|
||||
|
||||
|
||||
class DismissNotificationDict(TypedDict):
|
||||
success: bool
|
||||
|
||||
|
||||
class BillingService:
|
||||
base_url = os.environ.get("BILLING_API_URL", "BILLING_API_URL")
|
||||
secret_key = os.environ.get("BILLING_API_SECRET_KEY", "BILLING_API_SECRET_KEY")
|
||||
@@ -38,11 +134,11 @@ class BillingService:
|
||||
_PLAN_CACHE_TTL = 600
|
||||
|
||||
@classmethod
|
||||
def get_info(cls, tenant_id: str):
|
||||
def get_info(cls, tenant_id: str) -> BillingInfo:
|
||||
params = {"tenant_id": tenant_id}
|
||||
|
||||
billing_info = cls._send_request("GET", "/subscription/info", params=params)
|
||||
return billing_info
|
||||
return _billing_info_adapter.validate_python(billing_info)
|
||||
|
||||
@classmethod
|
||||
def get_tenant_feature_plan_usage_info(cls, tenant_id: str):
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import logging
|
||||
|
||||
from sqlalchemy import update
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from configs import dify_config
|
||||
from core.errors.error import QuotaExceededError
|
||||
@@ -29,14 +29,15 @@ class CreditPoolService:
|
||||
@classmethod
|
||||
def get_pool(cls, tenant_id: str, pool_type: str = "trial") -> TenantCreditPool | None:
|
||||
"""get tenant credit pool"""
|
||||
return (
|
||||
db.session.query(TenantCreditPool)
|
||||
.filter_by(
|
||||
tenant_id=tenant_id,
|
||||
pool_type=pool_type,
|
||||
with sessionmaker(db.engine, expire_on_commit=False).begin() as session:
|
||||
return session.scalar(
|
||||
select(TenantCreditPool)
|
||||
.where(
|
||||
TenantCreditPool.tenant_id == tenant_id,
|
||||
TenantCreditPool.pool_type == pool_type,
|
||||
)
|
||||
.limit(1)
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def check_credits_available(
|
||||
|
||||
@@ -312,7 +312,10 @@ class FeatureService:
|
||||
features.apps.limit = billing_info["apps"]["limit"]
|
||||
|
||||
if "vector_space" in billing_info:
|
||||
features.vector_space.size = billing_info["vector_space"]["size"]
|
||||
# NOTE (hj24): billing API returns vector_space.size as float (e.g. 0.0)
|
||||
# but LimitationModel.size is int; truncate here for compatibility
|
||||
features.vector_space.size = int(billing_info["vector_space"]["size"])
|
||||
# NOTE END
|
||||
features.vector_space.limit = billing_info["vector_space"]["limit"]
|
||||
|
||||
if "documents_upload_quota" in billing_info:
|
||||
@@ -333,7 +336,11 @@ class FeatureService:
|
||||
features.model_load_balancing_enabled = billing_info["model_load_balancing_enabled"]
|
||||
|
||||
if "knowledge_rate_limit" in billing_info:
|
||||
# NOTE (hj24):
|
||||
# 1. knowledge_rate_limit size is nullable, currently it's defined but never used, only limit is used.
|
||||
# 2. So be careful if later we decide to use [size], we cannot assume it is always present.
|
||||
features.knowledge_rate_limit = billing_info["knowledge_rate_limit"]["limit"]
|
||||
# NOTE END
|
||||
|
||||
if "knowledge_pipeline_publish_enabled" in billing_info:
|
||||
features.knowledge_pipeline.publish_enabled = billing_info["knowledge_pipeline_publish_enabled"]
|
||||
|
||||
@@ -156,7 +156,12 @@ def _execute_workflow_common(
|
||||
state_owner_user_id=workflow.created_by,
|
||||
)
|
||||
|
||||
# Execute the workflow with the trigger type
|
||||
# NOTE (hj24)
|
||||
# Release the transaction before the blocking generate() call,
|
||||
# otherwise the connection stays "idle in transaction" for hours.
|
||||
session.commit()
|
||||
# NOTE END
|
||||
|
||||
generator.generate(
|
||||
app_model=app_model,
|
||||
workflow=workflow,
|
||||
|
||||
@@ -163,11 +163,9 @@ class DifyTestContainers:
|
||||
wait_for_logs(self.redis, "Ready to accept connections", timeout=30)
|
||||
logger.info("Redis container is ready and accepting connections")
|
||||
|
||||
# Start Dify Sandbox container for code execution environment
|
||||
# Dify Sandbox provides a secure environment for executing user code
|
||||
# Use pinned version 0.2.12 to match production docker-compose configuration
|
||||
# Start Dify Sandbox container for code execution environment.
|
||||
logger.info("Initializing Dify Sandbox container...")
|
||||
self.dify_sandbox = DockerContainer(image="langgenius/dify-sandbox:0.2.12").with_network(self.network)
|
||||
self.dify_sandbox = DockerContainer(image="langgenius/dify-sandbox:0.2.14").with_network(self.network)
|
||||
self.dify_sandbox.with_exposed_ports(8194)
|
||||
self.dify_sandbox.env = {
|
||||
"API_KEY": "test_api_key",
|
||||
@@ -187,7 +185,7 @@ class DifyTestContainers:
|
||||
# Start Dify Plugin Daemon container for plugin management
|
||||
# Dify Plugin Daemon provides plugin lifecycle management and execution
|
||||
logger.info("Initializing Dify Plugin Daemon container...")
|
||||
self.dify_plugin_daemon = DockerContainer(image="langgenius/dify-plugin-daemon:0.5.4-local").with_network(
|
||||
self.dify_plugin_daemon = DockerContainer(image="langgenius/dify-plugin-daemon:0.5.3-local").with_network(
|
||||
self.network
|
||||
)
|
||||
self.dify_plugin_daemon.with_exposed_ports(5002)
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
"""Unit tests for inner_api app DSL import/export endpoints.
|
||||
|
||||
Tests Pydantic model validation, endpoint handler logic, and the
|
||||
_get_active_account helper. Auth/setup decorators are tested separately
|
||||
in test_auth_wraps.py; handler tests use inspect.unwrap() to bypass them.
|
||||
"""
|
||||
|
||||
import inspect
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
from pydantic import ValidationError
|
||||
|
||||
from controllers.inner_api.app.dsl import (
|
||||
EnterpriseAppDSLExport,
|
||||
EnterpriseAppDSLImport,
|
||||
InnerAppDSLImportPayload,
|
||||
_get_active_account,
|
||||
)
|
||||
from services.app_dsl_service import ImportStatus
|
||||
|
||||
|
||||
class TestInnerAppDSLImportPayload:
|
||||
"""Test InnerAppDSLImportPayload Pydantic model validation."""
|
||||
|
||||
def test_valid_payload_all_fields(self):
|
||||
data = {
|
||||
"yaml_content": "version: 0.6.0\nkind: app\n",
|
||||
"creator_email": "user@example.com",
|
||||
"name": "My App",
|
||||
"description": "A test app",
|
||||
}
|
||||
payload = InnerAppDSLImportPayload.model_validate(data)
|
||||
assert payload.yaml_content == data["yaml_content"]
|
||||
assert payload.creator_email == "user@example.com"
|
||||
assert payload.name == "My App"
|
||||
assert payload.description == "A test app"
|
||||
|
||||
def test_valid_payload_optional_fields_omitted(self):
|
||||
data = {
|
||||
"yaml_content": "version: 0.6.0\n",
|
||||
"creator_email": "user@example.com",
|
||||
}
|
||||
payload = InnerAppDSLImportPayload.model_validate(data)
|
||||
assert payload.name is None
|
||||
assert payload.description is None
|
||||
|
||||
def test_missing_yaml_content_fails(self):
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
InnerAppDSLImportPayload.model_validate({"creator_email": "a@b.com"})
|
||||
assert "yaml_content" in str(exc_info.value)
|
||||
|
||||
def test_missing_creator_email_fails(self):
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
InnerAppDSLImportPayload.model_validate({"yaml_content": "test"})
|
||||
assert "creator_email" in str(exc_info.value)
|
||||
|
||||
|
||||
class TestGetActiveAccount:
|
||||
"""Test the _get_active_account helper function."""
|
||||
|
||||
@patch("controllers.inner_api.app.dsl.db")
|
||||
def test_returns_active_account(self, mock_db):
|
||||
mock_account = MagicMock()
|
||||
mock_account.status = "active"
|
||||
mock_db.session.query.return_value.filter_by.return_value.first.return_value = mock_account
|
||||
|
||||
result = _get_active_account("user@example.com")
|
||||
|
||||
assert result is mock_account
|
||||
mock_db.session.query.return_value.filter_by.assert_called_once_with(email="user@example.com")
|
||||
|
||||
@patch("controllers.inner_api.app.dsl.db")
|
||||
def test_returns_none_for_inactive_account(self, mock_db):
|
||||
mock_account = MagicMock()
|
||||
mock_account.status = "banned"
|
||||
mock_db.session.query.return_value.filter_by.return_value.first.return_value = mock_account
|
||||
|
||||
result = _get_active_account("banned@example.com")
|
||||
|
||||
assert result is None
|
||||
|
||||
@patch("controllers.inner_api.app.dsl.db")
|
||||
def test_returns_none_for_nonexistent_email(self, mock_db):
|
||||
mock_db.session.query.return_value.filter_by.return_value.first.return_value = None
|
||||
|
||||
result = _get_active_account("missing@example.com")
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
class TestEnterpriseAppDSLImport:
|
||||
"""Test EnterpriseAppDSLImport endpoint handler logic.
|
||||
|
||||
Uses inspect.unwrap() to bypass auth/setup decorators.
|
||||
"""
|
||||
|
||||
@pytest.fixture
|
||||
def api_instance(self):
|
||||
return EnterpriseAppDSLImport()
|
||||
|
||||
@pytest.fixture
|
||||
def _mock_import_deps(self):
|
||||
"""Patch db, Session, and AppDslService for import handler tests."""
|
||||
with (
|
||||
patch("controllers.inner_api.app.dsl.db"),
|
||||
patch("controllers.inner_api.app.dsl.Session") as mock_session,
|
||||
patch("controllers.inner_api.app.dsl.AppDslService") as mock_dsl_cls,
|
||||
):
|
||||
mock_session.return_value.__enter__ = MagicMock(return_value=MagicMock())
|
||||
mock_session.return_value.__exit__ = MagicMock(return_value=False)
|
||||
self._mock_dsl = MagicMock()
|
||||
mock_dsl_cls.return_value = self._mock_dsl
|
||||
yield
|
||||
|
||||
def _make_import_result(self, status: ImportStatus, **kwargs) -> "Import":
|
||||
from services.app_dsl_service import Import
|
||||
|
||||
result = Import(
|
||||
id="import-id",
|
||||
status=status,
|
||||
app_id=kwargs.get("app_id", "app-123"),
|
||||
app_mode=kwargs.get("app_mode", "workflow"),
|
||||
)
|
||||
return result
|
||||
|
||||
@pytest.mark.usefixtures("_mock_import_deps")
|
||||
@patch("controllers.inner_api.app.dsl._get_active_account")
|
||||
def test_import_success_returns_200(self, mock_get_account, api_instance, app: Flask):
|
||||
mock_account = MagicMock()
|
||||
mock_get_account.return_value = mock_account
|
||||
self._mock_dsl.import_app.return_value = self._make_import_result(ImportStatus.COMPLETED)
|
||||
|
||||
unwrapped = inspect.unwrap(api_instance.post)
|
||||
with app.test_request_context():
|
||||
with patch("controllers.inner_api.app.dsl.inner_api_ns") as mock_ns:
|
||||
mock_ns.payload = {
|
||||
"yaml_content": "version: 0.6.0\n",
|
||||
"creator_email": "user@example.com",
|
||||
}
|
||||
result = unwrapped(api_instance, workspace_id="ws-123")
|
||||
|
||||
body, status_code = result
|
||||
assert status_code == 200
|
||||
assert body["status"] == "completed"
|
||||
mock_account.set_tenant_id.assert_called_once_with("ws-123")
|
||||
|
||||
@pytest.mark.usefixtures("_mock_import_deps")
|
||||
@patch("controllers.inner_api.app.dsl._get_active_account")
|
||||
def test_import_pending_returns_202(self, mock_get_account, api_instance, app: Flask):
|
||||
mock_get_account.return_value = MagicMock()
|
||||
self._mock_dsl.import_app.return_value = self._make_import_result(ImportStatus.PENDING)
|
||||
|
||||
unwrapped = inspect.unwrap(api_instance.post)
|
||||
with app.test_request_context():
|
||||
with patch("controllers.inner_api.app.dsl.inner_api_ns") as mock_ns:
|
||||
mock_ns.payload = {"yaml_content": "test", "creator_email": "u@e.com"}
|
||||
body, status_code = unwrapped(api_instance, workspace_id="ws-123")
|
||||
|
||||
assert status_code == 202
|
||||
assert body["status"] == "pending"
|
||||
|
||||
@pytest.mark.usefixtures("_mock_import_deps")
|
||||
@patch("controllers.inner_api.app.dsl._get_active_account")
|
||||
def test_import_failed_returns_400(self, mock_get_account, api_instance, app: Flask):
|
||||
mock_get_account.return_value = MagicMock()
|
||||
self._mock_dsl.import_app.return_value = self._make_import_result(ImportStatus.FAILED)
|
||||
|
||||
unwrapped = inspect.unwrap(api_instance.post)
|
||||
with app.test_request_context():
|
||||
with patch("controllers.inner_api.app.dsl.inner_api_ns") as mock_ns:
|
||||
mock_ns.payload = {"yaml_content": "test", "creator_email": "u@e.com"}
|
||||
body, status_code = unwrapped(api_instance, workspace_id="ws-123")
|
||||
|
||||
assert status_code == 400
|
||||
assert body["status"] == "failed"
|
||||
|
||||
@patch("controllers.inner_api.app.dsl._get_active_account")
|
||||
def test_import_account_not_found_returns_404(self, mock_get_account, api_instance, app: Flask):
|
||||
mock_get_account.return_value = None
|
||||
|
||||
unwrapped = inspect.unwrap(api_instance.post)
|
||||
with app.test_request_context():
|
||||
with patch("controllers.inner_api.app.dsl.inner_api_ns") as mock_ns:
|
||||
mock_ns.payload = {"yaml_content": "test", "creator_email": "missing@e.com"}
|
||||
result = unwrapped(api_instance, workspace_id="ws-123")
|
||||
|
||||
body, status_code = result
|
||||
assert status_code == 404
|
||||
assert "missing@e.com" in body["message"]
|
||||
|
||||
|
||||
class TestEnterpriseAppDSLExport:
|
||||
"""Test EnterpriseAppDSLExport endpoint handler logic.
|
||||
|
||||
Uses inspect.unwrap() to bypass auth/setup decorators.
|
||||
"""
|
||||
|
||||
@pytest.fixture
|
||||
def api_instance(self):
|
||||
return EnterpriseAppDSLExport()
|
||||
|
||||
@patch("controllers.inner_api.app.dsl.AppDslService")
|
||||
@patch("controllers.inner_api.app.dsl.db")
|
||||
def test_export_success_returns_200(self, mock_db, mock_dsl_cls, api_instance, app: Flask):
|
||||
mock_app = MagicMock()
|
||||
mock_db.session.query.return_value.filter_by.return_value.first.return_value = mock_app
|
||||
mock_dsl_cls.export_dsl.return_value = "version: 0.6.0\nkind: app\n"
|
||||
|
||||
unwrapped = inspect.unwrap(api_instance.get)
|
||||
with app.test_request_context("?include_secret=false"):
|
||||
result = unwrapped(api_instance, app_id="app-123")
|
||||
|
||||
body, status_code = result
|
||||
assert status_code == 200
|
||||
assert body["data"] == "version: 0.6.0\nkind: app\n"
|
||||
mock_dsl_cls.export_dsl.assert_called_once_with(app_model=mock_app, include_secret=False)
|
||||
|
||||
@patch("controllers.inner_api.app.dsl.AppDslService")
|
||||
@patch("controllers.inner_api.app.dsl.db")
|
||||
def test_export_with_secret(self, mock_db, mock_dsl_cls, api_instance, app: Flask):
|
||||
mock_app = MagicMock()
|
||||
mock_db.session.query.return_value.filter_by.return_value.first.return_value = mock_app
|
||||
mock_dsl_cls.export_dsl.return_value = "yaml-data"
|
||||
|
||||
unwrapped = inspect.unwrap(api_instance.get)
|
||||
with app.test_request_context("?include_secret=true"):
|
||||
result = unwrapped(api_instance, app_id="app-123")
|
||||
|
||||
body, status_code = result
|
||||
assert status_code == 200
|
||||
mock_dsl_cls.export_dsl.assert_called_once_with(app_model=mock_app, include_secret=True)
|
||||
|
||||
@patch("controllers.inner_api.app.dsl.db")
|
||||
def test_export_app_not_found_returns_404(self, mock_db, api_instance, app: Flask):
|
||||
mock_db.session.query.return_value.filter_by.return_value.first.return_value = None
|
||||
|
||||
unwrapped = inspect.unwrap(api_instance.get)
|
||||
with app.test_request_context("?include_secret=false"):
|
||||
result = unwrapped(api_instance, app_id="nonexistent")
|
||||
|
||||
body, status_code = result
|
||||
assert status_code == 404
|
||||
assert "app not found" in body["message"]
|
||||
@@ -8,6 +8,7 @@ import core.app.apps.pipeline.pipeline_generator as module
|
||||
from core.app.apps.exc import GenerateTaskStoppedError
|
||||
from core.app.entities.app_invoke_entities import InvokeFrom
|
||||
from core.datasource.entities.datasource_entities import DatasourceProviderType
|
||||
from models.enums import DataSourceType
|
||||
|
||||
|
||||
class FakeRagPipelineGenerateEntity(SimpleNamespace):
|
||||
@@ -558,6 +559,24 @@ def test_build_document_sets_metadata_for_builtin_fields(generator, mocker):
|
||||
assert document.doc_metadata
|
||||
|
||||
|
||||
def test_build_document_supports_online_drive_datasource_type(generator):
|
||||
document = generator._build_document(
|
||||
tenant_id="tenant",
|
||||
dataset_id="ds",
|
||||
built_in_field_enabled=True,
|
||||
datasource_type=DatasourceProviderType.ONLINE_DRIVE,
|
||||
datasource_info={"id": "file-1", "bucket": "bucket-1", "name": "drive.pdf", "type": "file"},
|
||||
created_from="rag-pipeline",
|
||||
position=1,
|
||||
account=_build_user(),
|
||||
batch="batch",
|
||||
document_form="text",
|
||||
)
|
||||
|
||||
assert DataSourceType(document.data_source_type) == DataSourceType.ONLINE_DRIVE
|
||||
assert document.name == "drive.pdf"
|
||||
|
||||
|
||||
def test_build_document_invalid_datasource_type(generator):
|
||||
with pytest.raises(ValueError):
|
||||
generator._build_document(
|
||||
|
||||
@@ -290,9 +290,19 @@ class TestBillingServiceSubscriptionInfo:
|
||||
# Arrange
|
||||
tenant_id = "tenant-123"
|
||||
expected_response = {
|
||||
"subscription_plan": "professional",
|
||||
"billing_cycle": "monthly",
|
||||
"status": "active",
|
||||
"enabled": True,
|
||||
"subscription": {"plan": "professional", "interval": "month", "education": False},
|
||||
"members": {"size": 1, "limit": 50},
|
||||
"apps": {"size": 1, "limit": 200},
|
||||
"vector_space": {"size": 0.0, "limit": 20480},
|
||||
"knowledge_rate_limit": {"limit": 1000},
|
||||
"documents_upload_quota": {"size": 0, "limit": 1000},
|
||||
"annotation_quota_limit": {"size": 0, "limit": 5000},
|
||||
"docs_processing": "top-priority",
|
||||
"can_replace_logo": True,
|
||||
"model_load_balancing_enabled": True,
|
||||
"knowledge_pipeline_publish_enabled": True,
|
||||
"next_credit_reset_date": 1775952000,
|
||||
}
|
||||
mock_send_request.return_value = expected_response
|
||||
|
||||
@@ -1009,17 +1019,14 @@ class TestBillingServiceEdgeCases:
|
||||
yield mock
|
||||
|
||||
def test_get_info_empty_response(self, mock_send_request):
|
||||
"""Test handling of empty billing info response."""
|
||||
# Arrange
|
||||
"""Empty response from billing API should raise ValidationError due to missing required fields."""
|
||||
from pydantic import ValidationError
|
||||
|
||||
tenant_id = "tenant-empty"
|
||||
mock_send_request.return_value = {}
|
||||
|
||||
# Act
|
||||
result = BillingService.get_info(tenant_id)
|
||||
|
||||
# Assert
|
||||
assert result == {}
|
||||
mock_send_request.assert_called_once()
|
||||
with pytest.raises(ValidationError):
|
||||
BillingService.get_info(tenant_id)
|
||||
|
||||
def test_update_tenant_feature_plan_usage_zero_delta(self, mock_send_request):
|
||||
"""Test updating tenant feature usage with zero delta (no change)."""
|
||||
@@ -1434,12 +1441,21 @@ class TestBillingServiceIntegrationScenarios:
|
||||
|
||||
# Step 1: Get current billing info
|
||||
mock_send_request.return_value = {
|
||||
"subscription_plan": "sandbox",
|
||||
"billing_cycle": "monthly",
|
||||
"status": "active",
|
||||
"enabled": True,
|
||||
"subscription": {"plan": "sandbox", "interval": "", "education": False},
|
||||
"members": {"size": 0, "limit": 1},
|
||||
"apps": {"size": 0, "limit": 5},
|
||||
"vector_space": {"size": 0.0, "limit": 50},
|
||||
"knowledge_rate_limit": {"limit": 10},
|
||||
"documents_upload_quota": {"size": 0, "limit": 50},
|
||||
"annotation_quota_limit": {"size": 0, "limit": 10},
|
||||
"docs_processing": "standard",
|
||||
"can_replace_logo": False,
|
||||
"model_load_balancing_enabled": False,
|
||||
"knowledge_pipeline_publish_enabled": False,
|
||||
}
|
||||
current_info = BillingService.get_info(tenant_id)
|
||||
assert current_info["subscription_plan"] == "sandbox"
|
||||
assert current_info["subscription"]["plan"] == "sandbox"
|
||||
|
||||
# Step 2: Get payment link for upgrade
|
||||
mock_send_request.return_value = {"payment_link": "https://payment.example.com/upgrade"}
|
||||
@@ -1553,3 +1569,140 @@ class TestBillingServiceIntegrationScenarios:
|
||||
mock_send_request.return_value = {"result": "success", "activated": True}
|
||||
activate_result = BillingService.EducationIdentity.activate(account, "token-123", "MIT", "student")
|
||||
assert activate_result["activated"] is True
|
||||
|
||||
|
||||
class TestBillingServiceSubscriptionInfoDataType:
|
||||
"""Unit tests for data type coercion in BillingService.get_info
|
||||
|
||||
1. Verifies the get_info returns correct Python types for numeric fields
|
||||
2. Ensure the compatibility regardless of what results the upstream billing API returns
|
||||
"""
|
||||
|
||||
@pytest.fixture
|
||||
def mock_send_request(self):
|
||||
with patch.object(BillingService, "_send_request") as mock:
|
||||
yield mock
|
||||
|
||||
@pytest.fixture
|
||||
def normal_billing_response(self) -> dict:
|
||||
return {
|
||||
"enabled": True,
|
||||
"subscription": {
|
||||
"plan": "team",
|
||||
"interval": "year",
|
||||
"education": False,
|
||||
},
|
||||
"members": {"size": 10, "limit": 50},
|
||||
"apps": {"size": 80, "limit": 200},
|
||||
"vector_space": {"size": 5120.75, "limit": 20480},
|
||||
"knowledge_rate_limit": {"limit": 1000},
|
||||
"documents_upload_quota": {"size": 450, "limit": 1000},
|
||||
"annotation_quota_limit": {"size": 1200, "limit": 5000},
|
||||
"docs_processing": "top-priority",
|
||||
"can_replace_logo": True,
|
||||
"model_load_balancing_enabled": True,
|
||||
"knowledge_pipeline_publish_enabled": True,
|
||||
"next_credit_reset_date": 1745971200,
|
||||
}
|
||||
|
||||
@pytest.fixture
|
||||
def string_billing_response(self) -> dict:
|
||||
return {
|
||||
"enabled": True,
|
||||
"subscription": {
|
||||
"plan": "team",
|
||||
"interval": "year",
|
||||
"education": False,
|
||||
},
|
||||
"members": {"size": "10", "limit": "50"},
|
||||
"apps": {"size": "80", "limit": "200"},
|
||||
"vector_space": {"size": 5120.75, "limit": "20480"},
|
||||
"knowledge_rate_limit": {"limit": "1000"},
|
||||
"documents_upload_quota": {"size": "450", "limit": "1000"},
|
||||
"annotation_quota_limit": {"size": "1200", "limit": "5000"},
|
||||
"docs_processing": "top-priority",
|
||||
"can_replace_logo": True,
|
||||
"model_load_balancing_enabled": True,
|
||||
"knowledge_pipeline_publish_enabled": True,
|
||||
"next_credit_reset_date": "1745971200",
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _assert_billing_info_types(result: dict):
|
||||
assert isinstance(result["enabled"], bool)
|
||||
assert isinstance(result["subscription"]["plan"], str)
|
||||
assert isinstance(result["subscription"]["interval"], str)
|
||||
assert isinstance(result["subscription"]["education"], bool)
|
||||
|
||||
assert isinstance(result["members"]["size"], int)
|
||||
assert isinstance(result["members"]["limit"], int)
|
||||
|
||||
assert isinstance(result["apps"]["size"], int)
|
||||
assert isinstance(result["apps"]["limit"], int)
|
||||
|
||||
assert isinstance(result["vector_space"]["size"], float)
|
||||
assert isinstance(result["vector_space"]["limit"], int)
|
||||
|
||||
assert isinstance(result["knowledge_rate_limit"]["limit"], int)
|
||||
|
||||
assert isinstance(result["documents_upload_quota"]["size"], int)
|
||||
assert isinstance(result["documents_upload_quota"]["limit"], int)
|
||||
|
||||
assert isinstance(result["annotation_quota_limit"]["size"], int)
|
||||
assert isinstance(result["annotation_quota_limit"]["limit"], int)
|
||||
|
||||
assert isinstance(result["docs_processing"], str)
|
||||
assert isinstance(result["can_replace_logo"], bool)
|
||||
assert isinstance(result["model_load_balancing_enabled"], bool)
|
||||
assert isinstance(result["knowledge_pipeline_publish_enabled"], bool)
|
||||
if "next_credit_reset_date" in result:
|
||||
assert isinstance(result["next_credit_reset_date"], int)
|
||||
|
||||
def test_get_info_with_normal_types(self, mock_send_request, normal_billing_response):
|
||||
"""When the billing API returns native numeric types, get_info should preserve them."""
|
||||
mock_send_request.return_value = normal_billing_response
|
||||
|
||||
result = BillingService.get_info("tenant-type-test")
|
||||
|
||||
self._assert_billing_info_types(result)
|
||||
mock_send_request.assert_called_once_with("GET", "/subscription/info", params={"tenant_id": "tenant-type-test"})
|
||||
|
||||
def test_get_info_with_string_types(self, mock_send_request, string_billing_response):
|
||||
"""When the billing API returns numeric values as strings, get_info should coerce them."""
|
||||
mock_send_request.return_value = string_billing_response
|
||||
|
||||
result = BillingService.get_info("tenant-type-test")
|
||||
|
||||
self._assert_billing_info_types(result)
|
||||
mock_send_request.assert_called_once_with("GET", "/subscription/info", params={"tenant_id": "tenant-type-test"})
|
||||
|
||||
def test_get_info_without_optional_fields(self, mock_send_request, string_billing_response):
|
||||
"""NotRequired fields can be absent without raising."""
|
||||
del string_billing_response["next_credit_reset_date"]
|
||||
mock_send_request.return_value = string_billing_response
|
||||
|
||||
result = BillingService.get_info("tenant-type-test")
|
||||
|
||||
assert "next_credit_reset_date" not in result
|
||||
self._assert_billing_info_types(result)
|
||||
|
||||
def test_get_info_with_extra_fields(self, mock_send_request, string_billing_response):
|
||||
"""Undefined fields are silently stripped by validate_python."""
|
||||
string_billing_response["new_feature"] = "something"
|
||||
mock_send_request.return_value = string_billing_response
|
||||
|
||||
result = BillingService.get_info("tenant-type-test")
|
||||
|
||||
# extra fields are dropped by TypeAdapter on TypedDict
|
||||
assert "new_feature" not in result
|
||||
self._assert_billing_info_types(result)
|
||||
|
||||
def test_get_info_missing_required_field_raises(self, mock_send_request, string_billing_response):
|
||||
"""Missing a required field should raise ValidationError."""
|
||||
from pydantic import ValidationError
|
||||
|
||||
del string_billing_response["members"]
|
||||
mock_send_request.return_value = string_billing_response
|
||||
|
||||
with pytest.raises(ValidationError):
|
||||
BillingService.get_info("tenant-type-test")
|
||||
|
||||
Generated
+1
-1
@@ -1457,7 +1457,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "dify-api"
|
||||
version = "1.13.2"
|
||||
version = "1.13.3"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "aliyun-log-python-sdk" },
|
||||
|
||||
@@ -21,7 +21,7 @@ services:
|
||||
|
||||
# API service
|
||||
api:
|
||||
image: langgenius/dify-api:1.13.2
|
||||
image: langgenius/dify-api:1.13.3
|
||||
restart: always
|
||||
environment:
|
||||
# Use the shared environment variables.
|
||||
@@ -63,7 +63,7 @@ services:
|
||||
# worker service
|
||||
# The Celery worker for processing all queues (dataset, workflow, mail, etc.)
|
||||
worker:
|
||||
image: langgenius/dify-api:1.13.2
|
||||
image: langgenius/dify-api:1.13.3
|
||||
restart: always
|
||||
environment:
|
||||
# Use the shared environment variables.
|
||||
@@ -102,7 +102,7 @@ services:
|
||||
# worker_beat service
|
||||
# Celery beat for scheduling periodic tasks.
|
||||
worker_beat:
|
||||
image: langgenius/dify-api:1.13.2
|
||||
image: langgenius/dify-api:1.13.3
|
||||
restart: always
|
||||
environment:
|
||||
# Use the shared environment variables.
|
||||
@@ -132,7 +132,7 @@ services:
|
||||
|
||||
# Frontend web application.
|
||||
web:
|
||||
image: langgenius/dify-web:1.13.2
|
||||
image: langgenius/dify-web:1.13.3
|
||||
restart: always
|
||||
environment:
|
||||
CONSOLE_API_URL: ${CONSOLE_API_URL:-}
|
||||
@@ -245,7 +245,7 @@ services:
|
||||
|
||||
# The DifySandbox
|
||||
sandbox:
|
||||
image: langgenius/dify-sandbox:0.2.12
|
||||
image: langgenius/dify-sandbox:0.2.14
|
||||
restart: always
|
||||
environment:
|
||||
# The DifySandbox configurations
|
||||
@@ -269,7 +269,7 @@ services:
|
||||
|
||||
# plugin daemon
|
||||
plugin_daemon:
|
||||
image: langgenius/dify-plugin-daemon:0.5.4-local
|
||||
image: langgenius/dify-plugin-daemon:0.5.3-local
|
||||
restart: always
|
||||
environment:
|
||||
# Use the shared environment variables.
|
||||
|
||||
@@ -97,7 +97,7 @@ services:
|
||||
|
||||
# The DifySandbox
|
||||
sandbox:
|
||||
image: langgenius/dify-sandbox:0.2.12
|
||||
image: langgenius/dify-sandbox:0.2.14
|
||||
restart: always
|
||||
env_file:
|
||||
- ./middleware.env
|
||||
@@ -123,7 +123,7 @@ services:
|
||||
|
||||
# plugin daemon
|
||||
plugin_daemon:
|
||||
image: langgenius/dify-plugin-daemon:0.5.4-local
|
||||
image: langgenius/dify-plugin-daemon:0.5.3-local
|
||||
restart: always
|
||||
env_file:
|
||||
- ./middleware.env
|
||||
|
||||
@@ -731,7 +731,7 @@ services:
|
||||
|
||||
# API service
|
||||
api:
|
||||
image: langgenius/dify-api:1.13.2
|
||||
image: langgenius/dify-api:1.13.3
|
||||
restart: always
|
||||
environment:
|
||||
# Use the shared environment variables.
|
||||
@@ -773,7 +773,7 @@ services:
|
||||
# worker service
|
||||
# The Celery worker for processing all queues (dataset, workflow, mail, etc.)
|
||||
worker:
|
||||
image: langgenius/dify-api:1.13.2
|
||||
image: langgenius/dify-api:1.13.3
|
||||
restart: always
|
||||
environment:
|
||||
# Use the shared environment variables.
|
||||
@@ -812,7 +812,7 @@ services:
|
||||
# worker_beat service
|
||||
# Celery beat for scheduling periodic tasks.
|
||||
worker_beat:
|
||||
image: langgenius/dify-api:1.13.2
|
||||
image: langgenius/dify-api:1.13.3
|
||||
restart: always
|
||||
environment:
|
||||
# Use the shared environment variables.
|
||||
@@ -842,7 +842,7 @@ services:
|
||||
|
||||
# Frontend web application.
|
||||
web:
|
||||
image: langgenius/dify-web:1.13.2
|
||||
image: langgenius/dify-web:1.13.3
|
||||
restart: always
|
||||
environment:
|
||||
CONSOLE_API_URL: ${CONSOLE_API_URL:-}
|
||||
@@ -955,7 +955,7 @@ services:
|
||||
|
||||
# The DifySandbox
|
||||
sandbox:
|
||||
image: langgenius/dify-sandbox:0.2.12
|
||||
image: langgenius/dify-sandbox:0.2.14
|
||||
restart: always
|
||||
environment:
|
||||
# The DifySandbox configurations
|
||||
@@ -979,7 +979,7 @@ services:
|
||||
|
||||
# plugin daemon
|
||||
plugin_daemon:
|
||||
image: langgenius/dify-plugin-daemon:0.5.4-local
|
||||
image: langgenius/dify-plugin-daemon:0.5.3-local
|
||||
restart: always
|
||||
environment:
|
||||
# Use the shared environment variables.
|
||||
|
||||
@@ -5,7 +5,8 @@ app:
|
||||
max_workers: 4
|
||||
max_requests: 50
|
||||
worker_timeout: 5
|
||||
python_path: /usr/local/bin/python3
|
||||
python_path: /opt/python/bin/python3
|
||||
nodejs_path: /usr/local/bin/node
|
||||
enable_network: True # please make sure there is no network risk in your environment
|
||||
allowed_syscalls: # please leave it empty if you have no idea how seccomp works
|
||||
proxy:
|
||||
|
||||
@@ -5,7 +5,7 @@ app:
|
||||
max_workers: 4
|
||||
max_requests: 50
|
||||
worker_timeout: 5
|
||||
python_path: /usr/local/bin/python3
|
||||
python_path: /opt/python/bin/python3
|
||||
python_lib_path:
|
||||
- /usr/local/lib/python3.10
|
||||
- /usr/lib/python3.10
|
||||
|
||||
@@ -142,7 +142,7 @@ const ApiKeyModal = ({
|
||||
onExtraButtonClick={onRemove}
|
||||
disabled={disabled || isLoading || doingAction}
|
||||
clickOutsideNotClose={true}
|
||||
wrapperClassName="!z-[101]"
|
||||
wrapperClassName="!z-[1002]"
|
||||
>
|
||||
{pluginPayload.detail && (
|
||||
<ReadmeEntrance pluginDetail={pluginPayload.detail} showType={ReadmeShowType.modal} />
|
||||
|
||||
@@ -157,7 +157,7 @@ const OAuthClientSettings = ({
|
||||
)
|
||||
}
|
||||
containerClassName="pt-0"
|
||||
wrapperClassName="!z-[101]"
|
||||
wrapperClassName="!z-[1002]"
|
||||
clickOutsideNotClose={true}
|
||||
>
|
||||
{pluginPayload.detail && (
|
||||
|
||||
+7
-7
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "dify-web",
|
||||
"type": "module",
|
||||
"version": "1.13.2",
|
||||
"version": "1.13.3",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@10.32.1",
|
||||
"imports": {
|
||||
@@ -125,15 +125,15 @@
|
||||
"mime": "4.1.0",
|
||||
"mitt": "3.0.1",
|
||||
"negotiator": "1.0.0",
|
||||
"next": "16.2.1",
|
||||
"next": "16.2.3",
|
||||
"next-themes": "0.4.6",
|
||||
"nuqs": "2.8.9",
|
||||
"pinyin-pro": "3.28.0",
|
||||
"qrcode.react": "4.2.0",
|
||||
"qs": "6.15.0",
|
||||
"react": "19.2.4",
|
||||
"react": "19.2.5",
|
||||
"react-18-input-autosize": "3.0.0",
|
||||
"react-dom": "19.2.4",
|
||||
"react-dom": "19.2.5",
|
||||
"react-easy-crop": "5.5.6",
|
||||
"react-hotkeys-hook": "5.2.4",
|
||||
"react-i18next": "16.6.1",
|
||||
@@ -173,8 +173,8 @@
|
||||
"@mdx-js/loader": "3.1.1",
|
||||
"@mdx-js/react": "3.1.1",
|
||||
"@mdx-js/rollup": "3.1.1",
|
||||
"@next/eslint-plugin-next": "16.2.1",
|
||||
"@next/mdx": "16.2.1",
|
||||
"@next/eslint-plugin-next": "16.2.3",
|
||||
"@next/mdx": "16.2.3",
|
||||
"@rgrove/parse-xml": "4.2.0",
|
||||
"@storybook/addon-docs": "10.3.1",
|
||||
"@storybook/addon-links": "10.3.1",
|
||||
@@ -231,7 +231,7 @@
|
||||
"nock": "14.0.11",
|
||||
"postcss": "8.5.8",
|
||||
"postcss-js": "5.1.0",
|
||||
"react-server-dom-webpack": "19.2.4",
|
||||
"react-server-dom-webpack": "19.2.5",
|
||||
"sass": "1.98.0",
|
||||
"storybook": "10.3.1",
|
||||
"tailwindcss": "3.4.19",
|
||||
|
||||
Generated
+533
-533
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user