Quellcode durchsuchen

Merge branch 'main' into feat/r2

tags/2.0.0-beta.1
jyong vor 5 Monaten
Ursprung
Commit
a025db137d
100 geänderte Dateien mit 3290 neuen und 2853 gelöschten Zeilen
  1. 1
    0
      .devcontainer/post_create_command.sh
  2. 8
    0
      .github/workflows/translate-i18n-base-on-english.yml
  3. 1
    1
      README.md
  4. 1
    1
      README_AR.md
  5. 1
    1
      README_BN.md
  6. 1
    1
      README_CN.md
  7. 1
    1
      README_DE.md
  8. 1
    1
      README_JA.md
  9. 1
    1
      README_KL.md
  10. 1
    1
      README_KR.md
  11. 1
    1
      README_SI.md
  12. 1
    1
      README_TR.md
  13. 1
    1
      README_TW.md
  14. 1
    0
      api/.env.example
  15. 5
    0
      api/configs/middleware/vdb/qdrant_config.py
  16. 1
    1
      api/configs/packaging/__init__.py
  17. 0
    4
      api/contexts/__init__.py
  18. 6
    2
      api/controllers/console/workspace/plugin.py
  19. 9
    2
      api/controllers/inner_api/plugin/wraps.py
  20. 7
    7
      api/controllers/service_api/app/annotation.py
  21. 6
    1
      api/controllers/service_api/wraps.py
  22. 1
    1
      api/core/agent/cot_agent_runner.py
  23. 1
    1
      api/core/agent/entities.py
  24. 1
    1
      api/core/agent/fc_agent_runner.py
  25. 1
    1
      api/core/app/app_config/easy_ui_based_app/agent/manager.py
  26. 32
    16
      api/core/app/apps/advanced_chat/app_generator.py
  27. 1
    0
      api/core/app/apps/advanced_chat/generate_task_pipeline.py
  28. 31
    13
      api/core/app/apps/agent_chat/app_generator.py
  29. 13
    12
      api/core/app/apps/chat/app_generator.py
  30. 23
    21
      api/core/app/apps/completion/app_generator.py
  31. 31
    15
      api/core/app/apps/workflow/app_generator.py
  32. 0
    2
      api/core/app/task_pipeline/easy_ui_based_generate_task_pipeline.py
  33. 7
    3
      api/core/llm_generator/llm_generator.py
  34. 2
    1
      api/core/plugin/backwards_invocation/model.py
  35. 6
    1
      api/core/plugin/entities/plugin_daemon.py
  36. 17
    3
      api/core/plugin/impl/plugin.py
  37. 4
    0
      api/core/rag/datasource/vdb/qdrant/qdrant_vector.py
  38. 3
    0
      api/core/rag/datasource/vdb/tidb_on_qdrant/tidb_on_qdrant_vector.py
  39. 24
    271
      api/core/tools/utils/web_reader_tool.py
  40. 14
    1
      api/core/workflow/graph_engine/graph_engine.py
  41. 4
    2
      api/core/workflow/nodes/agent/agent_node.py
  42. 2
    2
      api/core/workflow/nodes/agent/entities.py
  43. 63
    11
      api/core/workflow/nodes/document_extractor/node.py
  44. 8
    1
      api/core/workflow/nodes/http_request/executor.py
  45. 15
    1
      api/core/workflow/nodes/iteration/iteration_node.py
  46. 21
    18
      api/core/workflow/workflow_cycle_manager.py
  47. 24
    4
      api/extensions/ext_login.py
  48. 42
    24
      api/extensions/ext_otel.py
  49. 2
    0
      api/fields/app_fields.py
  50. 1
    34
      api/libs/login.py
  51. 9
    0
      api/models/model.py
  52. 23
    3
      api/models/workflow.py
  53. 2
    1
      api/pyproject.toml
  54. 9
    11
      api/services/dataset_service.py
  55. 10
    1
      api/services/plugin/plugin_service.py
  56. 11
    28
      api/tasks/remove_app_and_related_data_task.py
  57. 3
    1
      api/tests/unit_tests/core/workflow/nodes/http_request/test_http_request_executor.py
  58. 180
    1
      api/tests/unit_tests/core/workflow/nodes/test_document_extractor_node.py
  59. 18
    4
      api/tests/unit_tests/models/test_workflow.py
  60. 158
    0
      api/tests/unit_tests/services/test_dataset_permission.py
  61. 2212
    2201
      api/uv.lock
  62. 2
    1
      docker/.env.example
  63. 5
    5
      docker/docker-compose-template.yaml
  64. 1
    1
      docker/docker-compose.middleware.yaml
  65. 7
    6
      docker/docker-compose.yaml
  66. BIN
      images/GitHub_README_if.png
  67. 1
    1
      web/.env.example
  68. 15
    7
      web/app/(commonLayout)/apps/AppCard.tsx
  69. 1
    1
      web/app/(commonLayout)/datasets/DatasetCard.tsx
  70. 9
    1
      web/app/account/header.tsx
  71. 1
    14
      web/app/components/app/log/list.tsx
  72. 6
    4
      web/app/components/base/chat/chat-with-history/sidebar/index.tsx
  73. 1
    3
      web/app/components/base/chat/chat/answer/index.tsx
  74. 9
    6
      web/app/components/base/chat/embedded-chatbot/header/index.tsx
  75. 9
    6
      web/app/components/base/chat/embedded-chatbot/index.tsx
  76. 3
    10
      web/app/components/base/logo/dify-logo.tsx
  77. 22
    11
      web/app/components/base/mermaid/index.tsx
  78. 15
    4
      web/app/components/base/mermaid/utils.ts
  79. 3
    3
      web/app/components/base/tab-slider/index.tsx
  80. 14
    6
      web/app/components/custom/custom-web-app-brand/index.tsx
  81. 1
    0
      web/app/components/datasets/documents/detail/completed/new-child-segment.tsx
  82. 3
    0
      web/app/components/datasets/documents/detail/new-segment.tsx
  83. 10
    1
      web/app/components/header/account-about/index.tsx
  84. 1
    2
      web/app/components/header/account-setting/model-provider-page/provider-icon/index.tsx
  85. 16
    2
      web/app/components/header/index.tsx
  86. 1
    1
      web/app/components/plugins/plugin-detail-panel/action-list.tsx
  87. 2
    1
      web/app/components/plugins/plugin-detail-panel/multiple-tool-selector/index.tsx
  88. 3
    1
      web/app/components/plugins/plugin-detail-panel/tool-selector/index.tsx
  89. 11
    2
      web/app/components/plugins/plugin-page/plugins-panel.tsx
  90. 5
    0
      web/app/components/plugins/types.ts
  91. 7
    5
      web/app/components/share/text-generation/index.tsx
  92. 1
    1
      web/app/components/workflow/nodes/agent/default.ts
  93. 7
    1
      web/app/components/workflow/nodes/http/use-config.ts
  94. 1
    1
      web/app/components/workflow/nodes/tool/panel.tsx
  95. 1
    1
      web/app/reset-password/set-password/page.tsx
  96. 1
    1
      web/app/routePrefixHandle.tsx
  97. 9
    1
      web/app/signin/_header.tsx
  98. 2
    2
      web/config/index.ts
  99. 0
    1
      web/i18n/de-DE/tools.ts
  100. 0
    0
      web/i18n/de-DE/workflow.ts

+ 1
- 0
.devcontainer/post_create_command.sh Datei anzeigen

@@ -7,6 +7,7 @@ pipx install uv
echo 'alias start-api="cd /workspaces/dify/api && uv run python -m flask run --host 0.0.0.0 --port=5001 --debug"' >> ~/.bashrc
echo 'alias start-worker="cd /workspaces/dify/api && uv run python -m celery -A app.celery worker -P gevent -c 1 --loglevel INFO -Q dataset,generation,mail,ops_trace,app_deletion"' >> ~/.bashrc
echo 'alias start-web="cd /workspaces/dify/web && pnpm dev"' >> ~/.bashrc
echo 'alias start-web-prod="cd /workspaces/dify/web && pnpm build && pnpm start"' >> ~/.bashrc
echo 'alias start-containers="cd /workspaces/dify/docker && docker-compose -f docker-compose.middleware.yaml -p dify --env-file middleware.env up -d"' >> ~/.bashrc
echo 'alias stop-containers="cd /workspaces/dify/docker && docker-compose -f docker-compose.middleware.yaml -p dify --env-file middleware.env down"' >> ~/.bashrc


+ 8
- 0
.github/workflows/translate-i18n-base-on-english.yml Datei anzeigen

@@ -31,11 +31,19 @@ jobs:
echo "FILES_CHANGED=false" >> $GITHUB_ENV
fi

- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 10
run_install: false

- name: Set up Node.js
if: env.FILES_CHANGED == 'true'
uses: actions/setup-node@v4
with:
node-version: 'lts/*'
cache: pnpm
cache-dependency-path: ./web/package.json

- name: Install dependencies
if: env.FILES_CHANGED == 'true'

+ 1
- 1
README.md Datei anzeigen

@@ -235,7 +235,7 @@ At the same time, please consider supporting Dify by sharing it on social media

## Community & contact

- [Github Discussion](https://github.com/langgenius/dify/discussions). Best for: sharing feedback and asking questions.
- [GitHub Discussion](https://github.com/langgenius/dify/discussions). Best for: sharing feedback and asking questions.
- [GitHub Issues](https://github.com/langgenius/dify/issues). Best for: bugs you encounter using Dify.AI, and feature proposals. See our [Contribution Guide](https://github.com/langgenius/dify/blob/main/CONTRIBUTING.md).
- [Discord](https://discord.gg/FngNHpbcY7). Best for: sharing your applications and hanging out with the community.
- [X(Twitter)](https://twitter.com/dify_ai). Best for: sharing your applications and hanging out with the community.

+ 1
- 1
README_AR.md Datei anzeigen

@@ -223,7 +223,7 @@ docker compose up -d
</a>

## المجتمع والاتصال
- [مناقشة Github](https://github.com/langgenius/dify/discussions). الأفضل لـ: مشاركة التعليقات وطرح الأسئلة.
- [مناقشة GitHub](https://github.com/langgenius/dify/discussions). الأفضل لـ: مشاركة التعليقات وطرح الأسئلة.
- [المشكلات على GitHub](https://github.com/langgenius/dify/issues). الأفضل لـ: الأخطاء التي تواجهها في استخدام Dify.AI، واقتراحات الميزات. انظر [دليل المساهمة](https://github.com/langgenius/dify/blob/main/CONTRIBUTING.md).
- [Discord](https://discord.gg/FngNHpbcY7). الأفضل لـ: مشاركة تطبيقاتك والترفيه مع المجتمع.
- [تويتر](https://twitter.com/dify_ai). الأفضل لـ: مشاركة تطبيقاتك والترفيه مع المجتمع.

+ 1
- 1
README_BN.md Datei anzeigen

@@ -234,7 +234,7 @@ GitHub-এ ডিফাইকে স্টার দিয়ে রাখুন

## কমিউনিটি এবং যোগাযোগ

- [Github Discussion](https://github.com/langgenius/dify/discussions) ফিডব্যাক এবং প্রতিক্রিয়া জানানোর মাধ্যম।
- [GitHub Discussion](https://github.com/langgenius/dify/discussions) ফিডব্যাক এবং প্রতিক্রিয়া জানানোর মাধ্যম।
- [GitHub Issues](https://github.com/langgenius/dify/issues). Dify.AI ব্যবহার করে আপনি যেসব বাগের সম্মুখীন হন এবং ফিচার প্রস্তাবনা। আমাদের [অবদান নির্দেশিকা](https://github.com/langgenius/dify/blob/main/CONTRIBUTING.md) দেখুন।
- [Discord](https://discord.gg/FngNHpbcY7) আপনার এপ্লিকেশন শেয়ার এবং কমিউনিটি আড্ডার মাধ্যম।
- [X(Twitter)](https://twitter.com/dify_ai) আপনার এপ্লিকেশন শেয়ার এবং কমিউনিটি আড্ডার মাধ্যম।

+ 1
- 1
README_CN.md Datei anzeigen

@@ -243,7 +243,7 @@ docker compose up -d

我们欢迎您为 Dify 做出贡献,以帮助改善 Dify。包括:提交代码、问题、新想法,或分享您基于 Dify 创建的有趣且有用的 AI 应用程序。同时,我们也欢迎您在不同的活动、会议和社交媒体上分享 Dify。

- [Github Discussion](https://github.com/langgenius/dify/discussions). 👉:分享您的应用程序并与社区交流。
- [GitHub Discussion](https://github.com/langgenius/dify/discussions). 👉:分享您的应用程序并与社区交流。
- [GitHub Issues](https://github.com/langgenius/dify/issues)。👉:使用 Dify.AI 时遇到的错误和问题,请参阅[贡献指南](CONTRIBUTING.md)。
- [电子邮件支持](mailto:hello@dify.ai?subject=[GitHub]Questions%20About%20Dify)。👉:关于使用 Dify.AI 的问题。
- [Discord](https://discord.gg/FngNHpbcY7)。👉:分享您的应用程序并与社区交流。

+ 1
- 1
README_DE.md Datei anzeigen

@@ -230,7 +230,7 @@ Falls Sie Code beitragen möchten, lesen Sie bitte unseren [Contribution Guide](

## Gemeinschaft & Kontakt

* [Github Discussion](https://github.com/langgenius/dify/discussions). Am besten geeignet für: den Austausch von Feedback und das Stellen von Fragen.
* [GitHub Discussion](https://github.com/langgenius/dify/discussions). Am besten geeignet für: den Austausch von Feedback und das Stellen von Fragen.
* [GitHub Issues](https://github.com/langgenius/dify/issues). Am besten für: Fehler, auf die Sie bei der Verwendung von Dify.AI stoßen, und Funktionsvorschläge. Siehe unseren [Contribution Guide](https://github.com/langgenius/dify/blob/main/CONTRIBUTING.md).
* [Discord](https://discord.gg/FngNHpbcY7). Am besten geeignet für: den Austausch von Bewerbungen und den Austausch mit der Community.
* [X(Twitter)](https://twitter.com/dify_ai). Am besten geeignet für: den Austausch von Bewerbungen und den Austausch mit der Community.

+ 1
- 1
README_JA.md Datei anzeigen

@@ -236,7 +236,7 @@ docker compose up -d

## コミュニティ & お問い合わせ

* [Github Discussion](https://github.com/langgenius/dify/discussions). 主に: フィードバックの共有や質問。
* [GitHub Discussion](https://github.com/langgenius/dify/discussions). 主に: フィードバックの共有や質問。
* [GitHub Issues](https://github.com/langgenius/dify/issues). 主に: Dify.AIを使用する際に発生するエラーや問題については、[貢献ガイド](CONTRIBUTING_JA.md)を参照してください
* [Discord](https://discord.gg/FngNHpbcY7). 主に: アプリケーションの共有やコミュニティとの交流。
* [X(Twitter)](https://twitter.com/dify_ai). 主に: アプリケーションの共有やコミュニティとの交流。

+ 1
- 1
README_KL.md Datei anzeigen

@@ -235,7 +235,7 @@ At the same time, please consider supporting Dify by sharing it on social media

## Community & Contact

* [Github Discussion](https://github.com/langgenius/dify/discussions
* [GitHub Discussion](https://github.com/langgenius/dify/discussions

). Best for: sharing feedback and asking questions.
* [GitHub Issues](https://github.com/langgenius/dify/issues). Best for: bugs you encounter using Dify.AI, and feature proposals. See our [Contribution Guide](https://github.com/langgenius/dify/blob/main/CONTRIBUTING.md).

+ 1
- 1
README_KR.md Datei anzeigen

@@ -229,7 +229,7 @@ Dify를 Kubernetes에 배포하고 프리미엄 스케일링 설정을 구성했

## 커뮤니티 & 연락처

* [Github 토론](https://github.com/langgenius/dify/discussions). 피드백 공유 및 질문하기에 적합합니다.
* [GitHub 토론](https://github.com/langgenius/dify/discussions). 피드백 공유 및 질문하기에 적합합니다.
* [GitHub 이슈](https://github.com/langgenius/dify/issues). Dify.AI 사용 중 발견한 버그와 기능 제안에 적합합니다. [기여 가이드](https://github.com/langgenius/dify/blob/main/CONTRIBUTING.md)를 참조하세요.
* [디스코드](https://discord.gg/FngNHpbcY7). 애플리케이션 공유 및 커뮤니티와 소통하기에 적합합니다.
* [트위터](https://twitter.com/dify_ai). 애플리케이션 공유 및 커뮤니티와 소통하기에 적합합니다.

+ 1
- 1
README_SI.md Datei anzeigen

@@ -229,7 +229,7 @@ Za tiste, ki bi radi prispevali kodo, si oglejte naš vodnik za prispevke . Hkra

## Skupnost in stik

* [Github Discussion](https://github.com/langgenius/dify/discussions). Najboljše za: izmenjavo povratnih informacij in postavljanje vprašanj.
* [GitHub Discussion](https://github.com/langgenius/dify/discussions). Najboljše za: izmenjavo povratnih informacij in postavljanje vprašanj.
* [GitHub Issues](https://github.com/langgenius/dify/issues). Najboljše za: hrošče, na katere naletite pri uporabi Dify.AI, in predloge funkcij. Oglejte si naš [vodnik za prispevke](https://github.com/langgenius/dify/blob/main/CONTRIBUTING.md).
* [Discord](https://discord.gg/FngNHpbcY7). Najboljše za: deljenje vaših aplikacij in druženje s skupnostjo.
* [X(Twitter)](https://twitter.com/dify_ai). Najboljše za: deljenje vaših aplikacij in druženje s skupnostjo.

+ 1
- 1
README_TR.md Datei anzeigen

@@ -227,7 +227,7 @@ Aynı zamanda, lütfen Dify'ı sosyal medyada, etkinliklerde ve konferanslarda p

## Topluluk & iletişim

* [Github Tartışmaları](https://github.com/langgenius/dify/discussions). En uygun: geri bildirim paylaşmak ve soru sormak için.
* [GitHub Tartışmaları](https://github.com/langgenius/dify/discussions). En uygun: geri bildirim paylaşmak ve soru sormak için.
* [GitHub Sorunları](https://github.com/langgenius/dify/issues). En uygun: Dify.AI kullanırken karşılaştığınız hatalar ve özellik önerileri için. [Katkı Kılavuzumuza](https://github.com/langgenius/dify/blob/main/CONTRIBUTING.md) bakın.
* [Discord](https://discord.gg/FngNHpbcY7). En uygun: uygulamalarınızı paylaşmak ve toplulukla vakit geçirmek için.
* [X(Twitter)](https://twitter.com/dify_ai). En uygun: uygulamalarınızı paylaşmak ve toplulukla vakit geçirmek için.

+ 1
- 1
README_TW.md Datei anzeigen

@@ -233,7 +233,7 @@ Dify 的所有功能都提供相應的 API,因此您可以輕鬆地將 Dify

## 社群與聯絡方式

- [Github Discussion](https://github.com/langgenius/dify/discussions):最適合分享反饋和提問。
- [GitHub Discussion](https://github.com/langgenius/dify/discussions):最適合分享反饋和提問。
- [GitHub Issues](https://github.com/langgenius/dify/issues):最適合報告使用 Dify.AI 時遇到的問題和提出功能建議。請參閱我們的[貢獻指南](https://github.com/langgenius/dify/blob/main/CONTRIBUTING.md)。
- [Discord](https://discord.gg/FngNHpbcY7):最適合分享您的應用程式並與社群互動。
- [X(Twitter)](https://twitter.com/dify_ai):最適合分享您的應用程式並與社群互動。

+ 1
- 0
api/.env.example Datei anzeigen

@@ -152,6 +152,7 @@ QDRANT_API_KEY=difyai123456
QDRANT_CLIENT_TIMEOUT=20
QDRANT_GRPC_ENABLED=false
QDRANT_GRPC_PORT=6334
QDRANT_REPLICATION_FACTOR=1

#Couchbase configuration
COUCHBASE_CONNECTION_STRING=127.0.0.1

+ 5
- 0
api/configs/middleware/vdb/qdrant_config.py Datei anzeigen

@@ -33,3 +33,8 @@ class QdrantConfig(BaseSettings):
description="Port number for gRPC connection to Qdrant server (default is 6334)",
default=6334,
)

QDRANT_REPLICATION_FACTOR: PositiveInt = Field(
description="Replication factor for Qdrant collections (default is 1)",
default=1,
)

+ 1
- 1
api/configs/packaging/__init__.py Datei anzeigen

@@ -9,7 +9,7 @@ class PackagingInfo(BaseSettings):

CURRENT_VERSION: str = Field(
description="Dify version",
default="1.4.0",
default="1.4.1",
)

COMMIT_SHA: str = Field(

+ 0
- 4
api/contexts/__init__.py Datei anzeigen

@@ -12,10 +12,6 @@ if TYPE_CHECKING:
from core.workflow.entities.variable_pool import VariablePool


tenant_id: ContextVar[str] = ContextVar("tenant_id")

workflow_variable_pool: ContextVar["VariablePool"] = ContextVar("workflow_variable_pool")

"""
To avoid race-conditions caused by gunicorn thread recycling, using RecyclableContextVar to replace with
"""

+ 6
- 2
api/controllers/console/workspace/plugin.py Datei anzeigen

@@ -41,12 +41,16 @@ class PluginListApi(Resource):
@account_initialization_required
def get(self):
tenant_id = current_user.current_tenant_id
parser = reqparse.RequestParser()
parser.add_argument("page", type=int, required=False, location="args", default=1)
parser.add_argument("page_size", type=int, required=False, location="args", default=256)
args = parser.parse_args()
try:
plugins = PluginService.list(tenant_id)
plugins_with_total = PluginService.list_with_total(tenant_id, args["page"], args["page_size"])
except PluginDaemonClientSideError as e:
raise ValueError(e)

return jsonable_encoder({"plugins": plugins})
return jsonable_encoder({"plugins": plugins_with_total.list, "total": plugins_with_total.total})


class PluginListLatestVersionsApi(Resource):

+ 9
- 2
api/controllers/inner_api/plugin/wraps.py Datei anzeigen

@@ -2,12 +2,14 @@ from collections.abc import Callable
from functools import wraps
from typing import Optional

from flask import request
from flask import current_app, request
from flask_login import user_logged_in
from flask_restful import reqparse
from pydantic import BaseModel
from sqlalchemy.orm import Session

from extensions.ext_database import db
from libs.login import _get_user
from models.account import Account, Tenant
from models.model import EndUser
from services.account_service import AccountService
@@ -80,7 +82,12 @@ def get_user_tenant(view: Optional[Callable] = None):
raise ValueError("tenant not found")

kwargs["tenant_model"] = tenant_model
kwargs["user_model"] = get_user(tenant_id, user_id)

user = get_user(tenant_id, user_id)
kwargs["user_model"] = user

current_app.login_manager._update_request_context_with_user(user) # type: ignore
user_logged_in.send(current_app._get_current_object(), user=_get_user()) # type: ignore

return view_func(*args, **kwargs)


+ 7
- 7
api/controllers/service_api/app/annotation.py Datei anzeigen

@@ -3,7 +3,7 @@ from flask_restful import Resource, marshal, marshal_with, reqparse
from werkzeug.exceptions import Forbidden

from controllers.service_api import api
from controllers.service_api.wraps import FetchUserArg, WhereisUserArg, validate_app_token
from controllers.service_api.wraps import validate_app_token
from extensions.ext_redis import redis_client
from fields.annotation_fields import (
annotation_fields,
@@ -14,7 +14,7 @@ from services.annotation_service import AppAnnotationService


class AnnotationReplyActionApi(Resource):
@validate_app_token(fetch_user_arg=FetchUserArg(fetch_from=WhereisUserArg.JSON))
@validate_app_token
def post(self, app_model: App, end_user: EndUser, action):
parser = reqparse.RequestParser()
parser.add_argument("score_threshold", required=True, type=float, location="json")
@@ -31,7 +31,7 @@ class AnnotationReplyActionApi(Resource):


class AnnotationReplyActionStatusApi(Resource):
@validate_app_token(fetch_user_arg=FetchUserArg(fetch_from=WhereisUserArg.QUERY))
@validate_app_token
def get(self, app_model: App, end_user: EndUser, job_id, action):
job_id = str(job_id)
app_annotation_job_key = "{}_app_annotation_job_{}".format(action, str(job_id))
@@ -49,7 +49,7 @@ class AnnotationReplyActionStatusApi(Resource):


class AnnotationListApi(Resource):
@validate_app_token(fetch_user_arg=FetchUserArg(fetch_from=WhereisUserArg.QUERY))
@validate_app_token
def get(self, app_model: App, end_user: EndUser):
page = request.args.get("page", default=1, type=int)
limit = request.args.get("limit", default=20, type=int)
@@ -65,7 +65,7 @@ class AnnotationListApi(Resource):
}
return response, 200

@validate_app_token(fetch_user_arg=FetchUserArg(fetch_from=WhereisUserArg.JSON))
@validate_app_token
@marshal_with(annotation_fields)
def post(self, app_model: App, end_user: EndUser):
parser = reqparse.RequestParser()
@@ -77,7 +77,7 @@ class AnnotationListApi(Resource):


class AnnotationUpdateDeleteApi(Resource):
@validate_app_token(fetch_user_arg=FetchUserArg(fetch_from=WhereisUserArg.JSON))
@validate_app_token
@marshal_with(annotation_fields)
def put(self, app_model: App, end_user: EndUser, annotation_id):
if not current_user.is_editor:
@@ -91,7 +91,7 @@ class AnnotationUpdateDeleteApi(Resource):
annotation = AppAnnotationService.update_app_annotation_directly(args, app_model.id, annotation_id)
return annotation

@validate_app_token(fetch_user_arg=FetchUserArg(fetch_from=WhereisUserArg.QUERY))
@validate_app_token
def delete(self, app_model: App, end_user: EndUser, annotation_id):
if not current_user.is_editor:
raise Forbidden()

+ 6
- 1
api/controllers/service_api/wraps.py Datei anzeigen

@@ -99,7 +99,12 @@ def validate_app_token(view: Optional[Callable] = None, *, fetch_user_arg: Optio
if user_id:
user_id = str(user_id)

kwargs["end_user"] = create_or_update_end_user_for_user_id(app_model, user_id)
end_user = create_or_update_end_user_for_user_id(app_model, user_id)
kwargs["end_user"] = end_user

# Set EndUser as current logged-in user for flask_login.current_user
current_app.login_manager._update_request_context_with_user(end_user) # type: ignore
user_logged_in.send(current_app._get_current_object(), user=end_user) # type: ignore

return view_func(*args, **kwargs)


+ 1
- 1
api/core/agent/cot_agent_runner.py Datei anzeigen

@@ -63,7 +63,7 @@ class CotAgentRunner(BaseAgentRunner, ABC):
self._instruction = self._fill_in_inputs_from_external_data_tools(instruction, inputs)

iteration_step = 1
max_iteration_steps = min(app_config.agent.max_iteration if app_config.agent else 5, 5) + 1
max_iteration_steps = min(app_config.agent.max_iteration, 99) + 1

# convert tools into ModelRuntime Tool format
tool_instances, prompt_messages_tools = self._init_prompt_tools()

+ 1
- 1
api/core/agent/entities.py Datei anzeigen

@@ -82,7 +82,7 @@ class AgentEntity(BaseModel):
strategy: Strategy
prompt: Optional[AgentPromptEntity] = None
tools: Optional[list[AgentToolEntity]] = None
max_iteration: int = 5
max_iteration: int = 10


class AgentInvokeMessage(ToolInvokeMessage):

+ 1
- 1
api/core/agent/fc_agent_runner.py Datei anzeigen

@@ -48,7 +48,7 @@ class FunctionCallAgentRunner(BaseAgentRunner):
assert app_config.agent

iteration_step = 1
max_iteration_steps = min(app_config.agent.max_iteration, 5) + 1
max_iteration_steps = min(app_config.agent.max_iteration, 99) + 1

# continue to run until there is not any tool call
function_call_state = True

+ 1
- 1
api/core/app/app_config/easy_ui_based_app/agent/manager.py Datei anzeigen

@@ -75,7 +75,7 @@ class AgentConfigManager:
strategy=strategy,
prompt=agent_prompt_entity,
tools=agent_tools,
max_iteration=agent_dict.get("max_iteration", 5),
max_iteration=agent_dict.get("max_iteration", 10),
)

return None

+ 32
- 16
api/core/app/apps/advanced_chat/app_generator.py Datei anzeigen

@@ -5,7 +5,7 @@ import uuid
from collections.abc import Generator, Mapping
from typing import Any, Literal, Optional, Union, overload

from flask import Flask, current_app
from flask import Flask, copy_current_request_context, current_app, has_request_context
from pydantic import ValidationError
from sqlalchemy.orm import sessionmaker

@@ -158,7 +158,6 @@ class AdvancedChatAppGenerator(MessageBasedAppGenerator):
trace_manager=trace_manager,
workflow_run_id=workflow_run_id,
)
contexts.tenant_id.set(application_generate_entity.app_config.tenant_id)
contexts.plugin_tool_providers.set({})
contexts.plugin_tool_providers_lock.set(threading.Lock())

@@ -240,7 +239,6 @@ class AdvancedChatAppGenerator(MessageBasedAppGenerator):
node_id=node_id, inputs=args["inputs"]
),
)
contexts.tenant_id.set(application_generate_entity.app_config.tenant_id)
contexts.plugin_tool_providers.set({})
contexts.plugin_tool_providers_lock.set(threading.Lock())

@@ -316,7 +314,6 @@ class AdvancedChatAppGenerator(MessageBasedAppGenerator):
extras={"auto_generate_conversation_name": False},
single_loop_run=AdvancedChatAppGenerateEntity.SingleLoopRunEntity(node_id=node_id, inputs=args["inputs"]),
)
contexts.tenant_id.set(application_generate_entity.app_config.tenant_id)
contexts.plugin_tool_providers.set({})
contexts.plugin_tool_providers_lock.set(threading.Lock())

@@ -399,18 +396,23 @@ class AdvancedChatAppGenerator(MessageBasedAppGenerator):
message_id=message.id,
)

# new thread
worker_thread = threading.Thread(
target=self._generate_worker,
kwargs={
"flask_app": current_app._get_current_object(), # type: ignore
"application_generate_entity": application_generate_entity,
"queue_manager": queue_manager,
"conversation_id": conversation.id,
"message_id": message.id,
"context": contextvars.copy_context(),
},
)
# new thread with request context and contextvars
context = contextvars.copy_context()

@copy_current_request_context
def worker_with_context():
# Run the worker within the copied context
return context.run(
self._generate_worker,
flask_app=current_app._get_current_object(), # type: ignore
application_generate_entity=application_generate_entity,
queue_manager=queue_manager,
conversation_id=conversation.id,
message_id=message.id,
context=context,
)

worker_thread = threading.Thread(target=worker_with_context)

worker_thread.start()

@@ -449,8 +451,22 @@ class AdvancedChatAppGenerator(MessageBasedAppGenerator):
"""
for var, val in context.items():
var.set(val)

# FIXME(-LAN-): Save current user before entering new app context
from flask import g

saved_user = None
if has_request_context() and hasattr(g, "_login_user"):
saved_user = g._login_user

with flask_app.app_context():
try:
# Restore user in new app context
if saved_user is not None:
from flask import g

g._login_user = saved_user

# get conversation and message
conversation = self._get_conversation(conversation_id)
message = self._get_message(message_id)

+ 1
- 0
api/core/app/apps/advanced_chat/generate_task_pipeline.py Datei anzeigen

@@ -315,6 +315,7 @@ class AdvancedChatAppGenerateTaskPipeline:
task_id=self._application_generate_entity.task_id,
workflow_execution=workflow_execution,
)
session.commit()

yield workflow_start_resp
elif isinstance(

+ 31
- 13
api/core/app/apps/agent_chat/app_generator.py Datei anzeigen

@@ -5,7 +5,7 @@ import uuid
from collections.abc import Generator, Mapping
from typing import Any, Literal, Union, overload

from flask import Flask, current_app
from flask import Flask, copy_current_request_context, current_app, has_request_context
from pydantic import ValidationError

from configs import dify_config
@@ -179,18 +179,23 @@ class AgentChatAppGenerator(MessageBasedAppGenerator):
message_id=message.id,
)

# new thread
worker_thread = threading.Thread(
target=self._generate_worker,
kwargs={
"flask_app": current_app._get_current_object(), # type: ignore
"context": contextvars.copy_context(),
"application_generate_entity": application_generate_entity,
"queue_manager": queue_manager,
"conversation_id": conversation.id,
"message_id": message.id,
},
)
# new thread with request context and contextvars
context = contextvars.copy_context()

@copy_current_request_context
def worker_with_context():
# Run the worker within the copied context
return context.run(
self._generate_worker,
flask_app=current_app._get_current_object(), # type: ignore
context=context,
application_generate_entity=application_generate_entity,
queue_manager=queue_manager,
conversation_id=conversation.id,
message_id=message.id,
)

worker_thread = threading.Thread(target=worker_with_context)

worker_thread.start()

@@ -227,8 +232,21 @@ class AgentChatAppGenerator(MessageBasedAppGenerator):
for var, val in context.items():
var.set(val)

# FIXME(-LAN-): Save current user before entering new app context
from flask import g

saved_user = None
if has_request_context() and hasattr(g, "_login_user"):
saved_user = g._login_user

with flask_app.app_context():
try:
# Restore user in new app context
if saved_user is not None:
from flask import g

g._login_user = saved_user

# get conversation and message
conversation = self._get_conversation(conversation_id)
message = self._get_message(message_id)

+ 13
- 12
api/core/app/apps/chat/app_generator.py Datei anzeigen

@@ -4,7 +4,7 @@ import uuid
from collections.abc import Generator, Mapping
from typing import Any, Literal, Union, overload

from flask import Flask, current_app
from flask import Flask, copy_current_request_context, current_app
from pydantic import ValidationError

from configs import dify_config
@@ -170,17 +170,18 @@ class ChatAppGenerator(MessageBasedAppGenerator):
message_id=message.id,
)

# new thread
worker_thread = threading.Thread(
target=self._generate_worker,
kwargs={
"flask_app": current_app._get_current_object(), # type: ignore
"application_generate_entity": application_generate_entity,
"queue_manager": queue_manager,
"conversation_id": conversation.id,
"message_id": message.id,
},
)
# new thread with request context
@copy_current_request_context
def worker_with_context():
return self._generate_worker(
flask_app=current_app._get_current_object(), # type: ignore
application_generate_entity=application_generate_entity,
queue_manager=queue_manager,
conversation_id=conversation.id,
message_id=message.id,
)

worker_thread = threading.Thread(target=worker_with_context)

worker_thread.start()


+ 23
- 21
api/core/app/apps/completion/app_generator.py Datei anzeigen

@@ -4,7 +4,7 @@ import uuid
from collections.abc import Generator, Mapping
from typing import Any, Literal, Union, overload

from flask import Flask, current_app
from flask import Flask, copy_current_request_context, current_app
from pydantic import ValidationError

from configs import dify_config
@@ -151,16 +151,17 @@ class CompletionAppGenerator(MessageBasedAppGenerator):
message_id=message.id,
)

# new thread
worker_thread = threading.Thread(
target=self._generate_worker,
kwargs={
"flask_app": current_app._get_current_object(), # type: ignore
"application_generate_entity": application_generate_entity,
"queue_manager": queue_manager,
"message_id": message.id,
},
)
# new thread with request context
@copy_current_request_context
def worker_with_context():
return self._generate_worker(
flask_app=current_app._get_current_object(), # type: ignore
application_generate_entity=application_generate_entity,
queue_manager=queue_manager,
message_id=message.id,
)

worker_thread = threading.Thread(target=worker_with_context)

worker_thread.start()

@@ -313,16 +314,17 @@ class CompletionAppGenerator(MessageBasedAppGenerator):
message_id=message.id,
)

# new thread
worker_thread = threading.Thread(
target=self._generate_worker,
kwargs={
"flask_app": current_app._get_current_object(), # type: ignore
"application_generate_entity": application_generate_entity,
"queue_manager": queue_manager,
"message_id": message.id,
},
)
# new thread with request context
@copy_current_request_context
def worker_with_context():
return self._generate_worker(
flask_app=current_app._get_current_object(), # type: ignore
application_generate_entity=application_generate_entity,
queue_manager=queue_manager,
message_id=message.id,
)

worker_thread = threading.Thread(target=worker_with_context)

worker_thread.start()


+ 31
- 15
api/core/app/apps/workflow/app_generator.py Datei anzeigen

@@ -5,7 +5,7 @@ import uuid
from collections.abc import Generator, Mapping, Sequence
from typing import Any, Literal, Optional, Union, overload

from flask import Flask, current_app
from flask import Flask, copy_current_request_context, current_app, has_request_context
from pydantic import ValidationError
from sqlalchemy.orm import sessionmaker

@@ -135,7 +135,6 @@ class WorkflowAppGenerator(BaseAppGenerator):
workflow_run_id=workflow_run_id,
)

contexts.tenant_id.set(application_generate_entity.app_config.tenant_id)
contexts.plugin_tool_providers.set({})
contexts.plugin_tool_providers_lock.set(threading.Lock())

@@ -207,17 +206,22 @@ class WorkflowAppGenerator(BaseAppGenerator):
app_mode=app_model.mode,
)

# new thread
worker_thread = threading.Thread(
target=self._generate_worker,
kwargs={
"flask_app": current_app._get_current_object(), # type: ignore
"application_generate_entity": application_generate_entity,
"queue_manager": queue_manager,
"context": contextvars.copy_context(),
"workflow_thread_pool_id": workflow_thread_pool_id,
},
)
# new thread with request context and contextvars
context = contextvars.copy_context()

@copy_current_request_context
def worker_with_context():
# Run the worker within the copied context
return context.run(
self._generate_worker,
flask_app=current_app._get_current_object(), # type: ignore
application_generate_entity=application_generate_entity,
queue_manager=queue_manager,
context=context,
workflow_thread_pool_id=workflow_thread_pool_id,
)

worker_thread = threading.Thread(target=worker_with_context)

worker_thread.start()

@@ -277,7 +281,6 @@ class WorkflowAppGenerator(BaseAppGenerator):
),
workflow_run_id=str(uuid.uuid4()),
)
contexts.tenant_id.set(application_generate_entity.app_config.tenant_id)
contexts.plugin_tool_providers.set({})
contexts.plugin_tool_providers_lock.set(threading.Lock())

@@ -354,7 +357,6 @@ class WorkflowAppGenerator(BaseAppGenerator):
single_loop_run=WorkflowAppGenerateEntity.SingleLoopRunEntity(node_id=node_id, inputs=args["inputs"]),
workflow_run_id=str(uuid.uuid4()),
)
contexts.tenant_id.set(application_generate_entity.app_config.tenant_id)
contexts.plugin_tool_providers.set({})
contexts.plugin_tool_providers_lock.set(threading.Lock())

@@ -408,8 +410,22 @@ class WorkflowAppGenerator(BaseAppGenerator):
"""
for var, val in context.items():
var.set(val)

# FIXME(-LAN-): Save current user before entering new app context
from flask import g

saved_user = None
if has_request_context() and hasattr(g, "_login_user"):
saved_user = g._login_user

with flask_app.app_context():
try:
# Restore user in new app context
if saved_user is not None:
from flask import g

g._login_user = saved_user

# workflow app
runner = WorkflowAppRunner(
application_generate_entity=application_generate_entity,

+ 0
- 2
api/core/app/task_pipeline/easy_ui_based_generate_task_pipeline.py Datei anzeigen

@@ -455,8 +455,6 @@ class EasyUIBasedGenerateTaskPipeline(BasedGenerateTaskPipeline, MessageCycleMan
agent_thought: Optional[MessageAgentThought] = (
db.session.query(MessageAgentThought).filter(MessageAgentThought.id == event.agent_thought_id).first()
)
db.session.refresh(agent_thought)
db.session.close()

if agent_thought:
return AgentThoughtStreamResponse(

+ 7
- 3
api/core/llm_generator/llm_generator.py Datei anzeigen

@@ -51,15 +51,19 @@ class LLMGenerator:
response = cast(
LLMResult,
model_instance.invoke_llm(
prompt_messages=list(prompts), model_parameters={"max_tokens": 100, "temperature": 1}, stream=False
prompt_messages=list(prompts), model_parameters={"max_tokens": 500, "temperature": 1}, stream=False
),
)
answer = cast(str, response.message.content)
cleaned_answer = re.sub(r"^.*(\{.*\}).*$", r"\1", answer, flags=re.DOTALL)
if cleaned_answer is None:
return ""
result_dict = json.loads(cleaned_answer)
answer = result_dict["Your Output"]
try:
result_dict = json.loads(cleaned_answer)
answer = result_dict["Your Output"]
except json.JSONDecodeError as e:
logging.exception("Failed to generate name after answer, use query instead")
answer = query
name = answer.strip()

if len(name) > 75:

+ 2
- 1
api/core/plugin/backwards_invocation/model.py Datei anzeigen

@@ -58,6 +58,7 @@ class PluginModelBackwardsInvocation(BaseBackwardsInvocation):
LLMNode.deduct_llm_quota(
tenant_id=tenant.id, model_instance=model_instance, usage=chunk.delta.usage
)
chunk.prompt_messages = []
yield chunk

return handle()
@@ -68,7 +69,7 @@ class PluginModelBackwardsInvocation(BaseBackwardsInvocation):
def handle_non_streaming(response: LLMResult) -> Generator[LLMResultChunk, None, None]:
yield LLMResultChunk(
model=response.model,
prompt_messages=response.prompt_messages,
prompt_messages=[],
system_fingerprint=response.system_fingerprint,
delta=LLMResultChunkDelta(
index=0,

+ 6
- 1
api/core/plugin/entities/plugin_daemon.py Datei anzeigen

@@ -10,7 +10,7 @@ from core.datasource.entities.datasource_entities import DatasourceProviderEntit
from core.model_runtime.entities.model_entities import AIModelEntity
from core.model_runtime.entities.provider_entities import ProviderEntity
from core.plugin.entities.base import BasePluginEntity
from core.plugin.entities.plugin import PluginDeclaration
from core.plugin.entities.plugin import PluginDeclaration, PluginEntity
from core.tools.entities.common_entities import I18nObject
from core.tools.entities.tool_entities import ToolProviderEntityWithPlugin

@@ -175,3 +175,8 @@ class PluginOAuthAuthorizationUrlResponse(BaseModel):

class PluginOAuthCredentialsResponse(BaseModel):
credentials: Mapping[str, Any] = Field(description="The credentials of the OAuth.")


class PluginListResponse(BaseModel):
list: list[PluginEntity]
total: int

+ 17
- 3
api/core/plugin/impl/plugin.py Datei anzeigen

@@ -9,7 +9,12 @@ from core.plugin.entities.plugin import (
PluginInstallation,
PluginInstallationSource,
)
from core.plugin.entities.plugin_daemon import PluginInstallTask, PluginInstallTaskStartResponse, PluginUploadResponse
from core.plugin.entities.plugin_daemon import (
PluginInstallTask,
PluginInstallTaskStartResponse,
PluginListResponse,
PluginUploadResponse,
)
from core.plugin.impl.base import BasePluginClient


@@ -27,12 +32,21 @@ class PluginInstaller(BasePluginClient):
)

def list_plugins(self, tenant_id: str) -> list[PluginEntity]:
return self._request_with_plugin_daemon_response(
result = self._request_with_plugin_daemon_response(
"GET",
f"plugin/{tenant_id}/management/list",
list[PluginEntity],
PluginListResponse,
params={"page": 1, "page_size": 256},
)
return result.list

def list_plugins_with_total(self, tenant_id: str, page: int, page_size: int) -> PluginListResponse:
return self._request_with_plugin_daemon_response(
"GET",
f"plugin/{tenant_id}/management/list",
PluginListResponse,
params={"page": page, "page_size": page_size},
)

def upload_pkg(
self,

+ 4
- 0
api/core/rag/datasource/vdb/qdrant/qdrant_vector.py Datei anzeigen

@@ -46,6 +46,7 @@ class QdrantConfig(BaseModel):
root_path: Optional[str] = None
grpc_port: int = 6334
prefer_grpc: bool = False
replication_factor: int = 1

def to_qdrant_params(self):
if self.endpoint and self.endpoint.startswith("path:"):
@@ -119,11 +120,13 @@ class QdrantVector(BaseVector):
max_indexing_threads=0,
on_disk=False,
)

self._client.create_collection(
collection_name=collection_name,
vectors_config=vectors_config,
hnsw_config=hnsw_config,
timeout=int(self._client_config.timeout),
replication_factor=self._client_config.replication_factor,
)

# create group_id payload index
@@ -466,5 +469,6 @@ class QdrantVectorFactory(AbstractVectorFactory):
timeout=dify_config.QDRANT_CLIENT_TIMEOUT,
grpc_port=dify_config.QDRANT_GRPC_PORT,
prefer_grpc=dify_config.QDRANT_GRPC_ENABLED,
replication_factor=dify_config.QDRANT_REPLICATION_FACTOR,
),
)

+ 3
- 0
api/core/rag/datasource/vdb/tidb_on_qdrant/tidb_on_qdrant_vector.py Datei anzeigen

@@ -49,6 +49,7 @@ class TidbOnQdrantConfig(BaseModel):
root_path: Optional[str] = None
grpc_port: int = 6334
prefer_grpc: bool = False
replication_factor: int = 1

def to_qdrant_params(self):
if self.endpoint and self.endpoint.startswith("path:"):
@@ -134,6 +135,7 @@ class TidbOnQdrantVector(BaseVector):
vectors_config=vectors_config,
hnsw_config=hnsw_config,
timeout=int(self._client_config.timeout),
replication_factor=self._client_config.replication_factor,
)

# create group_id payload index
@@ -484,6 +486,7 @@ class TidbOnQdrantVectorFactory(AbstractVectorFactory):
timeout=dify_config.TIDB_ON_QDRANT_CLIENT_TIMEOUT,
grpc_port=dify_config.TIDB_ON_QDRANT_GRPC_PORT,
prefer_grpc=dify_config.TIDB_ON_QDRANT_GRPC_ENABLED,
replication_factor=dify_config.QDRANT_REPLICATION_FACTOR,
),
)


+ 24
- 271
api/core/tools/utils/web_reader_tool.py Datei anzeigen

@@ -1,21 +1,13 @@
import hashlib
import json
import mimetypes
import os
import re
import site
import subprocess
import tempfile
import unicodedata
from contextlib import contextmanager
from pathlib import Path
from typing import Any, Literal, Optional, cast
from collections.abc import Sequence
from dataclasses import dataclass
from typing import Any, Optional, cast
from urllib.parse import unquote

import chardet
import cloudscraper # type: ignore
from bs4 import BeautifulSoup, CData, Comment, NavigableString # type: ignore
from regex import regex # type: ignore
from readabilipy import simple_json_from_html_string # type: ignore

from core.helper import ssrf_proxy
from core.rag.extractor import extract_processor
@@ -23,9 +15,7 @@ from core.rag.extractor.extract_processor import ExtractProcessor

FULL_TEMPLATE = """
TITLE: {title}
AUTHORS: {authors}
PUBLISH DATE: {publish_date}
TOP_IMAGE_URL: {top_image}
AUTHOR: {author}
TEXT:

{text}
@@ -73,8 +63,8 @@ def get_url(url: str, user_agent: Optional[str] = None) -> str:
response = ssrf_proxy.get(url, headers=headers, follow_redirects=True, timeout=(120, 300))
elif response.status_code == 403:
scraper = cloudscraper.create_scraper()
scraper.perform_request = ssrf_proxy.make_request
response = scraper.get(url, headers=headers, follow_redirects=True, timeout=(120, 300))
scraper.perform_request = ssrf_proxy.make_request # type: ignore
response = scraper.get(url, headers=headers, follow_redirects=True, timeout=(120, 300)) # type: ignore

if response.status_code != 200:
return "URL returned status code {}.".format(response.status_code)
@@ -90,273 +80,36 @@ def get_url(url: str, user_agent: Optional[str] = None) -> str:
else:
content = response.text

a = extract_using_readabilipy(content)
article = extract_using_readabilipy(content)

if not a["plain_text"] or not a["plain_text"].strip():
if not article.text:
return ""

res = FULL_TEMPLATE.format(
title=a["title"],
authors=a["byline"],
publish_date=a["date"],
top_image="",
text=a["plain_text"] or "",
title=article.title,
author=article.auther,
text=article.text,
)

return res


def extract_using_readabilipy(html):
with tempfile.NamedTemporaryFile(delete=False, mode="w+") as f_html:
f_html.write(html)
f_html.close()
html_path = f_html.name
@dataclass
class Article:
title: str
auther: str
text: Sequence[dict]

# Call Mozilla's Readability.js Readability.parse() function via node, writing output to a temporary file
article_json_path = html_path + ".json"
jsdir = os.path.join(find_module_path("readabilipy"), "javascript")
with chdir(jsdir):
subprocess.check_call(["node", "ExtractArticle.js", "-i", html_path, "-o", article_json_path])

# Read output of call to Readability.parse() from JSON file and return as Python dictionary
input_json = json.loads(Path(article_json_path).read_text(encoding="utf-8"))

# Deleting files after processing
os.unlink(article_json_path)
os.unlink(html_path)

article_json: dict[str, Any] = {
"title": None,
"byline": None,
"date": None,
"content": None,
"plain_content": None,
"plain_text": None,
}
# Populate article fields from readability fields where present
if input_json:
if input_json.get("title"):
article_json["title"] = input_json["title"]
if input_json.get("byline"):
article_json["byline"] = input_json["byline"]
if input_json.get("date"):
article_json["date"] = input_json["date"]
if input_json.get("content"):
article_json["content"] = input_json["content"]
article_json["plain_content"] = plain_content(article_json["content"], False, False)
article_json["plain_text"] = extract_text_blocks_as_plain_text(article_json["plain_content"])
if input_json.get("textContent"):
article_json["plain_text"] = input_json["textContent"]
article_json["plain_text"] = re.sub(r"\n\s*\n", "\n", article_json["plain_text"])

return article_json


def find_module_path(module_name):
for package_path in site.getsitepackages():
potential_path = os.path.join(package_path, module_name)
if os.path.exists(potential_path):
return potential_path

return None


@contextmanager
def chdir(path):
"""Change directory in context and return to original on exit"""
# From https://stackoverflow.com/a/37996581, couldn't find a built-in
original_path = os.getcwd()
os.chdir(path)
try:
yield
finally:
os.chdir(original_path)


def extract_text_blocks_as_plain_text(paragraph_html):
# Load article as DOM
soup = BeautifulSoup(paragraph_html, "html.parser")
# Select all lists
list_elements = soup.find_all(["ul", "ol"])
# Prefix text in all list items with "* " and make lists paragraphs
for list_element in list_elements:
plain_items = "".join(
list(filter(None, [plain_text_leaf_node(li)["text"] for li in list_element.find_all("li")]))
)
list_element.string = plain_items
list_element.name = "p"
# Select all text blocks
text_blocks = [s.parent for s in soup.find_all(string=True)]
text_blocks = [plain_text_leaf_node(block) for block in text_blocks]
# Drop empty paragraphs
text_blocks = list(filter(lambda p: p["text"] is not None, text_blocks))
return text_blocks


def plain_text_leaf_node(element):
# Extract all text, stripped of any child HTML elements and normalize it
plain_text = normalize_text(element.get_text())
if plain_text != "" and element.name == "li":
plain_text = "* {}, ".format(plain_text)
if plain_text == "":
plain_text = None
if "data-node-index" in element.attrs:
plain = {"node_index": element["data-node-index"], "text": plain_text}
else:
plain = {"text": plain_text}
return plain


def plain_content(readability_content, content_digests, node_indexes):
# Load article as DOM
soup = BeautifulSoup(readability_content, "html.parser")
# Make all elements plain
elements = plain_elements(soup.contents, content_digests, node_indexes)
if node_indexes:
# Add node index attributes to nodes
elements = [add_node_indexes(element) for element in elements]
# Replace article contents with plain elements
soup.contents = elements
return str(soup)


def plain_elements(elements, content_digests, node_indexes):
# Get plain content versions of all elements
elements = [plain_element(element, content_digests, node_indexes) for element in elements]
if content_digests:
# Add content digest attribute to nodes
elements = [add_content_digest(element) for element in elements]
return elements


def plain_element(element, content_digests, node_indexes):
# For lists, we make each item plain text
if is_leaf(element):
# For leaf node elements, extract the text content, discarding any HTML tags
# 1. Get element contents as text
plain_text = element.get_text()
# 2. Normalize the extracted text string to a canonical representation
plain_text = normalize_text(plain_text)
# 3. Update element content to be plain text
element.string = plain_text
elif is_text(element):
if is_non_printing(element):
# The simplified HTML may have come from Readability.js so might
# have non-printing text (e.g. Comment or CData). In this case, we
# keep the structure, but ensure that the string is empty.
element = type(element)("")
else:
plain_text = element.string
plain_text = normalize_text(plain_text)
element = type(element)(plain_text)
else:
# If not a leaf node or leaf type call recursively on child nodes, replacing
element.contents = plain_elements(element.contents, content_digests, node_indexes)
return element


def add_node_indexes(element, node_index="0"):
# Can't add attributes to string types
if is_text(element):
return element
# Add index to current element
element["data-node-index"] = node_index
# Add index to child elements
for local_idx, child in enumerate([c for c in element.contents if not is_text(c)], start=1):
# Can't add attributes to leaf string types
child_index = "{stem}.{local}".format(stem=node_index, local=local_idx)
add_node_indexes(child, node_index=child_index)
return element


def normalize_text(text):
"""Normalize unicode and whitespace."""
# Normalize unicode first to try and standardize whitespace characters as much as possible before normalizing them
text = strip_control_characters(text)
text = normalize_unicode(text)
text = normalize_whitespace(text)
return text


def strip_control_characters(text):
"""Strip out unicode control characters which might break the parsing."""
# Unicode control characters
# [Cc]: Other, Control [includes new lines]
# [Cf]: Other, Format
# [Cn]: Other, Not Assigned
# [Co]: Other, Private Use
# [Cs]: Other, Surrogate
control_chars = {"Cc", "Cf", "Cn", "Co", "Cs"}
retained_chars = ["\t", "\n", "\r", "\f"]

# Remove non-printing control characters
return "".join(
[
"" if (unicodedata.category(char) in control_chars) and (char not in retained_chars) else char
for char in text
]
def extract_using_readabilipy(html: str):
json_article: dict[str, Any] = simple_json_from_html_string(html, use_readability=True)
article = Article(
title=json_article.get("title") or "",
auther=json_article.get("byline") or "",
text=json_article.get("plain_text") or [],
)


def normalize_unicode(text):
"""Normalize unicode such that things that are visually equivalent map to the same unicode string where possible."""
normal_form: Literal["NFC", "NFD", "NFKC", "NFKD"] = "NFKC"
text = unicodedata.normalize(normal_form, text)
return text


def normalize_whitespace(text):
"""Replace runs of whitespace characters with a single space as this is what happens when HTML text is displayed."""
text = regex.sub(r"\s+", " ", text)
# Remove leading and trailing whitespace
text = text.strip()
return text


def is_leaf(element):
return element.name in {"p", "li"}


def is_text(element):
return isinstance(element, NavigableString)


def is_non_printing(element):
return any(isinstance(element, _e) for _e in [Comment, CData])


def add_content_digest(element):
if not is_text(element):
element["data-content-digest"] = content_digest(element)
return element


def content_digest(element):
digest: Any
if is_text(element):
# Hash
trimmed_string = element.string.strip()
if trimmed_string == "":
digest = ""
else:
digest = hashlib.sha256(trimmed_string.encode("utf-8")).hexdigest()
else:
contents = element.contents
num_contents = len(contents)
if num_contents == 0:
# No hash when no child elements exist
digest = ""
elif num_contents == 1:
# If single child, use digest of child
digest = content_digest(contents[0])
else:
# Build content digest from the "non-empty" digests of child nodes
digest = hashlib.sha256()
child_digests = list(filter(lambda x: x != "", [content_digest(content) for content in contents]))
for child in child_digests:
digest.update(child.encode("utf-8"))
digest = digest.hexdigest()
return digest
return article


def get_image_upload_file_ids(content):

+ 14
- 1
api/core/workflow/graph_engine/graph_engine.py Datei anzeigen

@@ -9,7 +9,7 @@ from copy import copy, deepcopy
from datetime import UTC, datetime
from typing import Any, Optional, cast

from flask import Flask, current_app
from flask import Flask, current_app, has_request_context

from configs import dify_config
from core.app.apps.base_app_queue_manager import GenerateTaskStoppedError
@@ -540,8 +540,21 @@ class GraphEngine:
for var, val in context.items():
var.set(val)

# FIXME(-LAN-): Save current user before entering new app context
from flask import g

saved_user = None
if has_request_context() and hasattr(g, "_login_user"):
saved_user = g._login_user

with flask_app.app_context():
try:
# Restore user in new app context
if saved_user is not None:
from flask import g

g._login_user = saved_user

q.put(
ParallelBranchRunStartedEvent(
parallel_id=parallel_id,

+ 4
- 2
api/core/workflow/nodes/agent/agent_node.py Datei anzeigen

@@ -356,7 +356,9 @@ class AgentNode(ToolNode):

def _remove_unsupported_model_features_for_old_version(self, model_schema: AIModelEntity) -> AIModelEntity:
if model_schema.features:
for feature in model_schema.features:
if feature.value not in AgentOldVersionModelFeatures:
for feature in model_schema.features[:]: # Create a copy to safely modify during iteration
try:
AgentOldVersionModelFeatures(feature.value) # Try to create enum member from value
except ValueError:
model_schema.features.remove(feature)
return model_schema

+ 2
- 2
api/core/workflow/nodes/agent/entities.py Datei anzeigen

@@ -1,4 +1,4 @@
from enum import Enum
from enum import Enum, StrEnum
from typing import Any, Literal, Union

from pydantic import BaseModel
@@ -26,7 +26,7 @@ class ParamsAutoGenerated(Enum):
OPEN = 1


class AgentOldVersionModelFeatures(Enum):
class AgentOldVersionModelFeatures(StrEnum):
"""
Enum class for old SDK version llm feature.
"""

+ 63
- 11
api/core/workflow/nodes/document_extractor/node.py Datei anzeigen

@@ -7,6 +7,7 @@ import tempfile
from collections.abc import Mapping, Sequence
from typing import Any, cast

import chardet
import docx
import pandas as pd
import pypandoc # type: ignore
@@ -180,26 +181,64 @@ def _extract_text_by_file_extension(*, file_content: bytes, file_extension: str)

def _extract_text_from_plain_text(file_content: bytes) -> str:
try:
return file_content.decode("utf-8", "ignore")
except UnicodeDecodeError as e:
raise TextExtractionError("Failed to decode plain text file") from e
# Detect encoding using chardet
result = chardet.detect(file_content)
encoding = result["encoding"]

# Fallback to utf-8 if detection fails
if not encoding:
encoding = "utf-8"

return file_content.decode(encoding, errors="ignore")
except (UnicodeDecodeError, LookupError) as e:
# If decoding fails, try with utf-8 as last resort
try:
return file_content.decode("utf-8", errors="ignore")
except UnicodeDecodeError:
raise TextExtractionError(f"Failed to decode plain text file: {e}") from e


def _extract_text_from_json(file_content: bytes) -> str:
try:
json_data = json.loads(file_content.decode("utf-8", "ignore"))
# Detect encoding using chardet
result = chardet.detect(file_content)
encoding = result["encoding"]

# Fallback to utf-8 if detection fails
if not encoding:
encoding = "utf-8"

json_data = json.loads(file_content.decode(encoding, errors="ignore"))
return json.dumps(json_data, indent=2, ensure_ascii=False)
except (UnicodeDecodeError, json.JSONDecodeError) as e:
raise TextExtractionError(f"Failed to decode or parse JSON file: {e}") from e
except (UnicodeDecodeError, LookupError, json.JSONDecodeError) as e:
# If decoding fails, try with utf-8 as last resort
try:
json_data = json.loads(file_content.decode("utf-8", errors="ignore"))
return json.dumps(json_data, indent=2, ensure_ascii=False)
except (UnicodeDecodeError, json.JSONDecodeError):
raise TextExtractionError(f"Failed to decode or parse JSON file: {e}") from e


def _extract_text_from_yaml(file_content: bytes) -> str:
"""Extract the content from yaml file"""
try:
yaml_data = yaml.safe_load_all(file_content.decode("utf-8", "ignore"))
# Detect encoding using chardet
result = chardet.detect(file_content)
encoding = result["encoding"]

# Fallback to utf-8 if detection fails
if not encoding:
encoding = "utf-8"

yaml_data = yaml.safe_load_all(file_content.decode(encoding, errors="ignore"))
return cast(str, yaml.dump_all(yaml_data, allow_unicode=True, sort_keys=False))
except (UnicodeDecodeError, yaml.YAMLError) as e:
raise TextExtractionError(f"Failed to decode or parse YAML file: {e}") from e
except (UnicodeDecodeError, LookupError, yaml.YAMLError) as e:
# If decoding fails, try with utf-8 as last resort
try:
yaml_data = yaml.safe_load_all(file_content.decode("utf-8", errors="ignore"))
return cast(str, yaml.dump_all(yaml_data, allow_unicode=True, sort_keys=False))
except (UnicodeDecodeError, yaml.YAMLError):
raise TextExtractionError(f"Failed to decode or parse YAML file: {e}") from e


def _extract_text_from_pdf(file_content: bytes) -> str:
@@ -338,7 +377,20 @@ def _extract_text_from_file(file: File):

def _extract_text_from_csv(file_content: bytes) -> str:
try:
csv_file = io.StringIO(file_content.decode("utf-8", "ignore"))
# Detect encoding using chardet
result = chardet.detect(file_content)
encoding = result["encoding"]

# Fallback to utf-8 if detection fails
if not encoding:
encoding = "utf-8"

try:
csv_file = io.StringIO(file_content.decode(encoding, errors="ignore"))
except (UnicodeDecodeError, LookupError):
# If decoding fails, try with utf-8 as last resort
csv_file = io.StringIO(file_content.decode("utf-8", errors="ignore"))

csv_reader = csv.reader(csv_file)
rows = list(csv_reader)

@@ -366,7 +418,7 @@ def _extract_text_from_excel(file_content: bytes) -> str:
df = excel_file.parse(sheet_name=sheet_name)
df.dropna(how="all", inplace=True)
# Create Markdown table two times to separate tables with a newline
markdown_table += df.to_markdown(index=False) + "\n\n"
markdown_table += df.to_markdown(index=False, floatfmt="") + "\n\n"
except Exception as e:
continue
return markdown_table

+ 8
- 1
api/core/workflow/nodes/http_request/executor.py Datei anzeigen

@@ -235,6 +235,10 @@ class Executor:
files[key].append(file_tuple)

# convert files to list for httpx request
# If there are no actual files, we still need to force httpx to use `multipart/form-data`.
# This is achieved by inserting a harmless placeholder file that will be ignored by the server.
if not files:
self.files = [("__multipart_placeholder__", ("", b"", "application/octet-stream"))]
if files:
self.files = []
for key, file_tuples in files.items():
@@ -373,7 +377,10 @@ class Executor:
raw += f"{k}: {v}\r\n"

body_string = ""
if self.files:
# Only log actual files if present.
# '__multipart_placeholder__' is inserted to force multipart encoding but is not a real file.
# This prevents logging meaningless placeholder entries.
if self.files and not all(f[0] == "__multipart_placeholder__" for f in self.files):
for key, (filename, content, mime_type) in self.files:
body_string += f"--{boundary}\r\n"
body_string += f'Content-Disposition: form-data; name="{key}"\r\n\r\n'

+ 15
- 1
api/core/workflow/nodes/iteration/iteration_node.py Datei anzeigen

@@ -7,7 +7,7 @@ from datetime import UTC, datetime
from queue import Empty, Queue
from typing import TYPE_CHECKING, Any, Optional, cast

from flask import Flask, current_app
from flask import Flask, current_app, has_request_context

from configs import dify_config
from core.variables import ArrayVariable, IntegerVariable, NoneVariable
@@ -586,7 +586,21 @@ class IterationNode(BaseNode[IterationNodeData]):
"""
for var, val in context.items():
var.set(val)

# FIXME(-LAN-): Save current user before entering new app context
from flask import g

saved_user = None
if has_request_context() and hasattr(g, "_login_user"):
saved_user = g._login_user

with flask_app.app_context():
# Restore user in new app context
if saved_user is not None:
from flask import g

g._login_user = saved_user

parallel_mode_run_id = uuid.uuid4().hex
graph_engine_copy = graph_engine.create_copy()
variable_pool_copy = graph_engine_copy.graph_runtime_state.variable_pool

+ 21
- 18
api/core/workflow/workflow_cycle_manager.py Datei anzeigen

@@ -125,6 +125,7 @@ class WorkflowCycleManager:
)
)

self._workflow_execution_repository.save(workflow_execution)
return workflow_execution

def handle_workflow_run_partial_success(
@@ -158,6 +159,7 @@ class WorkflowCycleManager:
)
)

self._workflow_execution_repository.save(execution)
return execution

def handle_workflow_run_failed(
@@ -172,44 +174,45 @@ class WorkflowCycleManager:
trace_manager: Optional[TraceQueueManager] = None,
exceptions_count: int = 0,
) -> WorkflowExecution:
execution = self._get_workflow_execution_or_raise_error(workflow_run_id)
workflow_execution = self._get_workflow_execution_or_raise_error(workflow_run_id)

execution.status = WorkflowExecutionStatus(status.value)
execution.error_message = error_message
execution.total_tokens = total_tokens
execution.total_steps = total_steps
execution.finished_at = datetime.now(UTC).replace(tzinfo=None)
execution.exceptions_count = exceptions_count
workflow_execution.status = WorkflowExecutionStatus(status.value)
workflow_execution.error_message = error_message
workflow_execution.total_tokens = total_tokens
workflow_execution.total_steps = total_steps
workflow_execution.finished_at = datetime.now(UTC).replace(tzinfo=None)
workflow_execution.exceptions_count = exceptions_count

# Use the instance repository to find running executions for a workflow run
running_domain_executions = self._workflow_node_execution_repository.get_running_executions(
workflow_run_id=execution.id
running_node_executions = self._workflow_node_execution_repository.get_running_executions(
workflow_run_id=workflow_execution.id
)

# Update the domain models
now = datetime.now(UTC).replace(tzinfo=None)
for domain_execution in running_domain_executions:
if domain_execution.node_execution_id:
for node_execution in running_node_executions:
if node_execution.node_execution_id:
# Update the domain model
domain_execution.status = NodeExecutionStatus.FAILED
domain_execution.error = error_message
domain_execution.finished_at = now
domain_execution.elapsed_time = (now - domain_execution.created_at).total_seconds()
node_execution.status = NodeExecutionStatus.FAILED
node_execution.error = error_message
node_execution.finished_at = now
node_execution.elapsed_time = (now - node_execution.created_at).total_seconds()

# Update the repository with the domain model
self._workflow_node_execution_repository.save(domain_execution)
self._workflow_node_execution_repository.save(node_execution)

if trace_manager:
trace_manager.add_trace_task(
TraceTask(
TraceTaskName.WORKFLOW_TRACE,
workflow_execution=execution,
workflow_execution=workflow_execution,
conversation_id=conversation_id,
user_id=trace_manager.user_id,
)
)

return execution
self._workflow_execution_repository.save(workflow_execution)
return workflow_execution

def handle_node_execution_start(
self,

+ 24
- 4
api/extensions/ext_login.py Datei anzeigen

@@ -5,11 +5,11 @@ from flask import Response, request
from flask_login import user_loaded_from_request, user_logged_in
from werkzeug.exceptions import NotFound, Unauthorized

import contexts
from configs import dify_config
from dify_app import DifyApp
from extensions.ext_database import db
from libs.passport import PassportService
from models.account import Account
from models.account import Account, Tenant, TenantAccountJoin
from models.model import EndUser
from services.account_service import AccountService

@@ -32,6 +32,26 @@ def load_user_from_request(request_from_flask_login):
else:
auth_token = request.args.get("_token")

# Check for admin API key authentication first
if dify_config.ADMIN_API_KEY_ENABLE and auth_header:
admin_api_key = dify_config.ADMIN_API_KEY
if admin_api_key and admin_api_key == auth_token:
workspace_id = request.headers.get("X-WORKSPACE-ID")
if workspace_id:
tenant_account_join = (
db.session.query(Tenant, TenantAccountJoin)
.filter(Tenant.id == workspace_id)
.filter(TenantAccountJoin.tenant_id == Tenant.id)
.filter(TenantAccountJoin.role == "owner")
.one_or_none()
)
if tenant_account_join:
tenant, ta = tenant_account_join
account = db.session.query(Account).filter_by(id=ta.account_id).first()
if account:
account.current_tenant = tenant
return account

if request.blueprint in {"console", "inner_api"}:
if not auth_token:
raise Unauthorized("Invalid Authorization token.")
@@ -61,8 +81,8 @@ def on_user_logged_in(_sender, user):
Note: AccountService.load_logged_in_account will populate user.current_tenant_id
through the load_user method, which calls account.set_tenant_id().
"""
if user and isinstance(user, Account) and user.current_tenant_id:
contexts.tenant_id.set(user.current_tenant_id)
# tenant_id context variable removed - using current_user.current_tenant_id directly
pass


@login_manager.unauthorized_handler

+ 42
- 24
api/extensions/ext_otel.py Datei anzeigen

@@ -12,19 +12,30 @@ from flask_login import user_loaded_from_request, user_logged_in # type: ignore

from configs import dify_config
from dify_app import DifyApp
from models import Account, EndUser


@user_logged_in.connect
@user_loaded_from_request.connect
def on_user_loaded(_sender, user):
def on_user_loaded(_sender, user: Union["Account", "EndUser"]):
if dify_config.ENABLE_OTEL:
from opentelemetry.trace import get_current_span

if user:
current_span = get_current_span()
if current_span:
current_span.set_attribute("service.tenant.id", user.current_tenant_id)
current_span.set_attribute("service.user.id", user.id)
try:
current_span = get_current_span()
if isinstance(user, Account) and user.current_tenant_id:
tenant_id = user.current_tenant_id
elif isinstance(user, EndUser):
tenant_id = user.tenant_id
else:
return
if current_span:
current_span.set_attribute("service.tenant.id", tenant_id)
current_span.set_attribute("service.user.id", user.id)
except Exception:
logging.exception("Error setting tenant and user attributes")
pass


def init_app(app: DifyApp):
@@ -47,21 +58,25 @@ def init_app(app: DifyApp):

def response_hook(span: Span, status: str, response_headers: list):
if span and span.is_recording():
if status.startswith("2"):
span.set_status(StatusCode.OK)
else:
span.set_status(StatusCode.ERROR, status)

status = status.split(" ")[0]
status_code = int(status)
status_class = f"{status_code // 100}xx"
attributes: dict[str, str | int] = {"status_code": status_code, "status_class": status_class}
request = flask.request
if request and request.url_rule:
attributes[SpanAttributes.HTTP_TARGET] = str(request.url_rule.rule)
if request and request.method:
attributes[SpanAttributes.HTTP_METHOD] = str(request.method)
_http_response_counter.add(1, attributes)
try:
if status.startswith("2"):
span.set_status(StatusCode.OK)
else:
span.set_status(StatusCode.ERROR, status)

status = status.split(" ")[0]
status_code = int(status)
status_class = f"{status_code // 100}xx"
attributes: dict[str, str | int] = {"status_code": status_code, "status_class": status_class}
request = flask.request
if request and request.url_rule:
attributes[SpanAttributes.HTTP_TARGET] = str(request.url_rule.rule)
if request and request.method:
attributes[SpanAttributes.HTTP_METHOD] = str(request.method)
_http_response_counter.add(1, attributes)
except Exception:
logging.exception("Error setting status and attributes")
pass

instrumentor = FlaskInstrumentor()
if dify_config.DEBUG:
@@ -92,7 +107,7 @@ def init_app(app: DifyApp):
class ExceptionLoggingHandler(logging.Handler):
"""Custom logging handler that creates spans for logging.exception() calls"""

def emit(self, record):
def emit(self, record: logging.LogRecord):
try:
if record.exc_info:
tracer = get_tracer_provider().get_tracer("dify.exception.logging")
@@ -107,9 +122,12 @@ def init_app(app: DifyApp):
},
) as span:
span.set_status(StatusCode.ERROR)
span.record_exception(record.exc_info[1])
span.set_attribute("exception.type", record.exc_info[0].__name__)
span.set_attribute("exception.message", str(record.exc_info[1]))
if record.exc_info[1]:
span.record_exception(record.exc_info[1])
span.set_attribute("exception.message", str(record.exc_info[1]))
if record.exc_info[0]:
span.set_attribute("exception.type", record.exc_info[0].__name__)

except Exception:
pass


+ 2
- 0
api/fields/app_fields.py Datei anzeigen

@@ -100,6 +100,8 @@ app_partial_fields = {
"updated_at": TimestampField,
"tags": fields.List(fields.Nested(tag_fields)),
"access_mode": fields.String,
"create_user_name": fields.String,
"author_name": fields.String,
}



+ 1
- 34
api/libs/login.py Datei anzeigen

@@ -2,14 +2,11 @@ from functools import wraps
from typing import Any

from flask import current_app, g, has_request_context, request
from flask_login import user_logged_in # type: ignore
from flask_login.config import EXEMPT_METHODS # type: ignore
from werkzeug.exceptions import Unauthorized
from werkzeug.local import LocalProxy

from configs import dify_config
from extensions.ext_database import db
from models.account import Account, Tenant, TenantAccountJoin
from models.account import Account
from models.model import EndUser

#: A proxy for the current user. If no user is logged in, this will be an
@@ -53,36 +50,6 @@ def login_required(func):

@wraps(func)
def decorated_view(*args, **kwargs):
auth_header = request.headers.get("Authorization")
if dify_config.ADMIN_API_KEY_ENABLE:
if auth_header:
if " " not in auth_header:
raise Unauthorized("Invalid Authorization header format. Expected 'Bearer <api-key>' format.")
auth_scheme, auth_token = auth_header.split(None, 1)
auth_scheme = auth_scheme.lower()
if auth_scheme != "bearer":
raise Unauthorized("Invalid Authorization header format. Expected 'Bearer <api-key>' format.")

admin_api_key = dify_config.ADMIN_API_KEY
if admin_api_key:
if admin_api_key == auth_token:
workspace_id = request.headers.get("X-WORKSPACE-ID")
if workspace_id:
tenant_account_join = (
db.session.query(Tenant, TenantAccountJoin)
.filter(Tenant.id == workspace_id)
.filter(TenantAccountJoin.tenant_id == Tenant.id)
.filter(TenantAccountJoin.role == "owner")
.one_or_none()
)
if tenant_account_join:
tenant, ta = tenant_account_join
account = db.session.query(Account).filter_by(id=ta.account_id).first()
# Login admin
if account:
account.current_tenant = tenant
current_app.login_manager._update_request_context_with_user(account) # type: ignore
user_logged_in.send(current_app._get_current_object(), user=_get_user()) # type: ignore
if request.method in EXEMPT_METHODS or dify_config.LOGIN_DISABLED:
pass
elif not current_user.is_authenticated:

+ 9
- 0
api/models/model.py Datei anzeigen

@@ -295,6 +295,15 @@ class App(Base):

return tags or []

@property
def author_name(self):
if self.created_by:
account = db.session.query(Account).filter(Account.id == self.created_by).first()
if account:
return account.name

return None


class AppModelConfig(Base):
__tablename__ = "app_model_configs"

+ 23
- 3
api/models/workflow.py Datei anzeigen

@@ -6,6 +6,8 @@ from enum import Enum, StrEnum
from typing import TYPE_CHECKING, Any, Optional, Union
from uuid import uuid4

from flask_login import current_user

from core.variables import utils as variable_utils
from core.workflow.constants import CONVERSATION_VARIABLE_NODE_ID, SYSTEM_VARIABLE_NODE_ID
from factories.variable_factory import build_segment
@@ -17,7 +19,6 @@ import sqlalchemy as sa
from sqlalchemy import UniqueConstraint, func
from sqlalchemy.orm import Mapped, mapped_column

import contexts
from constants import DEFAULT_FILE_NUMBER_LIMITS, HIDDEN_VALUE
from core.helper import encrypter
from core.variables import SecretVariable, Segment, SegmentType, Variable
@@ -280,7 +281,16 @@ class Workflow(Base):
if self._environment_variables is None:
self._environment_variables = "{}"

tenant_id = contexts.tenant_id.get()
# Get tenant_id from current_user (Account or EndUser)
if isinstance(current_user, Account):
# Account user
tenant_id = current_user.current_tenant_id
else:
# EndUser
tenant_id = current_user.tenant_id

if not tenant_id:
return []

environment_variables_dict: dict[str, Any] = json.loads(self._environment_variables)
results = [
@@ -303,7 +313,17 @@ class Workflow(Base):
self._environment_variables = "{}"
return

tenant_id = contexts.tenant_id.get()
# Get tenant_id from current_user (Account or EndUser)
if isinstance(current_user, Account):
# Account user
tenant_id = current_user.current_tenant_id
else:
# EndUser
tenant_id = current_user.tenant_id

if not tenant_id:
self._environment_variables = "{}"
return

value = list(value)
if any(var for var in value if not var.id):

+ 2
- 1
api/pyproject.toml Datei anzeigen

@@ -149,6 +149,7 @@ dev = [
"types-tqdm~=4.67.0",
"types-ujson~=5.10.0",
"boto3-stubs>=1.38.20",
"types-jmespath>=1.0.2.20240106",
]

############################################################
@@ -190,7 +191,7 @@ vdb = [
"pymilvus~=2.5.0",
"pymochow==1.3.1",
"pyobvector~=0.1.6",
"qdrant-client==1.7.3",
"qdrant-client==1.9.0",
"tablestore==6.1.0",
"tcvectordb~=1.6.4",
"tidb-vector==0.0.9",

+ 9
- 11
api/services/dataset_service.py Datei anzeigen

@@ -648,17 +648,15 @@ class DatasetService:
if dataset.permission == DatasetPermissionEnum.ONLY_ME and dataset.created_by != user.id:
logging.debug(f"User {user.id} does not have permission to access dataset {dataset.id}")
raise NoPermissionError("You do not have permission to access this dataset.")
if dataset.permission == "partial_members":
user_permission = (
db.session.query(DatasetPermission).filter_by(dataset_id=dataset.id, account_id=user.id).first()
)
if (
not user_permission
and dataset.tenant_id != user.current_tenant_id
and dataset.created_by != user.id
):
logging.debug(f"User {user.id} does not have permission to access dataset {dataset.id}")
raise NoPermissionError("You do not have permission to access this dataset.")
if dataset.permission == DatasetPermissionEnum.PARTIAL_TEAM:
# For partial team permission, user needs explicit permission or be the creator
if dataset.created_by != user.id:
user_permission = (
db.session.query(DatasetPermission).filter_by(dataset_id=dataset.id, account_id=user.id).first()
)
if not user_permission:
logging.debug(f"User {user.id} does not have permission to access dataset {dataset.id}")
raise NoPermissionError("You do not have permission to access this dataset.")

@staticmethod
def check_dataset_operator_permission(user: Optional[Account] = None, dataset: Optional[Dataset] = None):

+ 10
- 1
api/services/plugin/plugin_service.py Datei anzeigen

@@ -17,7 +17,7 @@ from core.plugin.entities.plugin import (
PluginInstallation,
PluginInstallationSource,
)
from core.plugin.entities.plugin_daemon import PluginInstallTask, PluginUploadResponse
from core.plugin.entities.plugin_daemon import PluginInstallTask, PluginListResponse, PluginUploadResponse
from core.plugin.impl.asset import PluginAssetManager
from core.plugin.impl.debugging import PluginDebuggingClient
from core.plugin.impl.plugin import PluginInstaller
@@ -110,6 +110,15 @@ class PluginService:
plugins = manager.list_plugins(tenant_id)
return plugins

@staticmethod
def list_with_total(tenant_id: str, page: int, page_size: int) -> PluginListResponse:
"""
list all plugins of the tenant
"""
manager = PluginInstaller()
plugins = manager.list_plugins_with_total(tenant_id, page, page_size)
return plugins

@staticmethod
def list_installations_from_ids(tenant_id: str, ids: Sequence[str]) -> Sequence[PluginInstallation]:
"""

+ 11
- 28
api/tasks/remove_app_and_related_data_task.py Datei anzeigen

@@ -4,16 +4,12 @@ from collections.abc import Callable

import click
from celery import shared_task # type: ignore
from sqlalchemy import delete, select
from sqlalchemy import delete
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.orm import Session

from core.repositories import SQLAlchemyWorkflowNodeExecutionRepository
from extensions.ext_database import db
from models import (
Account,
ApiToken,
App,
AppAnnotationHitHistory,
AppAnnotationSetting,
AppDatasetJoin,
@@ -34,7 +30,7 @@ from models import (
)
from models.tools import WorkflowToolProvider
from models.web import PinnedConversation, SavedMessage
from models.workflow import ConversationVariable, Workflow, WorkflowAppLog, WorkflowRun
from models.workflow import ConversationVariable, Workflow, WorkflowAppLog, WorkflowNodeExecution, WorkflowRun


@shared_task(queue="app_deletion", bind=True, max_retries=3)
@@ -191,30 +187,17 @@ def _delete_app_workflow_runs(tenant_id: str, app_id: str):


def _delete_app_workflow_node_executions(tenant_id: str, app_id: str):
# Get app's owner
with Session(db.engine, expire_on_commit=False) as session:
stmt = select(Account).where(Account.id == App.created_by).where(App.id == app_id)
user = session.scalar(stmt)

if user is None:
errmsg = (
f"Failed to delete workflow node executions for tenant {tenant_id} and app {app_id}, app's owner not found"
def del_workflow_node_execution(workflow_node_execution_id: str):
db.session.query(WorkflowNodeExecution).filter(WorkflowNodeExecution.id == workflow_node_execution_id).delete(
synchronize_session=False
)
logging.error(errmsg)
raise ValueError(errmsg)

# Create a repository instance for WorkflowNodeExecution
repository = SQLAlchemyWorkflowNodeExecutionRepository(
session_factory=db.engine,
user=user,
app_id=app_id,
triggered_from=None,
)

# Use the clear method to delete all records for this tenant_id and app_id
repository.clear()

logging.info(click.style(f"Deleted workflow node executions for tenant {tenant_id} and app {app_id}", fg="green"))
_delete_records(
"""select id from workflow_node_executions where tenant_id=:tenant_id and app_id=:app_id limit 1000""",
{"tenant_id": tenant_id, "app_id": app_id},
del_workflow_node_execution,
"workflow node execution",
)


def _delete_app_workflow_app_logs(tenant_id: str, app_id: str):

+ 3
- 1
api/tests/unit_tests/core/workflow/nodes/http_request/test_http_request_executor.py Datei anzeigen

@@ -246,7 +246,9 @@ def test_executor_with_form_data():
assert "multipart/form-data" in executor.headers["Content-Type"]
assert executor.params == []
assert executor.json is None
assert executor.files is None
# '__multipart_placeholder__' is expected when no file inputs exist,
# to ensure the request is treated as multipart/form-data by the backend.
assert executor.files == [("__multipart_placeholder__", ("", b"", "application/octet-stream"))]
assert executor.content is None

# Check that the form data is correctly loaded in executor.data

+ 180
- 1
api/tests/unit_tests/core/workflow/nodes/test_document_extractor_node.py Datei anzeigen

@@ -10,6 +10,7 @@ from core.workflow.entities.node_entities import NodeRunResult
from core.workflow.nodes.document_extractor import DocumentExtractorNode, DocumentExtractorNodeData
from core.workflow.nodes.document_extractor.node import (
_extract_text_from_docx,
_extract_text_from_excel,
_extract_text_from_pdf,
_extract_text_from_plain_text,
)
@@ -149,7 +150,7 @@ def test_extract_text_from_plain_text_non_utf8():
temp_file.write(non_utf8_content)
temp_file.seek(0)
text = _extract_text_from_plain_text(temp_file.read())
assert text == "Hello, world."
assert text == "Hello, world©."


@patch("pypdfium2.PdfDocument")
@@ -182,3 +183,181 @@ def test_extract_text_from_docx(mock_document):

def test_node_type(document_extractor_node):
assert document_extractor_node._node_type == NodeType.DOCUMENT_EXTRACTOR


@patch("pandas.ExcelFile")
def test_extract_text_from_excel_single_sheet(mock_excel_file):
"""Test extracting text from Excel file with single sheet."""
# Mock DataFrame
mock_df = Mock()
mock_df.dropna = Mock()
mock_df.to_markdown.return_value = "| Name | Age |\n|------|-----|\n| John | 25 |"

# Mock ExcelFile
mock_excel_instance = Mock()
mock_excel_instance.sheet_names = ["Sheet1"]
mock_excel_instance.parse.return_value = mock_df
mock_excel_file.return_value = mock_excel_instance

file_content = b"fake_excel_content"
result = _extract_text_from_excel(file_content)

expected = "| Name | Age |\n|------|-----|\n| John | 25 |\n\n"
assert result == expected
mock_excel_file.assert_called_once()
mock_df.dropna.assert_called_once_with(how="all", inplace=True)
mock_df.to_markdown.assert_called_once_with(index=False, floatfmt="")


@patch("pandas.ExcelFile")
def test_extract_text_from_excel_multiple_sheets(mock_excel_file):
"""Test extracting text from Excel file with multiple sheets."""
# Mock DataFrames for different sheets
mock_df1 = Mock()
mock_df1.dropna = Mock()
mock_df1.to_markdown.return_value = "| Product | Price |\n|---------|-------|\n| Apple | 1.50 |"

mock_df2 = Mock()
mock_df2.dropna = Mock()
mock_df2.to_markdown.return_value = "| City | Population |\n|------|------------|\n| NYC | 8000000 |"

# Mock ExcelFile
mock_excel_instance = Mock()
mock_excel_instance.sheet_names = ["Products", "Cities"]
mock_excel_instance.parse.side_effect = [mock_df1, mock_df2]
mock_excel_file.return_value = mock_excel_instance

file_content = b"fake_excel_content_multiple_sheets"
result = _extract_text_from_excel(file_content)

expected = (
"| Product | Price |\n|---------|-------|\n| Apple | 1.50 |\n\n"
"| City | Population |\n|------|------------|\n| NYC | 8000000 |\n\n"
)
assert result == expected
assert mock_excel_instance.parse.call_count == 2


@patch("pandas.ExcelFile")
def test_extract_text_from_excel_empty_sheets(mock_excel_file):
"""Test extracting text from Excel file with empty sheets."""
# Mock empty DataFrame
mock_df = Mock()
mock_df.dropna = Mock()
mock_df.to_markdown.return_value = ""

# Mock ExcelFile
mock_excel_instance = Mock()
mock_excel_instance.sheet_names = ["EmptySheet"]
mock_excel_instance.parse.return_value = mock_df
mock_excel_file.return_value = mock_excel_instance

file_content = b"fake_excel_empty_content"
result = _extract_text_from_excel(file_content)

expected = "\n\n"
assert result == expected


@patch("pandas.ExcelFile")
def test_extract_text_from_excel_sheet_parse_error(mock_excel_file):
"""Test handling of sheet parsing errors - should continue with other sheets."""
# Mock DataFrames - one successful, one that raises exception
mock_df_success = Mock()
mock_df_success.dropna = Mock()
mock_df_success.to_markdown.return_value = "| Data | Value |\n|------|-------|\n| Test | 123 |"

# Mock ExcelFile
mock_excel_instance = Mock()
mock_excel_instance.sheet_names = ["GoodSheet", "BadSheet"]
mock_excel_instance.parse.side_effect = [mock_df_success, Exception("Parse error")]
mock_excel_file.return_value = mock_excel_instance

file_content = b"fake_excel_mixed_content"
result = _extract_text_from_excel(file_content)

expected = "| Data | Value |\n|------|-------|\n| Test | 123 |\n\n"
assert result == expected


@patch("pandas.ExcelFile")
def test_extract_text_from_excel_file_error(mock_excel_file):
"""Test handling of Excel file reading errors."""
mock_excel_file.side_effect = Exception("Invalid Excel file")

file_content = b"invalid_excel_content"

with pytest.raises(Exception) as exc_info:
_extract_text_from_excel(file_content)

# Note: The function should raise TextExtractionError, but since it's not imported in the test,
# we check for the general Exception pattern
assert "Failed to extract text from Excel file" in str(exc_info.value)


@patch("pandas.ExcelFile")
def test_extract_text_from_excel_io_bytesio_usage(mock_excel_file):
"""Test that BytesIO is properly used with the file content."""
import io

# Mock DataFrame
mock_df = Mock()
mock_df.dropna = Mock()
mock_df.to_markdown.return_value = "| Test | Data |\n|------|------|\n| 1 | A |"

# Mock ExcelFile
mock_excel_instance = Mock()
mock_excel_instance.sheet_names = ["TestSheet"]
mock_excel_instance.parse.return_value = mock_df
mock_excel_file.return_value = mock_excel_instance

file_content = b"test_excel_bytes"
result = _extract_text_from_excel(file_content)

# Verify that ExcelFile was called with a BytesIO object
mock_excel_file.assert_called_once()
call_args = mock_excel_file.call_args[0][0]
assert isinstance(call_args, io.BytesIO)

expected = "| Test | Data |\n|------|------|\n| 1 | A |\n\n"
assert result == expected


@patch("pandas.ExcelFile")
def test_extract_text_from_excel_all_sheets_fail(mock_excel_file):
"""Test when all sheets fail to parse - should return empty string."""
# Mock ExcelFile
mock_excel_instance = Mock()
mock_excel_instance.sheet_names = ["BadSheet1", "BadSheet2"]
mock_excel_instance.parse.side_effect = [Exception("Error 1"), Exception("Error 2")]
mock_excel_file.return_value = mock_excel_instance

file_content = b"fake_excel_all_bad_sheets"
result = _extract_text_from_excel(file_content)

# Should return empty string when all sheets fail
assert result == ""


@patch("pandas.ExcelFile")
def test_extract_text_from_excel_markdown_formatting(mock_excel_file):
"""Test that markdown formatting parameters are correctly applied."""
# Mock DataFrame
mock_df = Mock()
mock_df.dropna = Mock()
mock_df.to_markdown.return_value = "| Float | Int |\n|-------|-----|\n| 123456.78 | 42 |"

# Mock ExcelFile
mock_excel_instance = Mock()
mock_excel_instance.sheet_names = ["NumberSheet"]
mock_excel_instance.parse.return_value = mock_df
mock_excel_file.return_value = mock_excel_instance

file_content = b"fake_excel_numbers"
result = _extract_text_from_excel(file_content)

# Verify to_markdown was called with correct parameters
mock_df.to_markdown.assert_called_once_with(index=False, floatfmt="")

expected = "| Float | Int |\n|-------|-----|\n| 123456.78 | 42 |\n\n"
assert result == expected

+ 18
- 4
api/tests/unit_tests/models/test_workflow.py Datei anzeigen

@@ -2,14 +2,13 @@ import json
from unittest import mock
from uuid import uuid4

import contexts
from constants import HIDDEN_VALUE
from core.variables import FloatVariable, IntegerVariable, SecretVariable, StringVariable
from models.workflow import Workflow, WorkflowNodeExecution


def test_environment_variables():
contexts.tenant_id.set("tenant_id")
# tenant_id context variable removed - using current_user.current_tenant_id directly

# Create a Workflow instance
workflow = Workflow(
@@ -38,9 +37,14 @@ def test_environment_variables():
{"name": "var4", "value": 3.14, "id": str(uuid4()), "selector": ["env", "var4"]}
)

# Mock current_user as an EndUser
mock_user = mock.Mock()
mock_user.tenant_id = "tenant_id"

with (
mock.patch("core.helper.encrypter.encrypt_token", return_value="encrypted_token"),
mock.patch("core.helper.encrypter.decrypt_token", return_value="secret"),
mock.patch("models.workflow.current_user", mock_user),
):
# Set the environment_variables property of the Workflow instance
variables = [variable1, variable2, variable3, variable4]
@@ -51,7 +55,7 @@ def test_environment_variables():


def test_update_environment_variables():
contexts.tenant_id.set("tenant_id")
# tenant_id context variable removed - using current_user.current_tenant_id directly

# Create a Workflow instance
workflow = Workflow(
@@ -80,9 +84,14 @@ def test_update_environment_variables():
{"name": "var4", "value": 3.14, "id": str(uuid4()), "selector": ["env", "var4"]}
)

# Mock current_user as an EndUser
mock_user = mock.Mock()
mock_user.tenant_id = "tenant_id"

with (
mock.patch("core.helper.encrypter.encrypt_token", return_value="encrypted_token"),
mock.patch("core.helper.encrypter.decrypt_token", return_value="secret"),
mock.patch("models.workflow.current_user", mock_user),
):
variables = [variable1, variable2, variable3, variable4]

@@ -104,7 +113,7 @@ def test_update_environment_variables():


def test_to_dict():
contexts.tenant_id.set("tenant_id")
# tenant_id context variable removed - using current_user.current_tenant_id directly

# Create a Workflow instance
workflow = Workflow(
@@ -121,9 +130,14 @@ def test_to_dict():

# Create some EnvironmentVariable instances

# Mock current_user as an EndUser
mock_user = mock.Mock()
mock_user.tenant_id = "tenant_id"

with (
mock.patch("core.helper.encrypter.encrypt_token", return_value="encrypted_token"),
mock.patch("core.helper.encrypter.decrypt_token", return_value="secret"),
mock.patch("models.workflow.current_user", mock_user),
):
# Set the environment_variables property of the Workflow instance
workflow.environment_variables = [

+ 158
- 0
api/tests/unit_tests/services/test_dataset_permission.py Datei anzeigen

@@ -0,0 +1,158 @@
from unittest.mock import Mock, patch

import pytest

from models.account import Account, TenantAccountRole
from models.dataset import Dataset, DatasetPermission, DatasetPermissionEnum
from services.dataset_service import DatasetService
from services.errors.account import NoPermissionError


class TestDatasetPermissionService:
"""Test cases for dataset permission checking functionality"""

def setup_method(self):
"""Set up test fixtures"""
# Mock tenant and user
self.tenant_id = "test-tenant-123"
self.creator_id = "creator-456"
self.normal_user_id = "normal-789"
self.owner_user_id = "owner-999"

# Mock dataset
self.dataset = Mock(spec=Dataset)
self.dataset.id = "dataset-123"
self.dataset.tenant_id = self.tenant_id
self.dataset.created_by = self.creator_id

# Mock users
self.creator_user = Mock(spec=Account)
self.creator_user.id = self.creator_id
self.creator_user.current_tenant_id = self.tenant_id
self.creator_user.current_role = TenantAccountRole.EDITOR

self.normal_user = Mock(spec=Account)
self.normal_user.id = self.normal_user_id
self.normal_user.current_tenant_id = self.tenant_id
self.normal_user.current_role = TenantAccountRole.NORMAL

self.owner_user = Mock(spec=Account)
self.owner_user.id = self.owner_user_id
self.owner_user.current_tenant_id = self.tenant_id
self.owner_user.current_role = TenantAccountRole.OWNER

def test_permission_check_different_tenant_should_fail(self):
"""Test that users from different tenants cannot access dataset"""
self.normal_user.current_tenant_id = "different-tenant"

with pytest.raises(NoPermissionError, match="You do not have permission to access this dataset."):
DatasetService.check_dataset_permission(self.dataset, self.normal_user)

def test_owner_can_access_any_dataset(self):
"""Test that tenant owners can access any dataset regardless of permission"""
self.dataset.permission = DatasetPermissionEnum.ONLY_ME

# Should not raise any exception
DatasetService.check_dataset_permission(self.dataset, self.owner_user)

def test_only_me_permission_creator_can_access(self):
"""Test ONLY_ME permission allows only creator to access"""
self.dataset.permission = DatasetPermissionEnum.ONLY_ME

# Creator should be able to access
DatasetService.check_dataset_permission(self.dataset, self.creator_user)

def test_only_me_permission_others_cannot_access(self):
"""Test ONLY_ME permission denies access to non-creators"""
self.dataset.permission = DatasetPermissionEnum.ONLY_ME

with pytest.raises(NoPermissionError, match="You do not have permission to access this dataset."):
DatasetService.check_dataset_permission(self.dataset, self.normal_user)

def test_all_team_permission_allows_access(self):
"""Test ALL_TEAM permission allows any team member to access"""
self.dataset.permission = DatasetPermissionEnum.ALL_TEAM

# Should not raise any exception for team members
DatasetService.check_dataset_permission(self.dataset, self.normal_user)
DatasetService.check_dataset_permission(self.dataset, self.creator_user)

@patch("services.dataset_service.db.session")
def test_partial_team_permission_creator_can_access(self, mock_session):
"""Test PARTIAL_TEAM permission allows creator to access"""
self.dataset.permission = DatasetPermissionEnum.PARTIAL_TEAM

# Should not raise any exception for creator
DatasetService.check_dataset_permission(self.dataset, self.creator_user)

# Should not query database for creator
mock_session.query.assert_not_called()

@patch("services.dataset_service.db.session")
def test_partial_team_permission_with_explicit_permission(self, mock_session):
"""Test PARTIAL_TEAM permission allows users with explicit permission"""
self.dataset.permission = DatasetPermissionEnum.PARTIAL_TEAM

# Mock database query to return a permission record
mock_permission = Mock(spec=DatasetPermission)
mock_session.query().filter_by().first.return_value = mock_permission

# Should not raise any exception
DatasetService.check_dataset_permission(self.dataset, self.normal_user)

# Verify database was queried correctly
mock_session.query().filter_by.assert_called_with(dataset_id=self.dataset.id, account_id=self.normal_user.id)

@patch("services.dataset_service.db.session")
def test_partial_team_permission_without_explicit_permission(self, mock_session):
"""Test PARTIAL_TEAM permission denies users without explicit permission"""
self.dataset.permission = DatasetPermissionEnum.PARTIAL_TEAM

# Mock database query to return None (no permission record)
mock_session.query().filter_by().first.return_value = None

with pytest.raises(NoPermissionError, match="You do not have permission to access this dataset."):
DatasetService.check_dataset_permission(self.dataset, self.normal_user)

# Verify database was queried correctly
mock_session.query().filter_by.assert_called_with(dataset_id=self.dataset.id, account_id=self.normal_user.id)

@patch("services.dataset_service.db.session")
def test_partial_team_permission_non_creator_without_permission_fails(self, mock_session):
"""Test that non-creators without explicit permission are denied access"""
self.dataset.permission = DatasetPermissionEnum.PARTIAL_TEAM

# Create a different user (not the creator)
other_user = Mock(spec=Account)
other_user.id = "other-user-123"
other_user.current_tenant_id = self.tenant_id
other_user.current_role = TenantAccountRole.NORMAL

# Mock database query to return None (no permission record)
mock_session.query().filter_by().first.return_value = None

with pytest.raises(NoPermissionError, match="You do not have permission to access this dataset."):
DatasetService.check_dataset_permission(self.dataset, other_user)

def test_partial_team_permission_uses_correct_enum(self):
"""Test that the method correctly uses DatasetPermissionEnum.PARTIAL_TEAM"""
# This test ensures we're using the enum instead of string literals
self.dataset.permission = DatasetPermissionEnum.PARTIAL_TEAM

# Creator should always have access
DatasetService.check_dataset_permission(self.dataset, self.creator_user)

@patch("services.dataset_service.logging")
@patch("services.dataset_service.db.session")
def test_permission_denied_logs_debug_message(self, mock_session, mock_logging):
"""Test that permission denied events are logged"""
self.dataset.permission = DatasetPermissionEnum.PARTIAL_TEAM
mock_session.query().filter_by().first.return_value = None

with pytest.raises(NoPermissionError):
DatasetService.check_dataset_permission(self.dataset, self.normal_user)

# Verify debug message was logged
mock_logging.debug.assert_called_with(
f"User {self.normal_user.id} does not have permission to access dataset {self.dataset.id}"
)

+ 2212
- 2201
api/uv.lock
Datei-Diff unterdrückt, da er zu groß ist
Datei anzeigen


+ 2
- 1
docker/.env.example Datei anzeigen

@@ -412,6 +412,7 @@ QDRANT_API_KEY=difyai123456
QDRANT_CLIENT_TIMEOUT=20
QDRANT_GRPC_ENABLED=false
QDRANT_GRPC_PORT=6334
QDRANT_REPLICATION_FACTOR=1

# Milvus configuration. Only available when VECTOR_STORE is `milvus`.
# The milvus uri.
@@ -801,7 +802,7 @@ MAX_TOOLS_NUM=10
MAX_PARALLEL_LIMIT=10

# The maximum number of iterations for agent setting
MAX_ITERATIONS_NUM=5
MAX_ITERATIONS_NUM=99

# ------------------------------
# Environment Variables for web Service

+ 5
- 5
docker/docker-compose-template.yaml Datei anzeigen

@@ -2,7 +2,7 @@ x-shared-env: &shared-api-worker-env
services:
# API service
api:
image: langgenius/dify-api:1.4.0
image: langgenius/dify-api:1.4.1
restart: always
environment:
# Use the shared environment variables.
@@ -31,7 +31,7 @@ services:
# worker service
# The Celery worker for processing the queue.
worker:
image: langgenius/dify-api:1.4.0
image: langgenius/dify-api:1.4.1
restart: always
environment:
# Use the shared environment variables.
@@ -57,7 +57,7 @@ services:

# Frontend web application.
web:
image: langgenius/dify-web:1.4.0
image: langgenius/dify-web:1.4.1
restart: always
environment:
CONSOLE_API_URL: ${CONSOLE_API_URL:-}
@@ -75,7 +75,7 @@ services:
LOOP_NODE_MAX_COUNT: ${LOOP_NODE_MAX_COUNT:-100}
MAX_TOOLS_NUM: ${MAX_TOOLS_NUM:-10}
MAX_PARALLEL_LIMIT: ${MAX_PARALLEL_LIMIT:-10}
MAX_ITERATIONS_NUM: ${MAX_ITERATIONS_NUM:-5}
MAX_ITERATIONS_NUM: ${MAX_ITERATIONS_NUM:-99}
ENABLE_WEBSITE_JINAREADER: ${ENABLE_WEBSITE_JINAREADER:-true}
ENABLE_WEBSITE_FIRECRAWL: ${ENABLE_WEBSITE_FIRECRAWL:-true}
ENABLE_WEBSITE_WATERCRAWL: ${ENABLE_WEBSITE_WATERCRAWL:-true}
@@ -142,7 +142,7 @@ services:

# plugin daemon
plugin_daemon:
image: langgenius/dify-plugin-daemon:0.0.10-local
image: langgenius/dify-plugin-daemon:0.1.1-local
restart: always
environment:
# Use the shared environment variables.

+ 1
- 1
docker/docker-compose.middleware.yaml Datei anzeigen

@@ -71,7 +71,7 @@ services:

# plugin daemon
plugin_daemon:
image: langgenius/dify-plugin-daemon:0.0.10-local
image: langgenius/dify-plugin-daemon:0.1.1-local
restart: always
env_file:
- ./middleware.env

+ 7
- 6
docker/docker-compose.yaml Datei anzeigen

@@ -138,6 +138,7 @@ x-shared-env: &shared-api-worker-env
QDRANT_CLIENT_TIMEOUT: ${QDRANT_CLIENT_TIMEOUT:-20}
QDRANT_GRPC_ENABLED: ${QDRANT_GRPC_ENABLED:-false}
QDRANT_GRPC_PORT: ${QDRANT_GRPC_PORT:-6334}
QDRANT_REPLICATION_FACTOR: ${QDRANT_REPLICATION_FACTOR:-1}
MILVUS_URI: ${MILVUS_URI:-http://host.docker.internal:19530}
MILVUS_DATABASE: ${MILVUS_DATABASE:-}
MILVUS_TOKEN: ${MILVUS_TOKEN:-}
@@ -353,7 +354,7 @@ x-shared-env: &shared-api-worker-env
LOOP_NODE_MAX_COUNT: ${LOOP_NODE_MAX_COUNT:-100}
MAX_TOOLS_NUM: ${MAX_TOOLS_NUM:-10}
MAX_PARALLEL_LIMIT: ${MAX_PARALLEL_LIMIT:-10}
MAX_ITERATIONS_NUM: ${MAX_ITERATIONS_NUM:-5}
MAX_ITERATIONS_NUM: ${MAX_ITERATIONS_NUM:-99}
TEXT_GENERATION_TIMEOUT_MS: ${TEXT_GENERATION_TIMEOUT_MS:-60000}
PGUSER: ${PGUSER:-${DB_USERNAME}}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-${DB_PASSWORD}}
@@ -500,7 +501,7 @@ x-shared-env: &shared-api-worker-env
services:
# API service
api:
image: langgenius/dify-api:1.4.0
image: langgenius/dify-api:1.4.1
restart: always
environment:
# Use the shared environment variables.
@@ -529,7 +530,7 @@ services:
# worker service
# The Celery worker for processing the queue.
worker:
image: langgenius/dify-api:1.4.0
image: langgenius/dify-api:1.4.1
restart: always
environment:
# Use the shared environment variables.
@@ -555,7 +556,7 @@ services:

# Frontend web application.
web:
image: langgenius/dify-web:1.4.0
image: langgenius/dify-web:1.4.1
restart: always
environment:
CONSOLE_API_URL: ${CONSOLE_API_URL:-}
@@ -573,7 +574,7 @@ services:
LOOP_NODE_MAX_COUNT: ${LOOP_NODE_MAX_COUNT:-100}
MAX_TOOLS_NUM: ${MAX_TOOLS_NUM:-10}
MAX_PARALLEL_LIMIT: ${MAX_PARALLEL_LIMIT:-10}
MAX_ITERATIONS_NUM: ${MAX_ITERATIONS_NUM:-5}
MAX_ITERATIONS_NUM: ${MAX_ITERATIONS_NUM:-99}
ENABLE_WEBSITE_JINAREADER: ${ENABLE_WEBSITE_JINAREADER:-true}
ENABLE_WEBSITE_FIRECRAWL: ${ENABLE_WEBSITE_FIRECRAWL:-true}
ENABLE_WEBSITE_WATERCRAWL: ${ENABLE_WEBSITE_WATERCRAWL:-true}
@@ -640,7 +641,7 @@ services:

# plugin daemon
plugin_daemon:
image: langgenius/dify-plugin-daemon:0.0.10-local
image: langgenius/dify-plugin-daemon:0.1.1-local
restart: always
environment:
# Use the shared environment variables.

BIN
images/GitHub_README_if.png Datei anzeigen


+ 1
- 1
web/.env.example Datei anzeigen

@@ -50,7 +50,7 @@ NEXT_PUBLIC_MAX_TOOLS_NUM=10
NEXT_PUBLIC_MAX_PARALLEL_LIMIT=10

# The maximum number of iterations for agent setting
NEXT_PUBLIC_MAX_ITERATIONS_NUM=5
NEXT_PUBLIC_MAX_ITERATIONS_NUM=99

NEXT_PUBLIC_ENABLE_WEBSITE_JINAREADER=true
NEXT_PUBLIC_ENABLE_WEBSITE_FIRECRAWL=true

+ 15
- 7
web/app/(commonLayout)/apps/AppCard.tsx Datei anzeigen

@@ -2,7 +2,7 @@

import { useContext, useContextSelector } from 'use-context-selector'
import { useRouter } from 'next/navigation'
import { useCallback, useEffect, useState } from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { RiBuildingLine, RiGlobalLine, RiLockLine, RiMoreFill } from '@remixicon/react'
import cn from '@/utils/classnames'
@@ -35,6 +35,7 @@ import Tooltip from '@/app/components/base/tooltip'
import AccessControl from '@/app/components/app/app-access-control'
import { AccessMode } from '@/models/access-control'
import { useGlobalPublicStore } from '@/context/global-public-context'
import { formatTime } from '@/utils/time'

export type AppCardProps = {
app: App
@@ -296,6 +297,15 @@ const AppCard = ({ app, onRefresh }: AppCardProps) => {
setTags(app.tags)
}, [app.tags])

const EditTimeText = useMemo(() => {
const timeText = formatTime({
date: (app.updated_at || app.created_at) * 1000,
dateFormat: 'MM/DD/YYYY h:mm',
})
return `${t('datasetDocuments.segment.editedAt')} ${timeText}`
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [app.updated_at, app.created_at])

return (
<>
<div
@@ -320,12 +330,10 @@ const AppCard = ({ app, onRefresh }: AppCardProps) => {
<div className='flex items-center text-sm font-semibold leading-5 text-text-secondary'>
<div className='truncate' title={app.name}>{app.name}</div>
</div>
<div className='flex items-center text-[10px] font-medium leading-[18px] text-text-tertiary'>
{app.mode === 'advanced-chat' && <div className='truncate'>{t('app.types.advanced').toUpperCase()}</div>}
{app.mode === 'chat' && <div className='truncate'>{t('app.types.chatbot').toUpperCase()}</div>}
{app.mode === 'agent-chat' && <div className='truncate'>{t('app.types.agent').toUpperCase()}</div>}
{app.mode === 'workflow' && <div className='truncate'>{t('app.types.workflow').toUpperCase()}</div>}
{app.mode === 'completion' && <div className='truncate'>{t('app.types.completion').toUpperCase()}</div>}
<div className='flex items-center gap-1 text-[10px] font-medium leading-[18px] text-text-tertiary'>
<div className='truncate' title={app.author_name}>{app.author_name}</div>
<div>·</div>
<div className='truncate'>{EditTimeText}</div>
</div>
</div>
<div className='flex h-5 w-5 shrink-0 items-center justify-center'>

+ 1
- 1
web/app/(commonLayout)/datasets/DatasetCard.tsx Datei anzeigen

@@ -111,7 +111,7 @@ const DatasetCard = ({
return (
<>
<div
className='group relative col-span-1 flex min-h-[160px] cursor-pointer flex-col rounded-xl border-[0.5px] border-solid border-components-card-border bg-components-card-bg shadow-sm transition-all duration-200 ease-in-out hover:shadow-lg'
className='group relative col-span-1 flex min-h-[171px] cursor-pointer flex-col rounded-xl border-[0.5px] border-solid border-components-card-border bg-components-card-bg shadow-sm transition-all duration-200 ease-in-out hover:shadow-lg'
data-disable-nprogress={true}
onClick={(e) => {
e.preventDefault()

+ 9
- 1
web/app/account/header.tsx Datei anzeigen

@@ -6,10 +6,12 @@ import Button from '../components/base/button'
import Avatar from './avatar'
import DifyLogo from '@/app/components/base/logo/dify-logo'
import { useCallback } from 'react'
import { useGlobalPublicStore } from '@/context/global-public-context'

const Header = () => {
const { t } = useTranslation()
const router = useRouter()
const systemFeatures = useGlobalPublicStore(s => s.systemFeatures)

const back = useCallback(() => {
router.back()
@@ -19,7 +21,13 @@ const Header = () => {
<div className='flex flex-1 items-center justify-between px-4'>
<div className='flex items-center gap-3'>
<div className='flex cursor-pointer items-center' onClick={back}>
<DifyLogo />
{systemFeatures.branding.enabled && systemFeatures.branding.login_page_logo
? <img
src={systemFeatures.branding.login_page_logo}
className='block h-[22px] w-auto object-contain'
alt='Dify logo'
/>
: <DifyLogo />}
</div>
<div className='h-4 w-[1px] origin-center rotate-[11.31deg] bg-divider-regular' />
<p className='title-3xl-semi-bold relative mt-[-2px] text-text-primary'>{t('common.account.account')}</p>

+ 1
- 14
web/app/components/app/log/list.tsx Datei anzeigen

@@ -32,7 +32,6 @@ import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
import TextGeneration from '@/app/components/app/text-generate/item'
import { addFileInfos, sortAgentSorts } from '@/app/components/tools/utils'
import MessageLogModal from '@/app/components/base/message-log-modal'
import PromptLogModal from '@/app/components/base/prompt-log-modal'
import { useStore as useAppStore } from '@/app/components/app/store'
import { useAppContext } from '@/context/app-context'
import useTimestamp from '@/hooks/use-timestamp'
@@ -191,13 +190,11 @@ function DetailPanel({ detail, onFeedback }: IDetailPanel) {
const { userProfile: { timezone } } = useAppContext()
const { formatTime } = useTimestamp()
const { onClose, appDetail } = useContext(DrawerContext)
const { currentLogItem, setCurrentLogItem, showMessageLogModal, setShowMessageLogModal, showPromptLogModal, setShowPromptLogModal, currentLogModalActiveTab } = useAppStore(useShallow(state => ({
const { currentLogItem, setCurrentLogItem, showMessageLogModal, setShowMessageLogModal, currentLogModalActiveTab } = useAppStore(useShallow(state => ({
currentLogItem: state.currentLogItem,
setCurrentLogItem: state.setCurrentLogItem,
showMessageLogModal: state.showMessageLogModal,
setShowMessageLogModal: state.setShowMessageLogModal,
showPromptLogModal: state.showPromptLogModal,
setShowPromptLogModal: state.setShowPromptLogModal,
currentLogModalActiveTab: state.currentLogModalActiveTab,
})))
const { t } = useTranslation()
@@ -518,16 +515,6 @@ function DetailPanel({ detail, onFeedback }: IDetailPanel) {
defaultTab={currentLogModalActiveTab}
/>
)}
{showPromptLogModal && (
<PromptLogModal
width={width}
currentLogItem={currentLogItem}
onCancel={() => {
setCurrentLogItem()
setShowPromptLogModal(false)
}}
/>
)}
</div>
)
}

+ 6
- 4
web/app/components/base/chat/chat-with-history/sidebar/index.tsx Datei anzeigen

@@ -148,10 +148,12 @@ const Sidebar = ({ isPanel }: Props) => {
'flex shrink-0 items-center gap-1.5 px-1',
)}>
<div className='system-2xs-medium-uppercase text-text-tertiary'>{t('share.chat.poweredBy')}</div>
{systemFeatures.branding.enabled ? (
<img src={systemFeatures.branding.login_page_logo} alt='logo' className='block h-5 w-auto' />
) : (
<DifyLogo size='small' />)
{
systemFeatures.branding.enabled && systemFeatures.branding.workspace_logo
? <img src={systemFeatures.branding.workspace_logo} alt='logo' className='block h-5 w-auto' />
: appData?.custom_config?.replace_webapp_logo
? <img src={`${appData?.custom_config?.replace_webapp_logo}`} alt='logo' className='block h-5 w-auto' />
: <DifyLogo size='small' />
}
</div>
)}

+ 1
- 3
web/app/components/base/chat/chat/answer/index.tsx Datei anzeigen

@@ -234,6 +234,4 @@ const Answer: FC<AnswerProps> = ({
)
}

export default memo(Answer, (prevProps, nextProps) =>
prevProps.responding === false && nextProps.responding === false,
)
export default memo(Answer)

+ 9
- 6
web/app/components/base/chat/embedded-chatbot/header/index.tsx Datei anzeigen

@@ -13,6 +13,7 @@ import Divider from '@/app/components/base/divider'
import ViewFormDropdown from '@/app/components/base/chat/embedded-chatbot/inputs-form/view-form-dropdown'
import DifyLogo from '@/app/components/base/logo/dify-logo'
import cn from '@/utils/classnames'
import { useGlobalPublicStore } from '@/context/global-public-context'

export type IHeaderProps = {
isMobile?: boolean
@@ -42,6 +43,7 @@ const Header: FC<IHeaderProps> = ({
const [parentOrigin, setParentOrigin] = useState('')
const [showToggleExpandButton, setShowToggleExpandButton] = useState(false)
const [expanded, setExpanded] = useState(false)
const systemFeatures = useGlobalPublicStore(s => s.systemFeatures)

const handleMessageReceived = useCallback((event: MessageEvent) => {
let currentParentOrigin = parentOrigin
@@ -85,12 +87,13 @@ const Header: FC<IHeaderProps> = ({
'flex shrink-0 items-center gap-1.5 px-2',
)}>
<div className='system-2xs-medium-uppercase text-text-tertiary'>{t('share.chat.poweredBy')}</div>
{appData?.custom_config?.replace_webapp_logo && (
<img src={appData?.custom_config?.replace_webapp_logo} alt='logo' className='block h-5 w-auto' />
)}
{!appData?.custom_config?.replace_webapp_logo && (
<DifyLogo size='small' />
)}
{
systemFeatures.branding.enabled && systemFeatures.branding.workspace_logo
? <img src={systemFeatures.branding.workspace_logo} alt='logo' className='block h-5 w-auto' />
: appData?.custom_config?.replace_webapp_logo
? <img src={`${appData?.custom_config?.replace_webapp_logo}`} alt='logo' className='block h-5 w-auto' />
: <DifyLogo size='small' />
}
</div>
)}
</div>

+ 9
- 6
web/app/components/base/chat/embedded-chatbot/index.tsx Datei anzeigen

@@ -22,6 +22,7 @@ import ChatWrapper from '@/app/components/base/chat/embedded-chatbot/chat-wrappe
import DifyLogo from '@/app/components/base/logo/dify-logo'
import cn from '@/utils/classnames'
import useDocumentTitle from '@/hooks/use-document-title'
import { useGlobalPublicStore } from '@/context/global-public-context'

const Chatbot = () => {
const {
@@ -37,6 +38,7 @@ const Chatbot = () => {
themeBuilder,
} = useEmbeddedChatbotContext()
const { t } = useTranslation()
const systemFeatures = useGlobalPublicStore(s => s.systemFeatures)

const customConfig = appData?.custom_config
const site = appData?.site
@@ -115,12 +117,13 @@ const Chatbot = () => {
'flex shrink-0 items-center gap-1.5 px-2',
)}>
<div className='system-2xs-medium-uppercase text-text-tertiary'>{t('share.chat.poweredBy')}</div>
{appData?.custom_config?.replace_webapp_logo && (
<img src={appData?.custom_config?.replace_webapp_logo} alt='logo' className='block h-5 w-auto' />
)}
{!appData?.custom_config?.replace_webapp_logo && (
<DifyLogo size='small' />
)}
{
systemFeatures.branding.enabled && systemFeatures.branding.workspace_logo
? <img src={systemFeatures.branding.workspace_logo} alt='logo' className='block h-5 w-auto' />
: appData?.custom_config?.replace_webapp_logo
? <img src={`${appData?.custom_config?.replace_webapp_logo}`} alt='logo' className='block h-5 w-auto' />
: <DifyLogo size='small' />
}
</div>
)}
</div>

+ 3
- 10
web/app/components/base/logo/dify-logo.tsx Datei anzeigen

@@ -3,7 +3,6 @@ import type { FC } from 'react'
import classNames from '@/utils/classnames'
import useTheme from '@/hooks/use-theme'
import { basePath } from '@/utils/var'
import { useGlobalPublicStore } from '@/context/global-public-context'
export type LogoStyle = 'default' | 'monochromeWhite'

export const logoPathMap: Record<LogoStyle, string> = {
@@ -32,18 +31,12 @@ const DifyLogo: FC<DifyLogoProps> = ({
}) => {
const { theme } = useTheme()
const themedStyle = (theme === 'dark' && style === 'default') ? 'monochromeWhite' : style
const { systemFeatures } = useGlobalPublicStore()
const hasBrandingLogo = Boolean(systemFeatures.branding.enabled && systemFeatures.branding.workspace_logo)

let src = `${basePath}${logoPathMap[themedStyle]}`
if (hasBrandingLogo)
src = systemFeatures.branding.workspace_logo

return (
<img
src={src}
className={classNames('block object-contain', logoSizeMap[size], hasBrandingLogo && 'w-auto', className)}
alt={hasBrandingLogo ? 'Logo' : 'Dify logo'}
src={`${basePath}${logoPathMap[themedStyle]}`}
className={classNames('block object-contain', logoSizeMap[size], className)}
alt='Dify logo'
/>
)
}

+ 22
- 11
web/app/components/base/mermaid/index.tsx Datei anzeigen

@@ -91,6 +91,11 @@ const initMermaid = () => {
numberSectionStyles: 4,
axisFormat: '%Y-%m-%d',
},
mindmap: {
useMaxWidth: true,
padding: 10,
diagramPadding: 20,
},
maxTextSize: 50000,
})
isMermaidInitialized = true
@@ -289,11 +294,12 @@ const Flowchart = React.forwardRef((props: {
try {
let finalCode: string

// Check if it's a gantt chart
// Check if it's a gantt chart or mindmap
const isGanttChart = primitiveCode.trim().startsWith('gantt')
const isMindMap = primitiveCode.trim().startsWith('mindmap')

if (isGanttChart) {
// For gantt charts, ensure each task is on its own line
if (isGanttChart || isMindMap) {
// For gantt charts and mindmaps, ensure each task is on its own line
// and preserve exact whitespace/format
finalCode = primitiveCode.trim()
}
@@ -352,6 +358,11 @@ const Flowchart = React.forwardRef((props: {
numberSectionStyles: 4,
axisFormat: '%Y-%m-%d',
},
mindmap: {
useMaxWidth: true,
padding: 10,
diagramPadding: 20,
},
}

if (look === 'classic') {
@@ -476,15 +487,15 @@ const Flowchart = React.forwardRef((props: {
'bg-white': currentTheme === Theme.light,
'bg-slate-900': currentTheme === Theme.dark,
}),
mermaidDiv: cn('mermaid relative h-auto w-full cursor-pointer', {
mermaidDiv: cn('mermaid cursor-pointer h-auto w-full relative', {
'bg-white': currentTheme === Theme.light,
'bg-slate-900': currentTheme === Theme.dark,
}),
errorMessage: cn('px-[26px] py-4', {
errorMessage: cn('py-4 px-[26px]', {
'text-red-500': currentTheme === Theme.light,
'text-red-400': currentTheme === Theme.dark,
}),
errorIcon: cn('h-6 w-6', {
errorIcon: cn('w-6 h-6', {
'text-red-500': currentTheme === Theme.light,
'text-red-400': currentTheme === Theme.dark,
}),
@@ -492,7 +503,7 @@ const Flowchart = React.forwardRef((props: {
'text-gray-700': currentTheme === Theme.light,
'text-gray-300': currentTheme === Theme.dark,
}),
themeToggle: cn('flex h-10 w-10 items-center justify-center rounded-full shadow-md backdrop-blur-sm transition-all duration-300', {
themeToggle: cn('flex items-center justify-center w-10 h-10 rounded-full transition-all duration-300 shadow-md backdrop-blur-sm', {
'bg-white/80 hover:bg-white hover:shadow-lg text-gray-700 border border-gray-200': currentTheme === Theme.light,
'bg-slate-800/80 hover:bg-slate-700 hover:shadow-lg text-yellow-300 border border-slate-600': currentTheme === Theme.dark,
}),
@@ -501,7 +512,7 @@ const Flowchart = React.forwardRef((props: {
// Style classes for look options
const getLookButtonClass = (lookType: 'classic' | 'handDrawn') => {
return cn(
'system-sm-medium mb-4 flex h-8 w-[calc((100%-8px)/2)] cursor-pointer items-center justify-center rounded-lg border border-components-option-card-option-border bg-components-option-card-option-bg text-text-secondary',
'flex items-center justify-center mb-4 w-[calc((100%-8px)/2)] h-8 rounded-lg border border-components-option-card-option-border bg-components-option-card-option-bg cursor-pointer system-sm-medium text-text-secondary',
look === lookType && 'border-[1.5px] border-components-option-card-option-selected-border bg-components-option-card-option-selected-bg text-text-primary',
currentTheme === Theme.dark && 'border-slate-600 bg-slate-800 text-slate-300',
look === lookType && currentTheme === Theme.dark && 'border-blue-500 bg-slate-700 text-white',
@@ -512,7 +523,7 @@ const Flowchart = React.forwardRef((props: {
<div ref={ref as React.RefObject<HTMLDivElement>} className={themeClasses.container}>
<div className={themeClasses.segmented}>
<div className="msh-segmented-group">
<label className="msh-segmented-item m-2 flex w-[200px] items-center space-x-1">
<label className="msh-segmented-item flex items-center space-x-1 m-2 w-[200px]">
<div
key='classic'
className={getLookButtonClass('classic')}
@@ -534,7 +545,7 @@ const Flowchart = React.forwardRef((props: {
<div ref={containerRef} style={{ position: 'absolute', visibility: 'hidden', height: 0, overflow: 'hidden' }} />

{isLoading && !svgCode && (
<div className='px-[26px] py-4'>
<div className='py-4 px-[26px]'>
<LoadingAnim type='text'/>
{!isCodeComplete && (
<div className="mt-2 text-sm text-gray-500">
@@ -546,7 +557,7 @@ const Flowchart = React.forwardRef((props: {

{svgCode && (
<div className={themeClasses.mermaidDiv} style={{ objectFit: 'cover' }} onClick={() => setImagePreviewUrl(svgCode)}>
<div className="absolute bottom-2 left-2 z-[100]">
<div className="absolute left-2 bottom-2 z-[100]">
<button
onClick={(e) => {
e.stopPropagation()

+ 15
- 4
web/app/components/base/mermaid/utils.ts Datei anzeigen

@@ -22,6 +22,10 @@ export function preprocessMermaidCode(code: string): string {
.replace(/section\s+([^:]+):/g, (match, sectionName) => `section ${sectionName}:`)
// Fix common syntax issues
.replace(/fifopacket/g, 'rect')
// Ensure graph has direction
.replace(/^graph\s+((?:TB|BT|RL|LR)*)/, (match, direction) => {
return direction ? match : 'graph TD'
})
// Clean up empty lines and extra spaces
.trim()
}
@@ -32,9 +36,9 @@ export function preprocessMermaidCode(code: string): string {
export function prepareMermaidCode(code: string, style: 'classic' | 'handDrawn'): string {
let finalCode = preprocessMermaidCode(code)

// Special handling for gantt charts
if (finalCode.trim().startsWith('gantt')) {
// For gantt charts, preserve the structure exactly as is
// Special handling for gantt charts and mindmaps
if (finalCode.trim().startsWith('gantt') || finalCode.trim().startsWith('mindmap')) {
// For gantt charts and mindmaps, preserve the structure exactly as is
return finalCode
}

@@ -173,8 +177,15 @@ export function isMermaidCodeComplete(code: string): boolean {
return lines.length >= 3
}

// Special handling for mindmaps
if (trimmedCode.startsWith('mindmap')) {
// For mindmaps, check if it has at least a root node
const lines = trimmedCode.split('\n').filter(line => line.trim().length > 0)
return lines.length >= 2
}

// Check for basic syntax structure
const hasValidStart = /^(graph|flowchart|sequenceDiagram|classDiagram|classDef|class|stateDiagram|gantt|pie|er|journey|requirementDiagram)/.test(trimmedCode)
const hasValidStart = /^(graph|flowchart|sequenceDiagram|classDiagram|classDef|class|stateDiagram|gantt|pie|er|journey|requirementDiagram|mindmap)/.test(trimmedCode)

// Check for balanced brackets and parentheses
const isBalanced = (() => {

+ 3
- 3
web/app/components/base/tab-slider/index.tsx Datei anzeigen

@@ -40,7 +40,7 @@ const TabSlider: FC<TabSliderProps> = ({
const newIndex = options.findIndex(option => option.value === value)
setActiveIndex(newIndex)
updateSliderStyle(newIndex)
}, [value, options, pluginList])
}, [value, options, pluginList?.total])

return (
<div className={cn(className, 'relative inline-flex items-center justify-center rounded-[10px] bg-components-segmented-control-bg-normal p-0.5')}>
@@ -69,13 +69,13 @@ const TabSlider: FC<TabSliderProps> = ({
{option.text}
{/* if no plugin installed, the badge won't show */}
{option.value === 'plugins'
&& (pluginList?.plugins.length ?? 0) > 0
&& (pluginList?.total ?? 0) > 0
&& <Badge
size='s'
uppercase={true}
state={BadgeState.Default}
>
{pluginList?.plugins.length}
{pluginList?.total}
</Badge>
}
</div>

+ 14
- 6
web/app/components/custom/custom-web-app-brand/index.tsx Datei anzeigen

@@ -24,6 +24,7 @@ import {
} from '@/service/common'
import { useAppContext } from '@/context/app-context'
import cn from '@/utils/classnames'
import { useGlobalPublicStore } from '@/context/global-public-context'

const ALLOW_FILE_EXTENSIONS = ['svg', 'png']

@@ -39,6 +40,7 @@ const CustomWebAppBrand = () => {
const [fileId, setFileId] = useState('')
const [imgKey, setImgKey] = useState(Date.now())
const [uploadProgress, setUploadProgress] = useState(0)
const systemFeatures = useGlobalPublicStore(s => s.systemFeatures)
const isSandbox = enableBilling && plan.type === Plan.sandbox
const uploading = uploadProgress > 0 && uploadProgress < 100
const webappLogo = currentWorkspace.custom_config?.replace_webapp_logo || ''
@@ -244,9 +246,12 @@ const CustomWebAppBrand = () => {
{!webappBrandRemoved && (
<>
<div className='system-2xs-medium-uppercase text-text-tertiary'>POWERED BY</div>
{webappLogo
? <img src={`${webappLogo}?hash=${imgKey}`} alt='logo' className='block h-5 w-auto' />
: <DifyLogo size='small' />
{
systemFeatures.branding.enabled && systemFeatures.branding.workspace_logo
? <img src={systemFeatures.branding.workspace_logo} alt='logo' className='block h-5 w-auto' />
: webappLogo
? <img src={`${webappLogo}?hash=${imgKey}`} alt='logo' className='block h-5 w-auto' />
: <DifyLogo size='small' />
}
</>
)}
@@ -303,9 +308,12 @@ const CustomWebAppBrand = () => {
{!webappBrandRemoved && (
<>
<div className='system-2xs-medium-uppercase text-text-tertiary'>POWERED BY</div>
{webappLogo
? <img src={`${webappLogo}?hash=${imgKey}`} alt='logo' className='block h-5 w-auto' />
: <DifyLogo size='small' />
{
systemFeatures.branding.enabled && systemFeatures.branding.workspace_logo
? <img src={systemFeatures.branding.workspace_logo} alt='logo' className='block h-5 w-auto' />
: webappLogo
? <img src={`${webappLogo}?hash=${imgKey}`} alt='logo' className='block h-5 w-auto' />
: <DifyLogo size='small' />
}
</>
)}

+ 1
- 0
web/app/components/datasets/documents/detail/completed/new-child-segment.tsx Datei anzeigen

@@ -91,6 +91,7 @@ const NewChildSegmentModal: FC<NewChildSegmentModalProps> = ({
customComponent: isFullDocMode && CustomButton,
})
handleCancel('add')
setContent('')
if (isFullDocMode) {
refreshTimer.current = setTimeout(() => {
onSave()

+ 3
- 0
web/app/components/datasets/documents/detail/new-segment.tsx Datei anzeigen

@@ -118,6 +118,9 @@ const NewSegmentModal: FC<NewSegmentModalProps> = ({
customComponent: CustomButton,
})
handleCancel('add')
setQuestion('')
setAnswer('')
setKeywords([])
refreshTimer.current = setTimeout(() => {
onSave()
}, 3000)

+ 10
- 1
web/app/components/header/account-about/index.tsx Datei anzeigen

@@ -9,6 +9,7 @@ import type { LangGeniusVersionResponse } from '@/models/common'
import { IS_CE_EDITION } from '@/config'
import DifyLogo from '@/app/components/base/logo/dify-logo'
import { noop } from 'lodash-es'
import { useGlobalPublicStore } from '@/context/global-public-context'

type IAccountSettingProps = {
langeniusVersionInfo: LangGeniusVersionResponse
@@ -21,6 +22,7 @@ export default function AccountAbout({
}: IAccountSettingProps) {
const { t } = useTranslation()
const isLatest = langeniusVersionInfo.current_version === langeniusVersionInfo.latest_version
const systemFeatures = useGlobalPublicStore(s => s.systemFeatures)

return (
<Modal
@@ -33,7 +35,14 @@ export default function AccountAbout({
<RiCloseLine className='h-4 w-4 text-text-tertiary' />
</div>
<div className='flex flex-col items-center gap-4 py-8'>
<DifyLogo size='large' className='mx-auto' />
{systemFeatures.branding.enabled && systemFeatures.branding.workspace_logo
? <img
src={systemFeatures.branding.workspace_logo}
className='block h-7 w-auto object-contain'
alt='logo'
/>
: <DifyLogo size='large' className='mx-auto' />}

<div className='text-center text-xs font-normal text-text-tertiary'>Version {langeniusVersionInfo?.current_version}</div>
<div className='flex flex-col items-center gap-2 text-center text-xs font-normal text-text-secondary'>
<div>© {dayjs().year()} LangGenius, Inc., Contributors.</div>

+ 1
- 2
web/app/components/header/account-setting/model-provider-page/provider-icon/index.tsx Datei anzeigen

@@ -1,6 +1,5 @@
import type { FC } from 'react'
import type { ModelProvider } from '../declarations'
import { basePath } from '@/utils/var'
import { useLanguage } from '../hooks'
import { Openai } from '@/app/components/base/icons/src/vender/other'
import { AnthropicDark, AnthropicLight } from '@/app/components/base/icons/src/public/llm'
@@ -41,7 +40,7 @@ const ProviderIcon: FC<ProviderIconProps> = ({
<div className={cn('inline-flex items-center gap-2', className)}>
<img
alt='provider-icon'
src={basePath + renderI18nObject(provider.icon_small, language)}
src={renderI18nObject(provider.icon_small, language)}
className='h-6 w-6'
/>
<div className='system-md-semibold text-text-primary'>

+ 16
- 2
web/app/components/header/index.tsx Datei anzeigen

@@ -21,6 +21,7 @@ import { useModalContext } from '@/context/modal-context'
import PlanBadge from './plan-badge'
import LicenseNav from './license-env'
import { Plan } from '../billing/type'
import { useGlobalPublicStore } from '@/context/global-public-context'

const navClassName = `
flex items-center relative mr-0 sm:mr-3 px-3 h-8 rounded-xl
@@ -36,6 +37,7 @@ const Header = () => {
const [isShowNavMenu, { toggle, setFalse: hideNavMenu }] = useBoolean(false)
const { enableBilling, plan } = useProviderContext()
const { setShowPricingModal, setShowAccountSettingModal } = useModalContext()
const systemFeatures = useGlobalPublicStore(s => s.systemFeatures)
const isFreePlan = plan.type === Plan.sandbox
const handlePlanClick = useCallback(() => {
if (isFreePlan)
@@ -61,7 +63,13 @@ const Header = () => {
!isMobile
&& <div className='flex shrink-0 items-center gap-1.5 self-stretch pl-3'>
<Link href="/apps" className='flex h-8 shrink-0 items-center justify-center gap-2 px-0.5'>
<DifyLogo />
{systemFeatures.branding.enabled && systemFeatures.branding.workspace_logo
? <img
src={systemFeatures.branding.workspace_logo}
className='block h-[22px] w-auto object-contain'
alt='logo'
/>
: <DifyLogo />}
</Link>
<div className='font-light text-divider-deep'>/</div>
<div className='flex items-center gap-0.5'>
@@ -76,7 +84,13 @@ const Header = () => {
{isMobile && (
<div className='flex'>
<Link href="/apps" className='mr-4 flex items-center'>
<DifyLogo />
{systemFeatures.branding.enabled && systemFeatures.branding.workspace_logo
? <img
src={systemFeatures.branding.workspace_logo}
className='block h-[22px] w-auto object-contain'
alt='logo'
/>
: <DifyLogo />}
</Link>
<div className='font-light text-divider-deep'>/</div>
{enableBilling ? <PlanBadge allowHover sandboxAsUpgrade plan={plan.type} onClick={handlePlanClick} /> : <LicenseNav />}

+ 1
- 1
web/app/components/plugins/plugin-detail-panel/action-list.tsx Datei anzeigen

@@ -78,7 +78,7 @@ const ActionList = ({
className='w-full'
onClick={() => setShowSettingAuth(true)}
disabled={!isCurrentWorkspaceManager}
>{t('tools.auth.unauthorized')}</Button>
>{t('workflow.nodes.tool.authorize')}</Button>
)}
</div>
<div className='flex flex-col gap-2'>

+ 2
- 1
web/app/components/plugins/plugin-detail-panel/multiple-tool-selector/index.tsx Datei anzeigen

@@ -141,7 +141,7 @@ const MultipleToolSelector = ({
}
panelShowState={panelShowState}
onPanelShowStateChange={setPanelShowState}
isEdit={false}
/>
{value.length === 0 && (
<div className='system-xs-regular flex justify-center rounded-[10px] bg-background-section p-3 text-text-tertiary'>{t('plugin.detailPanel.toolSelector.empty')}</div>
@@ -158,6 +158,7 @@ const MultipleToolSelector = ({
onSelect={item => handleConfigure(item, index)}
onDelete={() => handleDelete(index)}
supportEnableSwitch
isEdit
/>
</div>
))}

+ 3
- 1
web/app/components/plugins/plugin-detail-panel/tool-selector/index.tsx Datei anzeigen

@@ -54,6 +54,7 @@ type Props = {
scope?: string
value?: ToolValue
selectedTools?: ToolValue[]
isEdit?: boolean
onSelect: (tool: {
provider_name: string
tool_name: string
@@ -77,6 +78,7 @@ type Props = {
const ToolSelector: FC<Props> = ({
value,
selectedTools,
isEdit,
disabled,
placement = 'left',
offset = 4,
@@ -277,7 +279,7 @@ const ToolSelector: FC<Props> = ({
<div className={cn('relative max-h-[642px] min-h-20 w-[361px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur pb-4 shadow-lg backdrop-blur-sm', !isShowSettingAuth && 'overflow-y-auto pb-2')}>
{!isShowSettingAuth && (
<>
<div className='system-xl-semibold px-4 pb-1 pt-3.5 text-text-primary'>{t('plugin.detailPanel.toolSelector.title')}</div>
<div className='system-xl-semibold px-4 pb-1 pt-3.5 text-text-primary'>{t(`plugin.detailPanel.toolSelector.${isEdit ? 'toolSetting' : 'title'}`)}</div>
{/* base form */}
<div className='flex flex-col gap-3 px-4 py-2'>
<div className='flex flex-col gap-1'>

+ 11
- 2
web/app/components/plugins/plugin-page/plugins-panel.tsx Datei anzeigen

@@ -1,5 +1,6 @@
'use client'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import type { FilterState } from './filter-management'
import FilterManagement from './filter-management'
import List from './list'
@@ -7,14 +8,16 @@ import { useInstalledLatestVersion, useInstalledPluginList, useInvalidateInstall
import PluginDetailPanel from '@/app/components/plugins/plugin-detail-panel'
import { usePluginPageContext } from './context'
import { useDebounceFn } from 'ahooks'
import Button from '@/app/components/base/button'
import Empty from './empty'
import Loading from '../../base/loading'
import { PluginSource } from '../types'

const PluginsPanel = () => {
const { t } = useTranslation()
const filters = usePluginPageContext(v => v.filters) as FilterState
const setFilters = usePluginPageContext(v => v.setFilters)
const { data: pluginList, isLoading: isPluginListLoading } = useInstalledPluginList()
const { data: pluginList, isLoading: isPluginListLoading, isFetching, isLastPage, loadNextPage } = useInstalledPluginList()
const { data: installedLatestVersion } = useInstalledLatestVersion(
pluginList?.plugins
.filter(plugin => plugin.source === PluginSource.marketplace)
@@ -64,10 +67,16 @@ const PluginsPanel = () => {
/>
</div>
{isPluginListLoading ? <Loading type='app' /> : (filteredList?.length ?? 0) > 0 ? (
<div className='flex grow flex-wrap content-start items-start gap-2 self-stretch px-12'>
<div className='flex grow flex-wrap content-start items-start justify-center gap-2 self-stretch px-12'>
<div className='w-full'>
<List pluginList={filteredList || []} />
</div>
{!isLastPage && !isFetching && (
<Button onClick={loadNextPage}>
{t('workflow.common.loadMore')}
</Button>
)}
{isFetching && <div className='system-md-semibold text-text-secondary'>{t('appLog.detail.loading')}</div>}
</div>
) : (
<Empty />

+ 5
- 0
web/app/components/plugins/types.ts Datei anzeigen

@@ -325,6 +325,11 @@ export type InstalledPluginListResponse = {
plugins: PluginDetail[]
}

export type InstalledPluginListWithTotalResponse = {
plugins: PluginDetail[]
total: number
}

export type InstalledLatestVersionResponse = {
versions: {
[plugin_id: string]: {

+ 7
- 5
web/app/components/share/text-generation/index.tsx Datei anzeigen

@@ -641,11 +641,13 @@ const TextGeneration: FC<IMainProps> = ({
!isPC && resultExisted && 'rounded-b-2xl border-b-[0.5px] border-divider-regular',
)}>
<div className='system-2xs-medium-uppercase text-text-tertiary'>{t('share.chat.poweredBy')}</div>
{systemFeatures.branding.enabled ? (
<img src={systemFeatures.branding.login_page_logo} alt='logo' className='block h-5 w-auto' />
) : (
<DifyLogo size='small' />
)}
{
systemFeatures.branding.enabled && systemFeatures.branding.workspace_logo
? <img src={systemFeatures.branding.workspace_logo} alt='logo' className='block h-5 w-auto' />
: customConfig?.replace_webapp_logo
? <img src={`${customConfig?.replace_webapp_logo}`} alt='logo' className='block h-5 w-auto' />
: <DifyLogo size='small' />
}
</div>
)}
</div>

+ 1
- 1
web/app/components/workflow/nodes/agent/default.ts Datei anzeigen

@@ -126,7 +126,7 @@ const nodeDefault: NodeDefault<AgentNodeType> = {
}
}
// common params
if (param.required && !payload.agent_parameters?.[param.name]?.value) {
if (param.required && !(payload.agent_parameters?.[param.name]?.value || param.default)) {
return {
isValid: false,
errorMessage: t('workflow.errorMsg.fieldRequired', { field: renderI18nObject(param.label, language) }),

+ 7
- 1
web/app/components/workflow/nodes/http/use-config.ts Datei anzeigen

@@ -42,6 +42,12 @@ const useConfig = (id: string, payload: HttpNodeType) => {
data: transformToBodyPayload(bodyData, [BodyType.formData, BodyType.xWwwFormUrlencoded].includes(newInputs.body.type)),
}
}
else if (!bodyData) {
newInputs.body = {
...newInputs.body,
data: [],
}
}

setInputs(newInputs)
setIsDataReady(true)
@@ -151,7 +157,7 @@ const useConfig = (id: string, payload: HttpNodeType) => {
inputs.url,
inputs.headers,
inputs.params,
typeof inputs.body.data === 'string' ? inputs.body.data : inputs.body.data.map(item => item.value).join(''),
typeof inputs.body.data === 'string' ? inputs.body.data : inputs.body.data?.map(item => item.value).join(''),
fileVarInputs,
])


+ 1
- 1
web/app/components/workflow/nodes/tool/panel.tsx Datei anzeigen

@@ -79,7 +79,7 @@ const Panel: FC<NodePanelProps<ToolNodeType>> = ({
className='w-full'
onClick={showSetAuthModal}
>
{t(`${i18nPrefix}.toAuthorize`)}
{t(`${i18nPrefix}.authorize`)}
</Button>
</div>
</>

+ 1
- 1
web/app/reset-password/set-password/page.tsx Datei anzeigen

@@ -105,7 +105,7 @@ const ChangePasswordForm = () => {
</div>

<div className="mx-auto mt-6 w-full">
<div className="bg-white">
<div>
{/* Password */}
<div className='mb-5'>
<label htmlFor="password" className="system-md-semibold my-2 text-text-secondary">

+ 1
- 1
web/app/routePrefixHandle.tsx Datei anzeigen

@@ -10,7 +10,7 @@ export default function RoutePrefixHandle() {
const addPrefixToImg = (e: HTMLImageElement) => {
const url = new URL(e.src)
const prefix = url.pathname.substr(0, basePath.length)
if (prefix !== basePath) {
if (prefix !== basePath && !url.href.startsWith('blob:') && !url.href.startsWith('data:')) {
url.pathname = basePath + url.pathname
e.src = url.toString()
}

+ 9
- 1
web/app/signin/_header.tsx Datei anzeigen

@@ -7,6 +7,7 @@ import { languages } from '@/i18n/language'
import type { Locale } from '@/i18n'
import I18n from '@/context/i18n'
import dynamic from 'next/dynamic'
import { useGlobalPublicStore } from '@/context/global-public-context'

// Avoid rendering the logo and theme selector on the server
const DifyLogo = dynamic(() => import('@/app/components/base/logo/dify-logo'), {
@@ -20,10 +21,17 @@ const ThemeSelector = dynamic(() => import('@/app/components/base/theme-selector

const Header = () => {
const { locale, setLocaleOnClient } = useContext(I18n)
const systemFeatures = useGlobalPublicStore(s => s.systemFeatures)

return (
<div className='flex w-full items-center justify-between p-6'>
<DifyLogo size='large' />
{systemFeatures.branding.enabled && systemFeatures.branding.login_page_logo
? <img
src={systemFeatures.branding.login_page_logo}
className='block h-7 w-auto object-contain'
alt='logo'
/>
: <DifyLogo size='large' />}
<div className='flex items-center gap-1'>
<Select
value={locale}

+ 2
- 2
web/config/index.ts Datei anzeigen

@@ -173,7 +173,7 @@ export const MAX_TOOLS_NUM = maxToolsNum

export const DEFAULT_AGENT_SETTING = {
enabled: false,
max_iteration: 5,
max_iteration: 10,
strategy: AgentStrategy.functionCall,
tools: [],
}
@@ -295,7 +295,7 @@ else if (globalThis.document?.body?.getAttribute('data-public-loop-node-max-coun

export const LOOP_NODE_MAX_COUNT = loopNodeMaxCount

let maxIterationsNum = 5
let maxIterationsNum = 99

if (process.env.NEXT_PUBLIC_MAX_ITERATIONS_NUM && process.env.NEXT_PUBLIC_MAX_ITERATIONS_NUM !== '')
maxIterationsNum = Number.parseInt(process.env.NEXT_PUBLIC_MAX_ITERATIONS_NUM)

+ 0
- 1
web/i18n/de-DE/tools.ts Datei anzeigen

@@ -14,7 +14,6 @@ const translation = {
},
author: 'Von',
auth: {
unauthorized: 'Zur Autorisierung',
authorized: 'Autorisiert',
setup: 'Autorisierung einrichten, um zu nutzen',
setupModalTitle: 'Autorisierung einrichten',

+ 0
- 0
web/i18n/de-DE/workflow.ts Datei anzeigen


Einige Dateien werden nicht angezeigt, da zu viele Dateien in diesem Diff geändert wurden.

Laden…
Abbrechen
Speichern