| 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 | ||||
| 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' |
| ## 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. |
| </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). الأفضل لـ: مشاركة تطبيقاتك والترفيه مع المجتمع. |
| ## কমিউনিটি এবং যোগাযোগ | ## কমিউনিটি এবং যোগাযোগ | ||||
| - [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) আপনার এপ্লিকেশন শেয়ার এবং কমিউনিটি আড্ডার মাধ্যম। |
| 我们欢迎您为 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)。👉:分享您的应用程序并与社区交流。 |
| ## 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. |
| ## コミュニティ & お問い合わせ | ## コミュニティ & お問い合わせ | ||||
| * [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). 主に: アプリケーションの共有やコミュニティとの交流。 |
| ## 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). |
| ## 커뮤니티 & 연락처 | ## 커뮤니티 & 연락처 | ||||
| * [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). 애플리케이션 공유 및 커뮤니티와 소통하기에 적합합니다. |
| ## 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. |
| ## 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. |
| ## 社群與聯絡方式 | ## 社群與聯絡方式 | ||||
| - [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):最適合分享您的應用程式並與社群互動。 |
| 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 |
| 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, | |||||
| ) |
| 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( |
| 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 | ||||
| """ | """ |
| @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): |
| 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) | ||||
| 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() |
| 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) | ||||
| 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() |
| 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): |
| 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 |
| 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 |
| 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) |
| 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( |
| 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) |
| 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() | ||||
| 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() | ||||
| 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, |
| 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( |
| 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: |
| 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, |
| 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 |
| 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, |
| 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, | |||||
| ), | ), | ||||
| ) | ) |
| 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, | |||||
| ), | ), | ||||
| ) | ) | ||||
| 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): |
| 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, |
| 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 |
| 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. | ||||
| """ | """ |
| 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 |
| 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' |
| 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 |
| ) | ) | ||||
| ) | ) | ||||
| 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, |
| 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 |
| 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 | ||||
| "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, | |||||
| } | } | ||||
| 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: |
| 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" |
| 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): |
| "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", |
| 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): |
| 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]: | ||||
| """ | """ |
| 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): |
| 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 |
| 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 |
| 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 = [ |
| 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}" | |||||
| ) |
| 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 |
| 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. |
| # 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 |
| 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. |
| 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 |
| 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'> |
| 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() |
| 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> |
| 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> | ||||
| ) | ) | ||||
| } | } |
| '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> | ||||
| )} | )} |
| ) | ) | ||||
| } | } | ||||
| export default memo(Answer, (prevProps, nextProps) => | |||||
| prevProps.responding === false && nextProps.responding === false, | |||||
| ) | |||||
| export default memo(Answer) |
| 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> |
| 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> |
| 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' | |||||
| /> | /> | ||||
| ) | ) | ||||
| } | } |
| 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() |
| .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 = (() => { |
| 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> |
| } 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' /> | |||||
| } | } | ||||
| </> | </> | ||||
| )} | )} |
| customComponent: isFullDocMode && CustomButton, | customComponent: isFullDocMode && CustomButton, | ||||
| }) | }) | ||||
| handleCancel('add') | handleCancel('add') | ||||
| setContent('') | |||||
| if (isFullDocMode) { | if (isFullDocMode) { | ||||
| refreshTimer.current = setTimeout(() => { | refreshTimer.current = setTimeout(() => { | ||||
| onSave() | onSave() |
| customComponent: CustomButton, | customComponent: CustomButton, | ||||
| }) | }) | ||||
| handleCancel('add') | handleCancel('add') | ||||
| setQuestion('') | |||||
| setAnswer('') | |||||
| setKeywords([]) | |||||
| refreshTimer.current = setTimeout(() => { | refreshTimer.current = setTimeout(() => { | ||||
| onSave() | onSave() | ||||
| }, 3000) | }, 3000) |
| 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> |
| 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'> |
| 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 />} |
| 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'> |
| } | } | ||||
| 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> | ||||
| ))} | ))} |
| 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'> |
| '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 /> |
| 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]: { |
| !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> |
| } | } | ||||
| } | } | ||||
| // 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) }), |
| 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, | ||||
| ]) | ]) | ||||
| className='w-full' | className='w-full' | ||||
| onClick={showSetAuthModal} | onClick={showSetAuthModal} | ||||
| > | > | ||||
| {t(`${i18nPrefix}.toAuthorize`)} | |||||
| {t(`${i18nPrefix}.authorize`)} | |||||
| </Button> | </Button> | ||||
| </div> | </div> | ||||
| </> | </> |
| </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"> |
| 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() | ||||
| } | } |
| 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} |
| 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) |
| }, | }, | ||||
| 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', |