Browse Source

Merge branch 'main' into feat/r2

tags/2.0.0-beta.1
jyong 5 months ago
parent
commit
a025db137d
100 changed files with 3290 additions and 2853 deletions
  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 View File

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-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-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="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 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 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 View File

echo "FILES_CHANGED=false" >> $GITHUB_ENV echo "FILES_CHANGED=false" >> $GITHUB_ENV
fi fi


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

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


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

+ 1
- 1
README.md View File



## Community & contact ## 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). - [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. - [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. - [X(Twitter)](https://twitter.com/dify_ai). Best for: sharing your applications and hanging out with the community.

+ 1
- 1
README_AR.md View File

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

+ 1
- 1
README_BN.md View File



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


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

+ 1
- 1
README_CN.md View File



我们欢迎您为 Dify 做出贡献,以帮助改善 Dify。包括:提交代码、问题、新想法,或分享您基于 Dify 创建的有趣且有用的 AI 应用程序。同时,我们也欢迎您在不同的活动、会议和社交媒体上分享 Dify。 我们欢迎您为 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)。 - [GitHub Issues](https://github.com/langgenius/dify/issues)。👉:使用 Dify.AI 时遇到的错误和问题,请参阅[贡献指南](CONTRIBUTING.md)。
- [电子邮件支持](mailto:hello@dify.ai?subject=[GitHub]Questions%20About%20Dify)。👉:关于使用 Dify.AI 的问题。 - [电子邮件支持](mailto:hello@dify.ai?subject=[GitHub]Questions%20About%20Dify)。👉:关于使用 Dify.AI 的问题。
- [Discord](https://discord.gg/FngNHpbcY7)。👉:分享您的应用程序并与社区交流。 - [Discord](https://discord.gg/FngNHpbcY7)。👉:分享您的应用程序并与社区交流。

+ 1
- 1
README_DE.md View File



## Gemeinschaft & Kontakt ## 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). * [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. * [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. * [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 View File



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


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

+ 1
- 1
README_KL.md View File



## Community & Contact ## 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. ). 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). * [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 View File



## 커뮤니티 & 연락처 ## 커뮤니티 & 연락처


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

+ 1
- 1
README_SI.md View File



## Skupnost in stik ## 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). * [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. * [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. * [X(Twitter)](https://twitter.com/dify_ai). Najboljše za: deljenje vaših aplikacij in druženje s skupnostjo.

+ 1
- 1
README_TR.md View File



## Topluluk & iletişim ## 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. * [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. * [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. * [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 View File



## 社群與聯絡方式 ## 社群與聯絡方式


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

+ 1
- 0
api/.env.example View File

QDRANT_CLIENT_TIMEOUT=20 QDRANT_CLIENT_TIMEOUT=20
QDRANT_GRPC_ENABLED=false QDRANT_GRPC_ENABLED=false
QDRANT_GRPC_PORT=6334 QDRANT_GRPC_PORT=6334
QDRANT_REPLICATION_FACTOR=1


#Couchbase configuration #Couchbase configuration
COUCHBASE_CONNECTION_STRING=127.0.0.1 COUCHBASE_CONNECTION_STRING=127.0.0.1

+ 5
- 0
api/configs/middleware/vdb/qdrant_config.py View File

description="Port number for gRPC connection to Qdrant server (default is 6334)", description="Port number for gRPC connection to Qdrant server (default is 6334)",
default=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 View File



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


COMMIT_SHA: str = Field( COMMIT_SHA: str = Field(

+ 0
- 4
api/contexts/__init__.py View File

from core.workflow.entities.variable_pool import VariablePool 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 To avoid race-conditions caused by gunicorn thread recycling, using RecyclableContextVar to replace with
""" """

+ 6
- 2
api/controllers/console/workspace/plugin.py View File

@account_initialization_required @account_initialization_required
def get(self): def get(self):
tenant_id = current_user.current_tenant_id 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: try:
plugins = PluginService.list(tenant_id)
plugins_with_total = PluginService.list_with_total(tenant_id, args["page"], args["page_size"])
except PluginDaemonClientSideError as e: except PluginDaemonClientSideError as e:
raise ValueError(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): class PluginListLatestVersionsApi(Resource):

+ 9
- 2
api/controllers/inner_api/plugin/wraps.py View File

from functools import wraps from functools import wraps
from typing import Optional 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 flask_restful import reqparse
from pydantic import BaseModel from pydantic import BaseModel
from sqlalchemy.orm import Session from sqlalchemy.orm import Session


from extensions.ext_database import db from extensions.ext_database import db
from libs.login import _get_user
from models.account import Account, Tenant from models.account import Account, Tenant
from models.model import EndUser from models.model import EndUser
from services.account_service import AccountService from services.account_service import AccountService
raise ValueError("tenant not found") raise ValueError("tenant not found")


kwargs["tenant_model"] = tenant_model 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) return view_func(*args, **kwargs)



+ 7
- 7
api/controllers/service_api/app/annotation.py View File

from werkzeug.exceptions import Forbidden from werkzeug.exceptions import Forbidden


from controllers.service_api import api 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 extensions.ext_redis import redis_client
from fields.annotation_fields import ( from fields.annotation_fields import (
annotation_fields, annotation_fields,




class AnnotationReplyActionApi(Resource): 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): def post(self, app_model: App, end_user: EndUser, action):
parser = reqparse.RequestParser() parser = reqparse.RequestParser()
parser.add_argument("score_threshold", required=True, type=float, location="json") parser.add_argument("score_threshold", required=True, type=float, location="json")




class AnnotationReplyActionStatusApi(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): def get(self, app_model: App, end_user: EndUser, job_id, action):
job_id = str(job_id) job_id = str(job_id)
app_annotation_job_key = "{}_app_annotation_job_{}".format(action, str(job_id)) app_annotation_job_key = "{}_app_annotation_job_{}".format(action, str(job_id))




class AnnotationListApi(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): def get(self, app_model: App, end_user: EndUser):
page = request.args.get("page", default=1, type=int) page = request.args.get("page", default=1, type=int)
limit = request.args.get("limit", default=20, type=int) limit = request.args.get("limit", default=20, type=int)
} }
return response, 200 return response, 200


@validate_app_token(fetch_user_arg=FetchUserArg(fetch_from=WhereisUserArg.JSON))
@validate_app_token
@marshal_with(annotation_fields) @marshal_with(annotation_fields)
def post(self, app_model: App, end_user: EndUser): def post(self, app_model: App, end_user: EndUser):
parser = reqparse.RequestParser() parser = reqparse.RequestParser()




class AnnotationUpdateDeleteApi(Resource): class AnnotationUpdateDeleteApi(Resource):
@validate_app_token(fetch_user_arg=FetchUserArg(fetch_from=WhereisUserArg.JSON))
@validate_app_token
@marshal_with(annotation_fields) @marshal_with(annotation_fields)
def put(self, app_model: App, end_user: EndUser, annotation_id): def put(self, app_model: App, end_user: EndUser, annotation_id):
if not current_user.is_editor: if not current_user.is_editor:
annotation = AppAnnotationService.update_app_annotation_directly(args, app_model.id, annotation_id) annotation = AppAnnotationService.update_app_annotation_directly(args, app_model.id, annotation_id)
return annotation 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): def delete(self, app_model: App, end_user: EndUser, annotation_id):
if not current_user.is_editor: if not current_user.is_editor:
raise Forbidden() raise Forbidden()

+ 6
- 1
api/controllers/service_api/wraps.py View File

if user_id: if user_id:
user_id = str(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) return view_func(*args, **kwargs)



+ 1
- 1
api/core/agent/cot_agent_runner.py View File

self._instruction = self._fill_in_inputs_from_external_data_tools(instruction, inputs) self._instruction = self._fill_in_inputs_from_external_data_tools(instruction, inputs)


iteration_step = 1 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 # convert tools into ModelRuntime Tool format
tool_instances, prompt_messages_tools = self._init_prompt_tools() tool_instances, prompt_messages_tools = self._init_prompt_tools()

+ 1
- 1
api/core/agent/entities.py View File

strategy: Strategy strategy: Strategy
prompt: Optional[AgentPromptEntity] = None prompt: Optional[AgentPromptEntity] = None
tools: Optional[list[AgentToolEntity]] = None tools: Optional[list[AgentToolEntity]] = None
max_iteration: int = 5
max_iteration: int = 10




class AgentInvokeMessage(ToolInvokeMessage): class AgentInvokeMessage(ToolInvokeMessage):

+ 1
- 1
api/core/agent/fc_agent_runner.py View File

assert app_config.agent assert app_config.agent


iteration_step = 1 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 # continue to run until there is not any tool call
function_call_state = True function_call_state = True

+ 1
- 1
api/core/app/app_config/easy_ui_based_app/agent/manager.py View File

strategy=strategy, strategy=strategy,
prompt=agent_prompt_entity, prompt=agent_prompt_entity,
tools=agent_tools, tools=agent_tools,
max_iteration=agent_dict.get("max_iteration", 5),
max_iteration=agent_dict.get("max_iteration", 10),
) )


return None return None

+ 32
- 16
api/core/app/apps/advanced_chat/app_generator.py View File

from collections.abc import Generator, Mapping from collections.abc import Generator, Mapping
from typing import Any, Literal, Optional, Union, overload 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 pydantic import ValidationError
from sqlalchemy.orm import sessionmaker from sqlalchemy.orm import sessionmaker


trace_manager=trace_manager, trace_manager=trace_manager,
workflow_run_id=workflow_run_id, 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.set({})
contexts.plugin_tool_providers_lock.set(threading.Lock()) contexts.plugin_tool_providers_lock.set(threading.Lock())


node_id=node_id, inputs=args["inputs"] 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.set({})
contexts.plugin_tool_providers_lock.set(threading.Lock()) contexts.plugin_tool_providers_lock.set(threading.Lock())


extras={"auto_generate_conversation_name": False}, extras={"auto_generate_conversation_name": False},
single_loop_run=AdvancedChatAppGenerateEntity.SingleLoopRunEntity(node_id=node_id, inputs=args["inputs"]), 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.set({})
contexts.plugin_tool_providers_lock.set(threading.Lock()) contexts.plugin_tool_providers_lock.set(threading.Lock())


message_id=message.id, 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() worker_thread.start()


""" """
for var, val in context.items(): for var, val in context.items():
var.set(val) 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(): with flask_app.app_context():
try: 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 # get conversation and message
conversation = self._get_conversation(conversation_id) conversation = self._get_conversation(conversation_id)
message = self._get_message(message_id) message = self._get_message(message_id)

+ 1
- 0
api/core/app/apps/advanced_chat/generate_task_pipeline.py View File

task_id=self._application_generate_entity.task_id, task_id=self._application_generate_entity.task_id,
workflow_execution=workflow_execution, workflow_execution=workflow_execution,
) )
session.commit()


yield workflow_start_resp yield workflow_start_resp
elif isinstance( elif isinstance(

+ 31
- 13
api/core/app/apps/agent_chat/app_generator.py View File

from collections.abc import Generator, Mapping from collections.abc import Generator, Mapping
from typing import Any, Literal, Union, overload 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 pydantic import ValidationError


from configs import dify_config from configs import dify_config
message_id=message.id, 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() worker_thread.start()


for var, val in context.items(): for var, val in context.items():
var.set(val) 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(): with flask_app.app_context():
try: 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 # get conversation and message
conversation = self._get_conversation(conversation_id) conversation = self._get_conversation(conversation_id)
message = self._get_message(message_id) message = self._get_message(message_id)

+ 13
- 12
api/core/app/apps/chat/app_generator.py View File

from collections.abc import Generator, Mapping from collections.abc import Generator, Mapping
from typing import Any, Literal, Union, overload 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 pydantic import ValidationError


from configs import dify_config from configs import dify_config
message_id=message.id, 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() worker_thread.start()



+ 23
- 21
api/core/app/apps/completion/app_generator.py View File

from collections.abc import Generator, Mapping from collections.abc import Generator, Mapping
from typing import Any, Literal, Union, overload 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 pydantic import ValidationError


from configs import dify_config from configs import dify_config
message_id=message.id, 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() worker_thread.start()


message_id=message.id, 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() worker_thread.start()



+ 31
- 15
api/core/app/apps/workflow/app_generator.py View File

from collections.abc import Generator, Mapping, Sequence from collections.abc import Generator, Mapping, Sequence
from typing import Any, Literal, Optional, Union, overload 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 pydantic import ValidationError
from sqlalchemy.orm import sessionmaker from sqlalchemy.orm import sessionmaker


workflow_run_id=workflow_run_id, 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.set({})
contexts.plugin_tool_providers_lock.set(threading.Lock()) contexts.plugin_tool_providers_lock.set(threading.Lock())


app_mode=app_model.mode, 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() worker_thread.start()


), ),
workflow_run_id=str(uuid.uuid4()), 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.set({})
contexts.plugin_tool_providers_lock.set(threading.Lock()) contexts.plugin_tool_providers_lock.set(threading.Lock())


single_loop_run=WorkflowAppGenerateEntity.SingleLoopRunEntity(node_id=node_id, inputs=args["inputs"]), single_loop_run=WorkflowAppGenerateEntity.SingleLoopRunEntity(node_id=node_id, inputs=args["inputs"]),
workflow_run_id=str(uuid.uuid4()), 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.set({})
contexts.plugin_tool_providers_lock.set(threading.Lock()) contexts.plugin_tool_providers_lock.set(threading.Lock())


""" """
for var, val in context.items(): for var, val in context.items():
var.set(val) 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(): with flask_app.app_context():
try: try:
# Restore user in new app context
if saved_user is not None:
from flask import g

g._login_user = saved_user

# workflow app # workflow app
runner = WorkflowAppRunner( runner = WorkflowAppRunner(
application_generate_entity=application_generate_entity, application_generate_entity=application_generate_entity,

+ 0
- 2
api/core/app/task_pipeline/easy_ui_based_generate_task_pipeline.py View File

agent_thought: Optional[MessageAgentThought] = ( agent_thought: Optional[MessageAgentThought] = (
db.session.query(MessageAgentThought).filter(MessageAgentThought.id == event.agent_thought_id).first() db.session.query(MessageAgentThought).filter(MessageAgentThought.id == event.agent_thought_id).first()
) )
db.session.refresh(agent_thought)
db.session.close()


if agent_thought: if agent_thought:
return AgentThoughtStreamResponse( return AgentThoughtStreamResponse(

+ 7
- 3
api/core/llm_generator/llm_generator.py View File

response = cast( response = cast(
LLMResult, LLMResult,
model_instance.invoke_llm( 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) answer = cast(str, response.message.content)
cleaned_answer = re.sub(r"^.*(\{.*\}).*$", r"\1", answer, flags=re.DOTALL) cleaned_answer = re.sub(r"^.*(\{.*\}).*$", r"\1", answer, flags=re.DOTALL)
if cleaned_answer is None: if cleaned_answer is None:
return "" 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() name = answer.strip()


if len(name) > 75: if len(name) > 75:

+ 2
- 1
api/core/plugin/backwards_invocation/model.py View File

LLMNode.deduct_llm_quota( LLMNode.deduct_llm_quota(
tenant_id=tenant.id, model_instance=model_instance, usage=chunk.delta.usage tenant_id=tenant.id, model_instance=model_instance, usage=chunk.delta.usage
) )
chunk.prompt_messages = []
yield chunk yield chunk


return handle() return handle()
def handle_non_streaming(response: LLMResult) -> Generator[LLMResultChunk, None, None]: def handle_non_streaming(response: LLMResult) -> Generator[LLMResultChunk, None, None]:
yield LLMResultChunk( yield LLMResultChunk(
model=response.model, model=response.model,
prompt_messages=response.prompt_messages,
prompt_messages=[],
system_fingerprint=response.system_fingerprint, system_fingerprint=response.system_fingerprint,
delta=LLMResultChunkDelta( delta=LLMResultChunkDelta(
index=0, index=0,

+ 6
- 1
api/core/plugin/entities/plugin_daemon.py View File

from core.model_runtime.entities.model_entities import AIModelEntity from core.model_runtime.entities.model_entities import AIModelEntity
from core.model_runtime.entities.provider_entities import ProviderEntity from core.model_runtime.entities.provider_entities import ProviderEntity
from core.plugin.entities.base import BasePluginEntity 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.common_entities import I18nObject
from core.tools.entities.tool_entities import ToolProviderEntityWithPlugin from core.tools.entities.tool_entities import ToolProviderEntityWithPlugin




class PluginOAuthCredentialsResponse(BaseModel): class PluginOAuthCredentialsResponse(BaseModel):
credentials: Mapping[str, Any] = Field(description="The credentials of the OAuth.") 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 View File

PluginInstallation, PluginInstallation,
PluginInstallationSource, 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 from core.plugin.impl.base import BasePluginClient




) )


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

+ 4
- 0
api/core/rag/datasource/vdb/qdrant/qdrant_vector.py View File

root_path: Optional[str] = None root_path: Optional[str] = None
grpc_port: int = 6334 grpc_port: int = 6334
prefer_grpc: bool = False prefer_grpc: bool = False
replication_factor: int = 1


def to_qdrant_params(self): def to_qdrant_params(self):
if self.endpoint and self.endpoint.startswith("path:"): if self.endpoint and self.endpoint.startswith("path:"):
max_indexing_threads=0, max_indexing_threads=0,
on_disk=False, on_disk=False,
) )

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


# create group_id payload index # create group_id payload index
timeout=dify_config.QDRANT_CLIENT_TIMEOUT, timeout=dify_config.QDRANT_CLIENT_TIMEOUT,
grpc_port=dify_config.QDRANT_GRPC_PORT, grpc_port=dify_config.QDRANT_GRPC_PORT,
prefer_grpc=dify_config.QDRANT_GRPC_ENABLED, 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 View File

root_path: Optional[str] = None root_path: Optional[str] = None
grpc_port: int = 6334 grpc_port: int = 6334
prefer_grpc: bool = False prefer_grpc: bool = False
replication_factor: int = 1


def to_qdrant_params(self): def to_qdrant_params(self):
if self.endpoint and self.endpoint.startswith("path:"): if self.endpoint and self.endpoint.startswith("path:"):
vectors_config=vectors_config, vectors_config=vectors_config,
hnsw_config=hnsw_config, hnsw_config=hnsw_config,
timeout=int(self._client_config.timeout), timeout=int(self._client_config.timeout),
replication_factor=self._client_config.replication_factor,
) )


# create group_id payload index # create group_id payload index
timeout=dify_config.TIDB_ON_QDRANT_CLIENT_TIMEOUT, timeout=dify_config.TIDB_ON_QDRANT_CLIENT_TIMEOUT,
grpc_port=dify_config.TIDB_ON_QDRANT_GRPC_PORT, grpc_port=dify_config.TIDB_ON_QDRANT_GRPC_PORT,
prefer_grpc=dify_config.TIDB_ON_QDRANT_GRPC_ENABLED, 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 View File

import hashlib
import json
import mimetypes import mimetypes
import os
import re 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 from urllib.parse import unquote


import chardet import chardet
import cloudscraper # type: ignore 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.helper import ssrf_proxy
from core.rag.extractor import extract_processor from core.rag.extractor import extract_processor


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


{text} {text}
response = ssrf_proxy.get(url, headers=headers, follow_redirects=True, timeout=(120, 300)) response = ssrf_proxy.get(url, headers=headers, follow_redirects=True, timeout=(120, 300))
elif response.status_code == 403: elif response.status_code == 403:
scraper = cloudscraper.create_scraper() 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: if response.status_code != 200:
return "URL returned status code {}.".format(response.status_code) return "URL returned status code {}.".format(response.status_code)
else: else:
content = response.text 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 "" return ""


res = FULL_TEMPLATE.format( 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 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): def get_image_upload_file_ids(content):

+ 14
- 1
api/core/workflow/graph_engine/graph_engine.py View File

from datetime import UTC, datetime from datetime import UTC, datetime
from typing import Any, Optional, cast 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 configs import dify_config
from core.app.apps.base_app_queue_manager import GenerateTaskStoppedError from core.app.apps.base_app_queue_manager import GenerateTaskStoppedError
for var, val in context.items(): for var, val in context.items():
var.set(val) 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(): with flask_app.app_context():
try: try:
# Restore user in new app context
if saved_user is not None:
from flask import g

g._login_user = saved_user

q.put( q.put(
ParallelBranchRunStartedEvent( ParallelBranchRunStartedEvent(
parallel_id=parallel_id, parallel_id=parallel_id,

+ 4
- 2
api/core/workflow/nodes/agent/agent_node.py View File



def _remove_unsupported_model_features_for_old_version(self, model_schema: AIModelEntity) -> AIModelEntity: def _remove_unsupported_model_features_for_old_version(self, model_schema: AIModelEntity) -> AIModelEntity:
if model_schema.features: 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) model_schema.features.remove(feature)
return model_schema return model_schema

+ 2
- 2
api/core/workflow/nodes/agent/entities.py View File

from enum import Enum
from enum import Enum, StrEnum
from typing import Any, Literal, Union from typing import Any, Literal, Union


from pydantic import BaseModel from pydantic import BaseModel
OPEN = 1 OPEN = 1




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

+ 63
- 11
api/core/workflow/nodes/document_extractor/node.py View File

from collections.abc import Mapping, Sequence from collections.abc import Mapping, Sequence
from typing import Any, cast from typing import Any, cast


import chardet
import docx import docx
import pandas as pd import pandas as pd
import pypandoc # type: ignore import pypandoc # type: ignore


def _extract_text_from_plain_text(file_content: bytes) -> str: def _extract_text_from_plain_text(file_content: bytes) -> str:
try: 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: def _extract_text_from_json(file_content: bytes) -> str:
try: 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) 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: def _extract_text_from_yaml(file_content: bytes) -> str:
"""Extract the content from yaml file""" """Extract the content from yaml file"""
try: 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)) 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: def _extract_text_from_pdf(file_content: bytes) -> str:


def _extract_text_from_csv(file_content: bytes) -> str: def _extract_text_from_csv(file_content: bytes) -> str:
try: 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) csv_reader = csv.reader(csv_file)
rows = list(csv_reader) rows = list(csv_reader)


df = excel_file.parse(sheet_name=sheet_name) df = excel_file.parse(sheet_name=sheet_name)
df.dropna(how="all", inplace=True) df.dropna(how="all", inplace=True)
# Create Markdown table two times to separate tables with a newline # 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: except Exception as e:
continue continue
return markdown_table return markdown_table

+ 8
- 1
api/core/workflow/nodes/http_request/executor.py View File

files[key].append(file_tuple) files[key].append(file_tuple)


# convert files to list for httpx request # 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: if files:
self.files = [] self.files = []
for key, file_tuples in files.items(): for key, file_tuples in files.items():
raw += f"{k}: {v}\r\n" raw += f"{k}: {v}\r\n"


body_string = "" 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: for key, (filename, content, mime_type) in self.files:
body_string += f"--{boundary}\r\n" body_string += f"--{boundary}\r\n"
body_string += f'Content-Disposition: form-data; name="{key}"\r\n\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 View File

from queue import Empty, Queue from queue import Empty, Queue
from typing import TYPE_CHECKING, Any, Optional, cast 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 configs import dify_config
from core.variables import ArrayVariable, IntegerVariable, NoneVariable from core.variables import ArrayVariable, IntegerVariable, NoneVariable
""" """
for var, val in context.items(): for var, val in context.items():
var.set(val) 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(): 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 parallel_mode_run_id = uuid.uuid4().hex
graph_engine_copy = graph_engine.create_copy() graph_engine_copy = graph_engine.create_copy()
variable_pool_copy = graph_engine_copy.graph_runtime_state.variable_pool variable_pool_copy = graph_engine_copy.graph_runtime_state.variable_pool

+ 21
- 18
api/core/workflow/workflow_cycle_manager.py View File

) )
) )


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


def handle_workflow_run_partial_success( def handle_workflow_run_partial_success(
) )
) )


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


def handle_workflow_run_failed( def handle_workflow_run_failed(
trace_manager: Optional[TraceQueueManager] = None, trace_manager: Optional[TraceQueueManager] = None,
exceptions_count: int = 0, exceptions_count: int = 0,
) -> WorkflowExecution: ) -> 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 # 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 # Update the domain models
now = datetime.now(UTC).replace(tzinfo=None) 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 # 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 # 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: if trace_manager:
trace_manager.add_trace_task( trace_manager.add_trace_task(
TraceTask( TraceTask(
TraceTaskName.WORKFLOW_TRACE, TraceTaskName.WORKFLOW_TRACE,
workflow_execution=execution,
workflow_execution=workflow_execution,
conversation_id=conversation_id, conversation_id=conversation_id,
user_id=trace_manager.user_id, user_id=trace_manager.user_id,
) )
) )


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


def handle_node_execution_start( def handle_node_execution_start(
self, self,

+ 24
- 4
api/extensions/ext_login.py View File

from flask_login import user_loaded_from_request, user_logged_in from flask_login import user_loaded_from_request, user_logged_in
from werkzeug.exceptions import NotFound, Unauthorized from werkzeug.exceptions import NotFound, Unauthorized


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


else: else:
auth_token = request.args.get("_token") 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 request.blueprint in {"console", "inner_api"}:
if not auth_token: if not auth_token:
raise Unauthorized("Invalid Authorization token.") raise Unauthorized("Invalid Authorization token.")
Note: AccountService.load_logged_in_account will populate user.current_tenant_id Note: AccountService.load_logged_in_account will populate user.current_tenant_id
through the load_user method, which calls account.set_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 @login_manager.unauthorized_handler

+ 42
- 24
api/extensions/ext_otel.py View File



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




@user_logged_in.connect @user_logged_in.connect
@user_loaded_from_request.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: if dify_config.ENABLE_OTEL:
from opentelemetry.trace import get_current_span from opentelemetry.trace import get_current_span


if user: 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): def init_app(app: DifyApp):


def response_hook(span: Span, status: str, response_headers: list): def response_hook(span: Span, status: str, response_headers: list):
if span and span.is_recording(): 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() instrumentor = FlaskInstrumentor()
if dify_config.DEBUG: if dify_config.DEBUG:
class ExceptionLoggingHandler(logging.Handler): class ExceptionLoggingHandler(logging.Handler):
"""Custom logging handler that creates spans for logging.exception() calls""" """Custom logging handler that creates spans for logging.exception() calls"""


def emit(self, record):
def emit(self, record: logging.LogRecord):
try: try:
if record.exc_info: if record.exc_info:
tracer = get_tracer_provider().get_tracer("dify.exception.logging") tracer = get_tracer_provider().get_tracer("dify.exception.logging")
}, },
) as span: ) as span:
span.set_status(StatusCode.ERROR) 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: except Exception:
pass pass



+ 2
- 0
api/fields/app_fields.py View File

"updated_at": TimestampField, "updated_at": TimestampField,
"tags": fields.List(fields.Nested(tag_fields)), "tags": fields.List(fields.Nested(tag_fields)),
"access_mode": fields.String, "access_mode": fields.String,
"create_user_name": fields.String,
"author_name": fields.String,
} }





+ 1
- 34
api/libs/login.py View File

from typing import Any from typing import Any


from flask import current_app, g, has_request_context, request 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 flask_login.config import EXEMPT_METHODS # type: ignore
from werkzeug.exceptions import Unauthorized
from werkzeug.local import LocalProxy from werkzeug.local import LocalProxy


from configs import dify_config 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 from models.model import EndUser


#: A proxy for the current user. If no user is logged in, this will be an #: A proxy for the current user. If no user is logged in, this will be an


@wraps(func) @wraps(func)
def decorated_view(*args, **kwargs): 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: if request.method in EXEMPT_METHODS or dify_config.LOGIN_DISABLED:
pass pass
elif not current_user.is_authenticated: elif not current_user.is_authenticated:

+ 9
- 0
api/models/model.py View File



return tags or [] 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): class AppModelConfig(Base):
__tablename__ = "app_model_configs" __tablename__ = "app_model_configs"

+ 23
- 3
api/models/workflow.py View File

from typing import TYPE_CHECKING, Any, Optional, Union from typing import TYPE_CHECKING, Any, Optional, Union
from uuid import uuid4 from uuid import uuid4


from flask_login import current_user

from core.variables import utils as variable_utils from core.variables import utils as variable_utils
from core.workflow.constants import CONVERSATION_VARIABLE_NODE_ID, SYSTEM_VARIABLE_NODE_ID from core.workflow.constants import CONVERSATION_VARIABLE_NODE_ID, SYSTEM_VARIABLE_NODE_ID
from factories.variable_factory import build_segment from factories.variable_factory import build_segment
from sqlalchemy import UniqueConstraint, func from sqlalchemy import UniqueConstraint, func
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column


import contexts
from constants import DEFAULT_FILE_NUMBER_LIMITS, HIDDEN_VALUE from constants import DEFAULT_FILE_NUMBER_LIMITS, HIDDEN_VALUE
from core.helper import encrypter from core.helper import encrypter
from core.variables import SecretVariable, Segment, SegmentType, Variable from core.variables import SecretVariable, Segment, SegmentType, Variable
if self._environment_variables is None: if self._environment_variables is None:
self._environment_variables = "{}" 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) environment_variables_dict: dict[str, Any] = json.loads(self._environment_variables)
results = [ results = [
self._environment_variables = "{}" self._environment_variables = "{}"
return 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) value = list(value)
if any(var for var in value if not var.id): if any(var for var in value if not var.id):

+ 2
- 1
api/pyproject.toml View File

"types-tqdm~=4.67.0", "types-tqdm~=4.67.0",
"types-ujson~=5.10.0", "types-ujson~=5.10.0",
"boto3-stubs>=1.38.20", "boto3-stubs>=1.38.20",
"types-jmespath>=1.0.2.20240106",
] ]


############################################################ ############################################################
"pymilvus~=2.5.0", "pymilvus~=2.5.0",
"pymochow==1.3.1", "pymochow==1.3.1",
"pyobvector~=0.1.6", "pyobvector~=0.1.6",
"qdrant-client==1.7.3",
"qdrant-client==1.9.0",
"tablestore==6.1.0", "tablestore==6.1.0",
"tcvectordb~=1.6.4", "tcvectordb~=1.6.4",
"tidb-vector==0.0.9", "tidb-vector==0.0.9",

+ 9
- 11
api/services/dataset_service.py View File

if dataset.permission == DatasetPermissionEnum.ONLY_ME and dataset.created_by != user.id: 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}") 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.") 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 @staticmethod
def check_dataset_operator_permission(user: Optional[Account] = None, dataset: Optional[Dataset] = None): def check_dataset_operator_permission(user: Optional[Account] = None, dataset: Optional[Dataset] = None):

+ 10
- 1
api/services/plugin/plugin_service.py View File

PluginInstallation, PluginInstallation,
PluginInstallationSource, 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.asset import PluginAssetManager
from core.plugin.impl.debugging import PluginDebuggingClient from core.plugin.impl.debugging import PluginDebuggingClient
from core.plugin.impl.plugin import PluginInstaller from core.plugin.impl.plugin import PluginInstaller
plugins = manager.list_plugins(tenant_id) plugins = manager.list_plugins(tenant_id)
return plugins 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 @staticmethod
def list_installations_from_ids(tenant_id: str, ids: Sequence[str]) -> Sequence[PluginInstallation]: def list_installations_from_ids(tenant_id: str, ids: Sequence[str]) -> Sequence[PluginInstallation]:
""" """

+ 11
- 28
api/tasks/remove_app_and_related_data_task.py View File



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


from core.repositories import SQLAlchemyWorkflowNodeExecutionRepository
from extensions.ext_database import db from extensions.ext_database import db
from models import ( from models import (
Account,
ApiToken, ApiToken,
App,
AppAnnotationHitHistory, AppAnnotationHitHistory,
AppAnnotationSetting, AppAnnotationSetting,
AppDatasetJoin, AppDatasetJoin,
) )
from models.tools import WorkflowToolProvider from models.tools import WorkflowToolProvider
from models.web import PinnedConversation, SavedMessage 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) @shared_task(queue="app_deletion", bind=True, max_retries=3)




def _delete_app_workflow_node_executions(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): 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 View File

assert "multipart/form-data" in executor.headers["Content-Type"] assert "multipart/form-data" in executor.headers["Content-Type"]
assert executor.params == [] assert executor.params == []
assert executor.json is None 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 assert executor.content is None


# Check that the form data is correctly loaded in executor.data # 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 View File

from core.workflow.nodes.document_extractor import DocumentExtractorNode, DocumentExtractorNodeData from core.workflow.nodes.document_extractor import DocumentExtractorNode, DocumentExtractorNodeData
from core.workflow.nodes.document_extractor.node import ( from core.workflow.nodes.document_extractor.node import (
_extract_text_from_docx, _extract_text_from_docx,
_extract_text_from_excel,
_extract_text_from_pdf, _extract_text_from_pdf,
_extract_text_from_plain_text, _extract_text_from_plain_text,
) )
temp_file.write(non_utf8_content) temp_file.write(non_utf8_content)
temp_file.seek(0) temp_file.seek(0)
text = _extract_text_from_plain_text(temp_file.read()) text = _extract_text_from_plain_text(temp_file.read())
assert text == "Hello, world."
assert text == "Hello, world©."




@patch("pypdfium2.PdfDocument") @patch("pypdfium2.PdfDocument")


def test_node_type(document_extractor_node): def test_node_type(document_extractor_node):
assert document_extractor_node._node_type == NodeType.DOCUMENT_EXTRACTOR 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 View File

from unittest import mock from unittest import mock
from uuid import uuid4 from uuid import uuid4


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




def test_environment_variables(): 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 # Create a Workflow instance
workflow = Workflow( workflow = Workflow(
{"name": "var4", "value": 3.14, "id": str(uuid4()), "selector": ["env", "var4"]} {"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 ( with (
mock.patch("core.helper.encrypter.encrypt_token", return_value="encrypted_token"), mock.patch("core.helper.encrypter.encrypt_token", return_value="encrypted_token"),
mock.patch("core.helper.encrypter.decrypt_token", return_value="secret"), 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 # Set the environment_variables property of the Workflow instance
variables = [variable1, variable2, variable3, variable4] variables = [variable1, variable2, variable3, variable4]




def test_update_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 # Create a Workflow instance
workflow = Workflow( workflow = Workflow(
{"name": "var4", "value": 3.14, "id": str(uuid4()), "selector": ["env", "var4"]} {"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 ( with (
mock.patch("core.helper.encrypter.encrypt_token", return_value="encrypted_token"), mock.patch("core.helper.encrypter.encrypt_token", return_value="encrypted_token"),
mock.patch("core.helper.encrypter.decrypt_token", return_value="secret"), mock.patch("core.helper.encrypter.decrypt_token", return_value="secret"),
mock.patch("models.workflow.current_user", mock_user),
): ):
variables = [variable1, variable2, variable3, variable4] variables = [variable1, variable2, variable3, variable4]






def test_to_dict(): 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 # Create a Workflow instance
workflow = Workflow( workflow = Workflow(


# Create some EnvironmentVariable instances # Create some EnvironmentVariable instances


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

with ( with (
mock.patch("core.helper.encrypter.encrypt_token", return_value="encrypted_token"), mock.patch("core.helper.encrypter.encrypt_token", return_value="encrypted_token"),
mock.patch("core.helper.encrypter.decrypt_token", return_value="secret"), 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 # Set the environment_variables property of the Workflow instance
workflow.environment_variables = [ workflow.environment_variables = [

+ 158
- 0
api/tests/unit_tests/services/test_dataset_permission.py View File

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
File diff suppressed because it is too large
View File


+ 2
- 1
docker/.env.example View File

QDRANT_CLIENT_TIMEOUT=20 QDRANT_CLIENT_TIMEOUT=20
QDRANT_GRPC_ENABLED=false QDRANT_GRPC_ENABLED=false
QDRANT_GRPC_PORT=6334 QDRANT_GRPC_PORT=6334
QDRANT_REPLICATION_FACTOR=1


# Milvus configuration. Only available when VECTOR_STORE is `milvus`. # Milvus configuration. Only available when VECTOR_STORE is `milvus`.
# The milvus uri. # The milvus uri.
MAX_PARALLEL_LIMIT=10 MAX_PARALLEL_LIMIT=10


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


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

+ 5
- 5
docker/docker-compose-template.yaml View File

services: services:
# API service # API service
api: api:
image: langgenius/dify-api:1.4.0
image: langgenius/dify-api:1.4.1
restart: always restart: always
environment: environment:
# Use the shared environment variables. # Use the shared environment variables.
# worker service # worker service
# The Celery worker for processing the queue. # The Celery worker for processing the queue.
worker: worker:
image: langgenius/dify-api:1.4.0
image: langgenius/dify-api:1.4.1
restart: always restart: always
environment: environment:
# Use the shared environment variables. # Use the shared environment variables.


# Frontend web application. # Frontend web application.
web: web:
image: langgenius/dify-web:1.4.0
image: langgenius/dify-web:1.4.1
restart: always restart: always
environment: environment:
CONSOLE_API_URL: ${CONSOLE_API_URL:-} CONSOLE_API_URL: ${CONSOLE_API_URL:-}
LOOP_NODE_MAX_COUNT: ${LOOP_NODE_MAX_COUNT:-100} LOOP_NODE_MAX_COUNT: ${LOOP_NODE_MAX_COUNT:-100}
MAX_TOOLS_NUM: ${MAX_TOOLS_NUM:-10} MAX_TOOLS_NUM: ${MAX_TOOLS_NUM:-10}
MAX_PARALLEL_LIMIT: ${MAX_PARALLEL_LIMIT:-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_JINAREADER: ${ENABLE_WEBSITE_JINAREADER:-true}
ENABLE_WEBSITE_FIRECRAWL: ${ENABLE_WEBSITE_FIRECRAWL:-true} ENABLE_WEBSITE_FIRECRAWL: ${ENABLE_WEBSITE_FIRECRAWL:-true}
ENABLE_WEBSITE_WATERCRAWL: ${ENABLE_WEBSITE_WATERCRAWL:-true} ENABLE_WEBSITE_WATERCRAWL: ${ENABLE_WEBSITE_WATERCRAWL:-true}


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

+ 1
- 1
docker/docker-compose.middleware.yaml View File



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

+ 7
- 6
docker/docker-compose.yaml View File

QDRANT_CLIENT_TIMEOUT: ${QDRANT_CLIENT_TIMEOUT:-20} QDRANT_CLIENT_TIMEOUT: ${QDRANT_CLIENT_TIMEOUT:-20}
QDRANT_GRPC_ENABLED: ${QDRANT_GRPC_ENABLED:-false} QDRANT_GRPC_ENABLED: ${QDRANT_GRPC_ENABLED:-false}
QDRANT_GRPC_PORT: ${QDRANT_GRPC_PORT:-6334} QDRANT_GRPC_PORT: ${QDRANT_GRPC_PORT:-6334}
QDRANT_REPLICATION_FACTOR: ${QDRANT_REPLICATION_FACTOR:-1}
MILVUS_URI: ${MILVUS_URI:-http://host.docker.internal:19530} MILVUS_URI: ${MILVUS_URI:-http://host.docker.internal:19530}
MILVUS_DATABASE: ${MILVUS_DATABASE:-} MILVUS_DATABASE: ${MILVUS_DATABASE:-}
MILVUS_TOKEN: ${MILVUS_TOKEN:-} MILVUS_TOKEN: ${MILVUS_TOKEN:-}
LOOP_NODE_MAX_COUNT: ${LOOP_NODE_MAX_COUNT:-100} LOOP_NODE_MAX_COUNT: ${LOOP_NODE_MAX_COUNT:-100}
MAX_TOOLS_NUM: ${MAX_TOOLS_NUM:-10} MAX_TOOLS_NUM: ${MAX_TOOLS_NUM:-10}
MAX_PARALLEL_LIMIT: ${MAX_PARALLEL_LIMIT:-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} TEXT_GENERATION_TIMEOUT_MS: ${TEXT_GENERATION_TIMEOUT_MS:-60000}
PGUSER: ${PGUSER:-${DB_USERNAME}} PGUSER: ${PGUSER:-${DB_USERNAME}}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-${DB_PASSWORD}} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-${DB_PASSWORD}}
services: services:
# API service # API service
api: api:
image: langgenius/dify-api:1.4.0
image: langgenius/dify-api:1.4.1
restart: always restart: always
environment: environment:
# Use the shared environment variables. # Use the shared environment variables.
# worker service # worker service
# The Celery worker for processing the queue. # The Celery worker for processing the queue.
worker: worker:
image: langgenius/dify-api:1.4.0
image: langgenius/dify-api:1.4.1
restart: always restart: always
environment: environment:
# Use the shared environment variables. # Use the shared environment variables.


# Frontend web application. # Frontend web application.
web: web:
image: langgenius/dify-web:1.4.0
image: langgenius/dify-web:1.4.1
restart: always restart: always
environment: environment:
CONSOLE_API_URL: ${CONSOLE_API_URL:-} CONSOLE_API_URL: ${CONSOLE_API_URL:-}
LOOP_NODE_MAX_COUNT: ${LOOP_NODE_MAX_COUNT:-100} LOOP_NODE_MAX_COUNT: ${LOOP_NODE_MAX_COUNT:-100}
MAX_TOOLS_NUM: ${MAX_TOOLS_NUM:-10} MAX_TOOLS_NUM: ${MAX_TOOLS_NUM:-10}
MAX_PARALLEL_LIMIT: ${MAX_PARALLEL_LIMIT:-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_JINAREADER: ${ENABLE_WEBSITE_JINAREADER:-true}
ENABLE_WEBSITE_FIRECRAWL: ${ENABLE_WEBSITE_FIRECRAWL:-true} ENABLE_WEBSITE_FIRECRAWL: ${ENABLE_WEBSITE_FIRECRAWL:-true}
ENABLE_WEBSITE_WATERCRAWL: ${ENABLE_WEBSITE_WATERCRAWL:-true} ENABLE_WEBSITE_WATERCRAWL: ${ENABLE_WEBSITE_WATERCRAWL:-true}


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

BIN
images/GitHub_README_if.png View File


+ 1
- 1
web/.env.example View File

NEXT_PUBLIC_MAX_PARALLEL_LIMIT=10 NEXT_PUBLIC_MAX_PARALLEL_LIMIT=10


# The maximum number of iterations for agent setting # 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_JINAREADER=true
NEXT_PUBLIC_ENABLE_WEBSITE_FIRECRAWL=true NEXT_PUBLIC_ENABLE_WEBSITE_FIRECRAWL=true

+ 15
- 7
web/app/(commonLayout)/apps/AppCard.tsx View File



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


export type AppCardProps = { export type AppCardProps = {
app: App app: App
setTags(app.tags) setTags(app.tags)
}, [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 ( return (
<> <>
<div <div
<div className='flex items-center text-sm font-semibold leading-5 text-text-secondary'> <div className='flex items-center text-sm font-semibold leading-5 text-text-secondary'>
<div className='truncate' title={app.name}>{app.name}</div> <div className='truncate' title={app.name}>{app.name}</div>
</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> </div>
<div className='flex h-5 w-5 shrink-0 items-center justify-center'> <div className='flex h-5 w-5 shrink-0 items-center justify-center'>

+ 1
- 1
web/app/(commonLayout)/datasets/DatasetCard.tsx View File

return ( return (
<> <>
<div <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} data-disable-nprogress={true}
onClick={(e) => { onClick={(e) => {
e.preventDefault() e.preventDefault()

+ 9
- 1
web/app/account/header.tsx View File

import Avatar from './avatar' import Avatar from './avatar'
import DifyLogo from '@/app/components/base/logo/dify-logo' import DifyLogo from '@/app/components/base/logo/dify-logo'
import { useCallback } from 'react' import { useCallback } from 'react'
import { useGlobalPublicStore } from '@/context/global-public-context'


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


const back = useCallback(() => { const back = useCallback(() => {
router.back() router.back()
<div className='flex flex-1 items-center justify-between px-4'> <div className='flex flex-1 items-center justify-between px-4'>
<div className='flex items-center gap-3'> <div className='flex items-center gap-3'>
<div className='flex cursor-pointer items-center' onClick={back}> <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>
<div className='h-4 w-[1px] origin-center rotate-[11.31deg] bg-divider-regular' /> <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> <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 View File

import TextGeneration from '@/app/components/app/text-generate/item' import TextGeneration from '@/app/components/app/text-generate/item'
import { addFileInfos, sortAgentSorts } from '@/app/components/tools/utils' import { addFileInfos, sortAgentSorts } from '@/app/components/tools/utils'
import MessageLogModal from '@/app/components/base/message-log-modal' 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 { useStore as useAppStore } from '@/app/components/app/store'
import { useAppContext } from '@/context/app-context' import { useAppContext } from '@/context/app-context'
import useTimestamp from '@/hooks/use-timestamp' import useTimestamp from '@/hooks/use-timestamp'
const { userProfile: { timezone } } = useAppContext() const { userProfile: { timezone } } = useAppContext()
const { formatTime } = useTimestamp() const { formatTime } = useTimestamp()
const { onClose, appDetail } = useContext(DrawerContext) 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, currentLogItem: state.currentLogItem,
setCurrentLogItem: state.setCurrentLogItem, setCurrentLogItem: state.setCurrentLogItem,
showMessageLogModal: state.showMessageLogModal, showMessageLogModal: state.showMessageLogModal,
setShowMessageLogModal: state.setShowMessageLogModal, setShowMessageLogModal: state.setShowMessageLogModal,
showPromptLogModal: state.showPromptLogModal,
setShowPromptLogModal: state.setShowPromptLogModal,
currentLogModalActiveTab: state.currentLogModalActiveTab, currentLogModalActiveTab: state.currentLogModalActiveTab,
}))) })))
const { t } = useTranslation() const { t } = useTranslation()
defaultTab={currentLogModalActiveTab} defaultTab={currentLogModalActiveTab}
/> />
)} )}
{showPromptLogModal && (
<PromptLogModal
width={width}
currentLogItem={currentLogItem}
onCancel={() => {
setCurrentLogItem()
setShowPromptLogModal(false)
}}
/>
)}
</div> </div>
) )
} }

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

'flex shrink-0 items-center gap-1.5 px-1', 'flex shrink-0 items-center gap-1.5 px-1',
)}> )}>
<div className='system-2xs-medium-uppercase text-text-tertiary'>{t('share.chat.poweredBy')}</div> <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> </div>
)} )}

+ 1
- 3
web/app/components/base/chat/chat/answer/index.tsx View File

) )
} }


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 View File

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


export type IHeaderProps = { export type IHeaderProps = {
isMobile?: boolean isMobile?: boolean
const [parentOrigin, setParentOrigin] = useState('') const [parentOrigin, setParentOrigin] = useState('')
const [showToggleExpandButton, setShowToggleExpandButton] = useState(false) const [showToggleExpandButton, setShowToggleExpandButton] = useState(false)
const [expanded, setExpanded] = useState(false) const [expanded, setExpanded] = useState(false)
const systemFeatures = useGlobalPublicStore(s => s.systemFeatures)


const handleMessageReceived = useCallback((event: MessageEvent) => { const handleMessageReceived = useCallback((event: MessageEvent) => {
let currentParentOrigin = parentOrigin let currentParentOrigin = parentOrigin
'flex shrink-0 items-center gap-1.5 px-2', 'flex shrink-0 items-center gap-1.5 px-2',
)}> )}>
<div className='system-2xs-medium-uppercase text-text-tertiary'>{t('share.chat.poweredBy')}</div> <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>
)} )}
</div> </div>

+ 9
- 6
web/app/components/base/chat/embedded-chatbot/index.tsx View File

import DifyLogo from '@/app/components/base/logo/dify-logo' import DifyLogo from '@/app/components/base/logo/dify-logo'
import cn from '@/utils/classnames' import cn from '@/utils/classnames'
import useDocumentTitle from '@/hooks/use-document-title' import useDocumentTitle from '@/hooks/use-document-title'
import { useGlobalPublicStore } from '@/context/global-public-context'


const Chatbot = () => { const Chatbot = () => {
const { const {
themeBuilder, themeBuilder,
} = useEmbeddedChatbotContext() } = useEmbeddedChatbotContext()
const { t } = useTranslation() const { t } = useTranslation()
const systemFeatures = useGlobalPublicStore(s => s.systemFeatures)


const customConfig = appData?.custom_config const customConfig = appData?.custom_config
const site = appData?.site const site = appData?.site
'flex shrink-0 items-center gap-1.5 px-2', 'flex shrink-0 items-center gap-1.5 px-2',
)}> )}>
<div className='system-2xs-medium-uppercase text-text-tertiary'>{t('share.chat.poweredBy')}</div> <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>
)} )}
</div> </div>

+ 3
- 10
web/app/components/base/logo/dify-logo.tsx View File

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


export const logoPathMap: Record<LogoStyle, string> = { export const logoPathMap: Record<LogoStyle, string> = {
}) => { }) => {
const { theme } = useTheme() const { theme } = useTheme()
const themedStyle = (theme === 'dark' && style === 'default') ? 'monochromeWhite' : style 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 ( return (
<img <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 View File

numberSectionStyles: 4, numberSectionStyles: 4,
axisFormat: '%Y-%m-%d', axisFormat: '%Y-%m-%d',
}, },
mindmap: {
useMaxWidth: true,
padding: 10,
diagramPadding: 20,
},
maxTextSize: 50000, maxTextSize: 50000,
}) })
isMermaidInitialized = true isMermaidInitialized = true
try { try {
let finalCode: string 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 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 // and preserve exact whitespace/format
finalCode = primitiveCode.trim() finalCode = primitiveCode.trim()
} }
numberSectionStyles: 4, numberSectionStyles: 4,
axisFormat: '%Y-%m-%d', axisFormat: '%Y-%m-%d',
}, },
mindmap: {
useMaxWidth: true,
padding: 10,
diagramPadding: 20,
},
} }


if (look === 'classic') { if (look === 'classic') {
'bg-white': currentTheme === Theme.light, 'bg-white': currentTheme === Theme.light,
'bg-slate-900': currentTheme === Theme.dark, '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-white': currentTheme === Theme.light,
'bg-slate-900': currentTheme === Theme.dark, '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-500': currentTheme === Theme.light,
'text-red-400': currentTheme === Theme.dark, '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-500': currentTheme === Theme.light,
'text-red-400': currentTheme === Theme.dark, 'text-red-400': currentTheme === Theme.dark,
}), }),
'text-gray-700': currentTheme === Theme.light, 'text-gray-700': currentTheme === Theme.light,
'text-gray-300': currentTheme === Theme.dark, '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-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, 'bg-slate-800/80 hover:bg-slate-700 hover:shadow-lg text-yellow-300 border border-slate-600': currentTheme === Theme.dark,
}), }),
// Style classes for look options // Style classes for look options
const getLookButtonClass = (lookType: 'classic' | 'handDrawn') => { const getLookButtonClass = (lookType: 'classic' | 'handDrawn') => {
return cn( 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', 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', 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', look === lookType && currentTheme === Theme.dark && 'border-blue-500 bg-slate-700 text-white',
<div ref={ref as React.RefObject<HTMLDivElement>} className={themeClasses.container}> <div ref={ref as React.RefObject<HTMLDivElement>} className={themeClasses.container}>
<div className={themeClasses.segmented}> <div className={themeClasses.segmented}>
<div className="msh-segmented-group"> <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 <div
key='classic' key='classic'
className={getLookButtonClass('classic')} className={getLookButtonClass('classic')}
<div ref={containerRef} style={{ position: 'absolute', visibility: 'hidden', height: 0, overflow: 'hidden' }} /> <div ref={containerRef} style={{ position: 'absolute', visibility: 'hidden', height: 0, overflow: 'hidden' }} />


{isLoading && !svgCode && ( {isLoading && !svgCode && (
<div className='px-[26px] py-4'>
<div className='py-4 px-[26px]'>
<LoadingAnim type='text'/> <LoadingAnim type='text'/>
{!isCodeComplete && ( {!isCodeComplete && (
<div className="mt-2 text-sm text-gray-500"> <div className="mt-2 text-sm text-gray-500">


{svgCode && ( {svgCode && (
<div className={themeClasses.mermaidDiv} style={{ objectFit: 'cover' }} onClick={() => setImagePreviewUrl(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 <button
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation()

+ 15
- 4
web/app/components/base/mermaid/utils.ts View File

.replace(/section\s+([^:]+):/g, (match, sectionName) => `section ${sectionName}:`) .replace(/section\s+([^:]+):/g, (match, sectionName) => `section ${sectionName}:`)
// Fix common syntax issues // Fix common syntax issues
.replace(/fifopacket/g, 'rect') .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 // Clean up empty lines and extra spaces
.trim() .trim()
} }
export function prepareMermaidCode(code: string, style: 'classic' | 'handDrawn'): string { export function prepareMermaidCode(code: string, style: 'classic' | 'handDrawn'): string {
let finalCode = preprocessMermaidCode(code) 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 return finalCode
} }


return lines.length >= 3 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 // 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 // Check for balanced brackets and parentheses
const isBalanced = (() => { const isBalanced = (() => {

+ 3
- 3
web/app/components/base/tab-slider/index.tsx View File

const newIndex = options.findIndex(option => option.value === value) const newIndex = options.findIndex(option => option.value === value)
setActiveIndex(newIndex) setActiveIndex(newIndex)
updateSliderStyle(newIndex) updateSliderStyle(newIndex)
}, [value, options, pluginList])
}, [value, options, pluginList?.total])


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

+ 14
- 6
web/app/components/custom/custom-web-app-brand/index.tsx View File

} from '@/service/common' } from '@/service/common'
import { useAppContext } from '@/context/app-context' import { useAppContext } from '@/context/app-context'
import cn from '@/utils/classnames' import cn from '@/utils/classnames'
import { useGlobalPublicStore } from '@/context/global-public-context'


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


const [fileId, setFileId] = useState('') const [fileId, setFileId] = useState('')
const [imgKey, setImgKey] = useState(Date.now()) const [imgKey, setImgKey] = useState(Date.now())
const [uploadProgress, setUploadProgress] = useState(0) const [uploadProgress, setUploadProgress] = useState(0)
const systemFeatures = useGlobalPublicStore(s => s.systemFeatures)
const isSandbox = enableBilling && plan.type === Plan.sandbox const isSandbox = enableBilling && plan.type === Plan.sandbox
const uploading = uploadProgress > 0 && uploadProgress < 100 const uploading = uploadProgress > 0 && uploadProgress < 100
const webappLogo = currentWorkspace.custom_config?.replace_webapp_logo || '' const webappLogo = currentWorkspace.custom_config?.replace_webapp_logo || ''
{!webappBrandRemoved && ( {!webappBrandRemoved && (
<> <>
<div className='system-2xs-medium-uppercase text-text-tertiary'>POWERED BY</div> <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' />
} }
</> </>
)} )}
{!webappBrandRemoved && ( {!webappBrandRemoved && (
<> <>
<div className='system-2xs-medium-uppercase text-text-tertiary'>POWERED BY</div> <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 View File

customComponent: isFullDocMode && CustomButton, customComponent: isFullDocMode && CustomButton,
}) })
handleCancel('add') handleCancel('add')
setContent('')
if (isFullDocMode) { if (isFullDocMode) {
refreshTimer.current = setTimeout(() => { refreshTimer.current = setTimeout(() => {
onSave() onSave()

+ 3
- 0
web/app/components/datasets/documents/detail/new-segment.tsx View File

customComponent: CustomButton, customComponent: CustomButton,
}) })
handleCancel('add') handleCancel('add')
setQuestion('')
setAnswer('')
setKeywords([])
refreshTimer.current = setTimeout(() => { refreshTimer.current = setTimeout(() => {
onSave() onSave()
}, 3000) }, 3000)

+ 10
- 1
web/app/components/header/account-about/index.tsx View File

import { IS_CE_EDITION } from '@/config' import { IS_CE_EDITION } from '@/config'
import DifyLogo from '@/app/components/base/logo/dify-logo' import DifyLogo from '@/app/components/base/logo/dify-logo'
import { noop } from 'lodash-es' import { noop } from 'lodash-es'
import { useGlobalPublicStore } from '@/context/global-public-context'


type IAccountSettingProps = { type IAccountSettingProps = {
langeniusVersionInfo: LangGeniusVersionResponse langeniusVersionInfo: LangGeniusVersionResponse
}: IAccountSettingProps) { }: IAccountSettingProps) {
const { t } = useTranslation() const { t } = useTranslation()
const isLatest = langeniusVersionInfo.current_version === langeniusVersionInfo.latest_version const isLatest = langeniusVersionInfo.current_version === langeniusVersionInfo.latest_version
const systemFeatures = useGlobalPublicStore(s => s.systemFeatures)


return ( return (
<Modal <Modal
<RiCloseLine className='h-4 w-4 text-text-tertiary' /> <RiCloseLine className='h-4 w-4 text-text-tertiary' />
</div> </div>
<div className='flex flex-col items-center gap-4 py-8'> <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='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 className='flex flex-col items-center gap-2 text-center text-xs font-normal text-text-secondary'>
<div>© {dayjs().year()} LangGenius, Inc., Contributors.</div> <div>© {dayjs().year()} LangGenius, Inc., Contributors.</div>

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

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

+ 16
- 2
web/app/components/header/index.tsx View File

import PlanBadge from './plan-badge' import PlanBadge from './plan-badge'
import LicenseNav from './license-env' import LicenseNav from './license-env'
import { Plan } from '../billing/type' import { Plan } from '../billing/type'
import { useGlobalPublicStore } from '@/context/global-public-context'


const navClassName = ` const navClassName = `
flex items-center relative mr-0 sm:mr-3 px-3 h-8 rounded-xl flex items-center relative mr-0 sm:mr-3 px-3 h-8 rounded-xl
const [isShowNavMenu, { toggle, setFalse: hideNavMenu }] = useBoolean(false) const [isShowNavMenu, { toggle, setFalse: hideNavMenu }] = useBoolean(false)
const { enableBilling, plan } = useProviderContext() const { enableBilling, plan } = useProviderContext()
const { setShowPricingModal, setShowAccountSettingModal } = useModalContext() const { setShowPricingModal, setShowAccountSettingModal } = useModalContext()
const systemFeatures = useGlobalPublicStore(s => s.systemFeatures)
const isFreePlan = plan.type === Plan.sandbox const isFreePlan = plan.type === Plan.sandbox
const handlePlanClick = useCallback(() => { const handlePlanClick = useCallback(() => {
if (isFreePlan) if (isFreePlan)
!isMobile !isMobile
&& <div className='flex shrink-0 items-center gap-1.5 self-stretch pl-3'> && <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'> <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> </Link>
<div className='font-light text-divider-deep'>/</div> <div className='font-light text-divider-deep'>/</div>
<div className='flex items-center gap-0.5'> <div className='flex items-center gap-0.5'>
{isMobile && ( {isMobile && (
<div className='flex'> <div className='flex'>
<Link href="/apps" className='mr-4 flex items-center'> <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> </Link>
<div className='font-light text-divider-deep'>/</div> <div className='font-light text-divider-deep'>/</div>
{enableBilling ? <PlanBadge allowHover sandboxAsUpgrade plan={plan.type} onClick={handlePlanClick} /> : <LicenseNav />} {enableBilling ? <PlanBadge allowHover sandboxAsUpgrade plan={plan.type} onClick={handlePlanClick} /> : <LicenseNav />}

+ 1
- 1
web/app/components/plugins/plugin-detail-panel/action-list.tsx View File

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

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

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

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

scope?: string scope?: string
value?: ToolValue value?: ToolValue
selectedTools?: ToolValue[] selectedTools?: ToolValue[]
isEdit?: boolean
onSelect: (tool: { onSelect: (tool: {
provider_name: string provider_name: string
tool_name: string tool_name: string
const ToolSelector: FC<Props> = ({ const ToolSelector: FC<Props> = ({
value, value,
selectedTools, selectedTools,
isEdit,
disabled, disabled,
placement = 'left', placement = 'left',
offset = 4, offset = 4,
<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')}> <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 && ( {!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 */} {/* base form */}
<div className='flex flex-col gap-3 px-4 py-2'> <div className='flex flex-col gap-3 px-4 py-2'>
<div className='flex flex-col gap-1'> <div className='flex flex-col gap-1'>

+ 11
- 2
web/app/components/plugins/plugin-page/plugins-panel.tsx View File

'use client' 'use client'
import { useMemo } from 'react' import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import type { FilterState } from './filter-management' import type { FilterState } from './filter-management'
import FilterManagement from './filter-management' import FilterManagement from './filter-management'
import List from './list' import List from './list'
import PluginDetailPanel from '@/app/components/plugins/plugin-detail-panel' import PluginDetailPanel from '@/app/components/plugins/plugin-detail-panel'
import { usePluginPageContext } from './context' import { usePluginPageContext } from './context'
import { useDebounceFn } from 'ahooks' import { useDebounceFn } from 'ahooks'
import Button from '@/app/components/base/button'
import Empty from './empty' import Empty from './empty'
import Loading from '../../base/loading' import Loading from '../../base/loading'
import { PluginSource } from '../types' import { PluginSource } from '../types'


const PluginsPanel = () => { const PluginsPanel = () => {
const { t } = useTranslation()
const filters = usePluginPageContext(v => v.filters) as FilterState const filters = usePluginPageContext(v => v.filters) as FilterState
const setFilters = usePluginPageContext(v => v.setFilters) 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( const { data: installedLatestVersion } = useInstalledLatestVersion(
pluginList?.plugins pluginList?.plugins
.filter(plugin => plugin.source === PluginSource.marketplace) .filter(plugin => plugin.source === PluginSource.marketplace)
/> />
</div> </div>
{isPluginListLoading ? <Loading type='app' /> : (filteredList?.length ?? 0) > 0 ? ( {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'> <div className='w-full'>
<List pluginList={filteredList || []} /> <List pluginList={filteredList || []} />
</div> </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> </div>
) : ( ) : (
<Empty /> <Empty />

+ 5
- 0
web/app/components/plugins/types.ts View File

plugins: PluginDetail[] plugins: PluginDetail[]
} }


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

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

+ 7
- 5
web/app/components/share/text-generation/index.tsx View File

!isPC && resultExisted && 'rounded-b-2xl border-b-[0.5px] border-divider-regular', !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> <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>
)} )}
</div> </div>

+ 1
- 1
web/app/components/workflow/nodes/agent/default.ts View File

} }
} }
// common params // common params
if (param.required && !payload.agent_parameters?.[param.name]?.value) {
if (param.required && !(payload.agent_parameters?.[param.name]?.value || param.default)) {
return { return {
isValid: false, isValid: false,
errorMessage: t('workflow.errorMsg.fieldRequired', { field: renderI18nObject(param.label, language) }), errorMessage: t('workflow.errorMsg.fieldRequired', { field: renderI18nObject(param.label, language) }),

+ 7
- 1
web/app/components/workflow/nodes/http/use-config.ts View File

data: transformToBodyPayload(bodyData, [BodyType.formData, BodyType.xWwwFormUrlencoded].includes(newInputs.body.type)), data: transformToBodyPayload(bodyData, [BodyType.formData, BodyType.xWwwFormUrlencoded].includes(newInputs.body.type)),
} }
} }
else if (!bodyData) {
newInputs.body = {
...newInputs.body,
data: [],
}
}


setInputs(newInputs) setInputs(newInputs)
setIsDataReady(true) setIsDataReady(true)
inputs.url, inputs.url,
inputs.headers, inputs.headers,
inputs.params, 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, fileVarInputs,
]) ])



+ 1
- 1
web/app/components/workflow/nodes/tool/panel.tsx View File

className='w-full' className='w-full'
onClick={showSetAuthModal} onClick={showSetAuthModal}
> >
{t(`${i18nPrefix}.toAuthorize`)}
{t(`${i18nPrefix}.authorize`)}
</Button> </Button>
</div> </div>
</> </>

+ 1
- 1
web/app/reset-password/set-password/page.tsx View File

</div> </div>


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

+ 1
- 1
web/app/routePrefixHandle.tsx View File

const addPrefixToImg = (e: HTMLImageElement) => { const addPrefixToImg = (e: HTMLImageElement) => {
const url = new URL(e.src) const url = new URL(e.src)
const prefix = url.pathname.substr(0, basePath.length) 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 url.pathname = basePath + url.pathname
e.src = url.toString() e.src = url.toString()
} }

+ 9
- 1
web/app/signin/_header.tsx View File

import type { Locale } from '@/i18n' import type { Locale } from '@/i18n'
import I18n from '@/context/i18n' import I18n from '@/context/i18n'
import dynamic from 'next/dynamic' import dynamic from 'next/dynamic'
import { useGlobalPublicStore } from '@/context/global-public-context'


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


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


return ( return (
<div className='flex w-full items-center justify-between p-6'> <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'> <div className='flex items-center gap-1'>
<Select <Select
value={locale} value={locale}

+ 2
- 2
web/config/index.ts View File



export const DEFAULT_AGENT_SETTING = { export const DEFAULT_AGENT_SETTING = {
enabled: false, enabled: false,
max_iteration: 5,
max_iteration: 10,
strategy: AgentStrategy.functionCall, strategy: AgentStrategy.functionCall,
tools: [], tools: [],
} }


export const LOOP_NODE_MAX_COUNT = loopNodeMaxCount 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 !== '') 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) maxIterationsNum = Number.parseInt(process.env.NEXT_PUBLIC_MAX_ITERATIONS_NUM)

+ 0
- 1
web/i18n/de-DE/tools.ts View File

}, },
author: 'Von', author: 'Von',
auth: { auth: {
unauthorized: 'Zur Autorisierung',
authorized: 'Autorisiert', authorized: 'Autorisiert',
setup: 'Autorisierung einrichten, um zu nutzen', setup: 'Autorisierung einrichten, um zu nutzen',
setupModalTitle: 'Autorisierung einrichten', setupModalTitle: 'Autorisierung einrichten',

+ 0
- 0
web/i18n/de-DE/workflow.ts View File


Some files were not shown because too many files changed in this diff

Loading…
Cancel
Save