Compare commits

...
Author SHA1 Message Date
takatostandGitHub 17f22347ae bump to 0.6.13 (#6078) 2024-07-08 23:23:07 +08:00
AIxGEEKandGitHub 22aaf8960b fix: Inconsistency Between Actual and Debug Input Variables (#6055) 2024-07-08 22:27:55 +08:00
0046ef7707 refactor: revamp picker block (#4227)
Co-authored-by: crazywoola <100913391+crazywoola@users.noreply.github.com>
2024-07-08 21:56:09 +08:00
takatostandGitHub 68b1d063f7 chore: remove tsne unused code (#6077) 2024-07-08 21:25:19 +08:00
AIxGEEKandGitHub 5e6c3001bd fix: relative in overflow div (#5998) 2024-07-08 18:35:12 +08:00
takatostandGitHub 7ed4e963aa chore(action): move docker login above Set up QEMU in build-push action workflow (#6073) 2024-07-08 18:32:29 +08:00
Chenhe GuandGitHub 001d868cbd remove clunky welcome message (#6069) 2024-07-08 18:17:41 +08:00
Xiao LeyandGitHub 6610b4cee5 feat: add request_params field to jina_reader tool (#5610) 2024-07-08 18:11:50 +08:00
JyongandGitHub cbbe28f40d fix azure stream download (#6063) 2024-07-08 17:13:16 +08:00
JoelandGitHub 603187393a chore: hide tracing introduce detail (#6049) 2024-07-08 10:50:18 +08:00
WaffleGitHubox01024@163.com <Waffle>
411e938e3b Address the issue of the absence of poetry in the development container. (#6036)
Co-authored-by: ox01024@163.com <Waffle>
2024-07-08 09:44:07 +08:00
75pyandGitHub 610da4f662 Fix authorization header validation to handle bearer types correctly - "authorization config header is required" error (#6040) 2024-07-08 00:09:59 +08:00
crazywoolaandGitHub 3ec80f9dda Fix/6034 get random order of categories in explore and workflow is missing in zh hant (#6043) 2024-07-07 17:06:47 +08:00
91c5818236 Modify slack webhook url validation to allow workflow (#6041) (#6042)
Co-authored-by: Shunsuke Mabuchi <mabuchs@amazon.co.jp>
2024-07-07 14:09:20 +08:00
-LAN-andGitHub c436454cd4 fix(configs): Update pydantic settings in config files (#6023) 2024-07-07 12:18:15 +08:00
YeuolyandGitHub a877d4831d Fix/incorrect parameter extractor memory (#6038) 2024-07-07 12:17:34 +08:00
takatostandGitHub d522308a29 chore: optimize memory fetch performance (#6039) 2024-07-07 08:54:24 +08:00
sinoandGitHub 85744b72e5 feat: support moonshot and glm base models for volcengine provider (#6029) 2024-07-07 01:17:33 +08:00
Cherilyn BurenandGitHub f0b7051e1a Optimize db config (#6011) 2024-07-07 01:06:51 +08:00
Masashi TomookaandGitHub 3b23d6764f fix: token count includes base64 string of input images (#5868) 2024-07-06 16:53:32 +08:00
Bowen LiangandGitHub 9b7c74a5d9 chore: skip pip upgrade preparation in api dockerfile (#5999) 2024-07-06 14:17:34 +08:00
-LAN-andGitHub 4d105d7bd7 feat(*): Swtich to dify_config. (#6025) 2024-07-06 12:05:13 +08:00
非法操作andGitHub eee779a923 fix: the input field of tool panel not worked as expected (#6003) 2024-07-06 09:54:30 +08:00
ab847c81fa Add 2 firecrawl tools : Scrape and Search (#6016)
Co-authored-by: -LAN- <laipz8200@outlook.com>
2024-07-06 09:45:39 +08:00
-LAN-andGitHub b217ee414f test(test_rerank): Remove duplicate test cases. (#6024) 2024-07-06 09:44:50 +08:00
takatostandGitHub 23dc6edb99 chore: optimize memory messages fetch count limit (#6021) 2024-07-06 03:25:38 +08:00
takatostandGitHub 79df8825c8 Revert "feat: knowledge admin role" (#6018) 2024-07-05 21:31:34 +08:00
K8sCatandGitHub 71c50b7e20 feat: add Llama 3 and Mixtral model options to ddgo_ai.yaml (#5979)
Signed-off-by: K8sCat <k8scat@gmail.com>
2024-07-05 21:11:15 +08:00
af98fd29bf fix: add status_code 304 (#6000)
Co-authored-by: crazywoola <100913391+crazywoola@users.noreply.github.com>
Co-authored-by: crazywoola <427733928@qq.com>
2024-07-05 21:10:33 +08:00
crazywoolaandGitHub cddea83e65 6014 i18n add support for spanish (#6017) 2024-07-05 21:05:33 +08:00
9f16739518 [Feature] Support loading for mermaid. (#6004)
Co-authored-by: 靖谦 <jingqian@kaiwu.cloud>
2024-07-05 21:01:50 +08:00
JoeandGitHub 3f0da88ff7 fix: update workflow trace query (#6010) 2024-07-05 18:37:26 +08:00
cc63af8e72 Removed firecrawl-py, fixed and improved firecrawl tool (#5896)
Co-authored-by: -LAN- <laipz8200@outlook.com>
2024-07-05 18:04:51 +08:00
非法操作andGitHub bf2268b0af fix API tool's schema not support array (#6006) 2024-07-05 17:11:59 +08:00
xielongandGitHub 00b4cc3cd4 feat: implement forgot password feature (#5534) 2024-07-05 13:38:51 +08:00
f546db5437 fix: document truncation and loss in notion document sync (#5631)
Co-authored-by: Aurelius Huang <cm.huang@aftership.com>
2024-07-05 11:48:17 +08:00
orangeclkandGitHub f8aaa57f31 feat: add retry mechanism for zhipuai (#5926) 2024-07-05 10:49:18 +08:00
cabcf94be3 fix: TENCENT_VECTOR_DB_REPLICAS can be set to 0 (#5968)
Co-authored-by: jianglin <jianglin@wangxiaobao.com>
2024-07-05 08:32:28 +08:00
legaoandGitHub 2d6624cf9e typo: Update README.md (#5987) 2024-07-04 22:50:27 +08:00
-LAN-andGitHub 02982df0d4 fix: Fix some type error in http executor. (#5915) 2024-07-04 19:34:37 +08:00
-LAN-andGitHub 421a24c38d refactor(api/app.py): Simplify the retrieval of debug settings. (#5916) 2024-07-04 18:19:06 +08:00
-LAN-andGitHub d7f75d17cc Chore/remove-unused-code (#5917) 2024-07-04 18:18:26 +08:00
5d9ad430af feat: knowledge admin role (#5965)
Co-authored-by: JzoNg <jzongcode@gmail.com>
Co-authored-by: jyong <718720800@qq.com>
2024-07-04 16:21:40 +08:00
非法操作andGitHub 46eca01fa3 fix: no json output vars in front-page tool (#5943) 2024-07-04 16:15:56 +08:00
AIxGEEKandGitHub 4d9c22bfc6 refactor: optimize-the-performance-of-var-reference-picker (#5918) 2024-07-04 15:33:36 +08:00
JoelandGitHub 52e59cf4df chore: enchance firecrawl user experience (#5958) 2024-07-04 15:26:38 +08:00
JoeandGitHub 688b8fe114 fix: langfuse logical operator error (#5948) 2024-07-04 13:47:15 +08:00
longzhihunandGitHub aecdfa2d5c feat: add claude3 function calling (#5889) 2024-07-03 22:21:02 +08:00
-LAN-andGitHub cb8feb732f refactor: Create a dify_config with Pydantic. (#5938) 2024-07-03 21:09:23 +08:00
orangeclkandGitHub c490bdfbf9 fix: zhipuai pytest correction (#5934) 2024-07-03 19:19:33 +08:00
-LAN-andGitHub e7494d632c docs(api/core/tools/docs/en_US/tool_scale_out.md): Format by markdownlint. (#5903) 2024-07-03 13:13:38 +08:00
takatostandGitHub e3006f98c9 chore: remove dify SaaS URL in default configs (#5888) 2024-07-02 22:49:18 +08:00
crazywoolaandGitHub b34baf1e3a feat: pr template (#5886) 2024-07-02 22:38:17 +08:00
quicksandandGitHub 372dc7ac1a fix bug : TencentVectorDBConfig Add TENCENT_VECTOR_DB_DATABASE (#5879) 2024-07-02 21:31:14 +08:00
-LAN-andGitHub 66a62e6c13 refactor(api/core/app/apps/base_app_generator.py): improve input validation and sanitization in BaseAppGenerator (#5866) 2024-07-02 18:58:07 +08:00
JoelandGitHub 04c0a9ad45 chore: click area that trigger showing tracing config is too large (#5878) 2024-07-02 18:28:41 +08:00
0944ca9d91 Fix/remove tsne position test (#5858)
Co-authored-by: StyleZhang <jasonapring2015@outlook.com>
2024-07-02 17:57:42 +08:00
d468f8b75c Fix/docker env namings (#5857)
Co-authored-by: dahuahua <38651850@qq.com>
2024-07-02 16:14:34 +08:00
Masashi TomookaandGitHub 6e256507d3 doc: docker-compose won't start due to wrong README (#5859) 2024-07-02 16:10:22 +08:00
JoelandGitHub a33ce09e6e fix:retieval setting document link 404 (#5861) 2024-07-02 16:02:17 +08:00
quicksandandGitHub 9f973bb703 chore:remove .env.example duplicate key (#5853) 2024-07-02 15:10:18 +08:00
JoelandGitHub 6d0a605c5f fix: not show opening question if the opening message is empty (#5856) 2024-07-02 15:04:02 +08:00
a948bf6ee8 fix: react.js error 185 maximum update depth exceeded in streaming responses during conversation (#5849)
Co-authored-by: 林星宇 <xy@udxx.cn>
2024-07-02 14:26:56 +08:00
189 changed files with 6779 additions and 2733 deletions
+1
View File
@@ -1,6 +1,7 @@
#!/bin/bash
cd web && npm install
pipx install poetry
echo 'alias start-api="cd /workspaces/dify/api && flask run --host 0.0.0.0 --port=5001 --debug"' >> ~/.bashrc
echo 'alias start-worker="cd /workspaces/dify/api && celery -A app.celery worker -P gevent -c 1 --loglevel INFO -Q dataset,generation,mail,ops_trace,app_deletion"' >> ~/.bashrc
+16 -14
View File
@@ -1,13 +1,21 @@
# Checklist:
> [!IMPORTANT]
> Please review the checklist below before submitting your pull request.
- [ ] Please open an issue before creating a PR or link to an existing issue
- [ ] I have performed a self-review of my own code
- [ ] I have commented my code, particularly in hard-to-understand areas
- [ ] I ran `dev/reformat`(backend) and `cd web && npx lint-staged`(frontend) to appease the lint gods
# Description
Please include a summary of the change and which issue is fixed. Please also include relevant motivation and context. List any dependencies that are required for this change.
Describe the big picture of your changes here to communicate to the maintainers why we should accept this pull request. If it fixes a bug or resolves a feature request, be sure to link to that issue. Close issue syntax: `Fixes #<issue number>`, see [documentation](https://docs.github.com/en/issues/tracking-your-work-with-issues/linking-a-pull-request-to-an-issue#linking-a-pull-request-to-an-issue-using-a-keyword) for more details.
Fixes # (issue)
Fixes
## Type of Change
Please delete options that are not relevant.
- [ ] Bug fix (non-breaking change which fixes an issue)
- [ ] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
@@ -15,18 +23,12 @@ Please delete options that are not relevant.
- [ ] Improvement, including but not limited to code refactoring, performance optimization, and UI/UX improvement
- [ ] Dependency upgrade
# How Has This Been Tested?
# Testing Instructions
Please describe the tests that you ran to verify your changes. Provide instructions so we can reproduce. Please also list any relevant details for your test configuration
- [ ] TODO
- [ ] Test A
- [ ] Test B
# Suggested Checklist:
- [ ] I have performed a self-review of my own code
- [ ] I have commented my code, particularly in hard-to-understand areas
- [ ] My changes generate no new warnings
- [ ] I ran `dev/reformat`(backend) and `cd web && npx lint-staged`(frontend) to appease the lint gods
- [ ] `optional` I have made corresponding changes to the documentation
- [ ] `optional` I have added tests that prove my fix is effective or that my feature works
- [ ] `optional` New and existing unit tests pass locally with my changes
+6 -6
View File
@@ -48,18 +48,18 @@ jobs:
platform=${{ matrix.platform }}
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Docker Hub
uses: docker/login-action@v2
with:
username: ${{ env.DOCKERHUB_USER }}
password: ${{ env.DOCKERHUB_TOKEN }}
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Extract metadata for Docker
id: meta
uses: docker/metadata-action@v5
+2
View File
@@ -174,3 +174,5 @@ sdks/python-client/dify_client.egg-info
.vscode/*
!.vscode/launch.json
pyrightconfig.json
.idea/
-1
View File
@@ -13,7 +13,6 @@
"jinja": true,
"env": {
"FLASK_APP": "app.py",
"FLASK_DEBUG": "1",
"GEVENT_SUPPORT": "True"
},
"args": [
+1 -2
View File
@@ -5,8 +5,7 @@ WORKDIR /app/api
# Install Poetry
ENV POETRY_VERSION=1.8.3
RUN pip install --no-cache-dir --upgrade pip && \
pip install --no-cache-dir --upgrade poetry==${POETRY_VERSION}
RUN pip install --no-cache-dir poetry==${POETRY_VERSION}
# Configure Poetry
ENV POETRY_CACHE_DIR=/tmp/poetry_cache
+1
View File
@@ -11,6 +11,7 @@
```bash
cd ../docker
cp middleware.env.example middleware.env
docker compose -f docker-compose.middleware.yaml -p dify up -d
cd ../api
```
+5 -3
View File
@@ -1,8 +1,8 @@
import os
from configs.app_config import DifyConfig
from configs import dify_config
if not os.environ.get("DEBUG") or os.environ.get("DEBUG", "false").lower() != 'true':
if os.environ.get("DEBUG", "false").lower() != 'true':
from gevent import monkey
monkey.patch_all()
@@ -43,6 +43,8 @@ from extensions import (
from extensions.ext_database import db
from extensions.ext_login import login_manager
from libs.passport import PassportService
# TODO: Find a way to avoid importing models here
from models import account, dataset, model, source, task, tool, tools, web
from services.account_service import AccountService
@@ -81,7 +83,7 @@ def create_flask_app_with_configs() -> Flask:
with configs loaded from .env file
"""
dify_app = DifyApp(__name__)
dify_app.config.from_mapping(DifyConfig().model_dump())
dify_app.config.from_mapping(dify_config.model_dump())
# populate configs into system environment variables
for key, value in dify_app.config.items():
+2 -1
View File
@@ -8,6 +8,7 @@ import click
from flask import current_app
from werkzeug.exceptions import NotFound
from configs import dify_config
from constants.languages import languages
from core.rag.datasource.vdb.vector_factory import Vector
from core.rag.datasource.vdb.vector_type import VectorType
@@ -112,7 +113,7 @@ def reset_encrypt_key_pair():
After the reset, all LLM credentials will become invalid, requiring re-entry.
Only support SELF_HOSTED mode.
"""
if current_app.config['EDITION'] != 'SELF_HOSTED':
if dify_config.EDITION != 'SELF_HOSTED':
click.echo(click.style('Sorry, only support SELF_HOSTED mode.', fg='red'))
return
+3
View File
@@ -0,0 +1,3 @@
from .app_config import DifyConfig
dify_config = DifyConfig()
+29 -7
View File
@@ -1,4 +1,5 @@
from pydantic_settings import BaseSettings, SettingsConfigDict
from pydantic import Field, computed_field
from pydantic_settings import SettingsConfigDict
from configs.deploy import DeploymentConfig
from configs.enterprise import EnterpriseFeatureConfig
@@ -8,13 +9,7 @@ from configs.middleware import MiddlewareConfig
from configs.packaging import PackagingInfo
# TODO: Both `BaseModel` and `BaseSettings` has `model_config` attribute but they are in different types.
# This inheritance is depends on the order of the classes.
# It is better to use `BaseSettings` as the base class.
class DifyConfig(
# based on pydantic-settings
BaseSettings,
# Packaging info
PackagingInfo,
@@ -34,12 +29,39 @@ class DifyConfig(
# **Before using, please contact business@dify.ai by email to inquire about licensing matters.**
EnterpriseFeatureConfig,
):
DEBUG: bool = Field(default=False, description='whether to enable debug mode.')
model_config = SettingsConfigDict(
# read from dotenv format config file
env_file='.env',
env_file_encoding='utf-8',
frozen=True,
# ignore extra attributes
extra='ignore',
)
CODE_MAX_NUMBER: int = 9223372036854775807
CODE_MIN_NUMBER: int = -9223372036854775808
CODE_MAX_STRING_LENGTH: int = 80000
CODE_MAX_STRING_ARRAY_LENGTH: int = 30
CODE_MAX_OBJECT_ARRAY_LENGTH: int = 30
CODE_MAX_NUMBER_ARRAY_LENGTH: int = 1000
HTTP_REQUEST_MAX_CONNECT_TIMEOUT: int = 300
HTTP_REQUEST_MAX_READ_TIMEOUT: int = 600
HTTP_REQUEST_MAX_WRITE_TIMEOUT: int = 600
HTTP_REQUEST_NODE_MAX_BINARY_SIZE: int = 1024 * 1024 * 10
@computed_field
def HTTP_REQUEST_NODE_READABLE_MAX_BINARY_SIZE(self) -> str:
return f'{self.HTTP_REQUEST_NODE_MAX_BINARY_SIZE / 1024 / 1024:.2f}MB'
HTTP_REQUEST_NODE_MAX_TEXT_SIZE: int = 1024 * 1024
@computed_field
def HTTP_REQUEST_NODE_READABLE_MAX_TEXT_SIZE(self) -> str:
return f'{self.HTTP_REQUEST_NODE_MAX_TEXT_SIZE / 1024 / 1024:.2f}MB'
SSRF_PROXY_HTTP_URL: str | None = None
SSRF_PROXY_HTTPS_URL: str | None = None
+3 -2
View File
@@ -1,7 +1,8 @@
from pydantic import BaseModel, Field
from pydantic import Field
from pydantic_settings import BaseSettings
class DeploymentConfig(BaseModel):
class DeploymentConfig(BaseSettings):
"""
Deployment configs
"""
+3 -2
View File
@@ -1,7 +1,8 @@
from pydantic import BaseModel, Field
from pydantic import Field
from pydantic_settings import BaseSettings
class EnterpriseFeatureConfig(BaseModel):
class EnterpriseFeatureConfig(BaseSettings):
"""
Enterprise feature configs.
**Before using, please contact business@dify.ai by email to inquire about licensing matters.**
-2
View File
@@ -1,5 +1,3 @@
from pydantic import BaseModel
from configs.extra.notion_config import NotionConfig
from configs.extra.sentry_config import SentryConfig
+3 -2
View File
@@ -1,9 +1,10 @@
from typing import Optional
from pydantic import BaseModel, Field
from pydantic import Field
from pydantic_settings import BaseSettings
class NotionConfig(BaseModel):
class NotionConfig(BaseSettings):
"""
Notion integration configs
"""
+3 -2
View File
@@ -1,9 +1,10 @@
from typing import Optional
from pydantic import BaseModel, Field, NonNegativeFloat
from pydantic import Field, NonNegativeFloat
from pydantic_settings import BaseSettings
class SentryConfig(BaseModel):
class SentryConfig(BaseSettings):
"""
Sentry configs
"""
+33 -28
View File
@@ -1,11 +1,12 @@
from typing import Optional
from pydantic import AliasChoices, BaseModel, Field, NonNegativeInt, PositiveInt, computed_field
from pydantic import AliasChoices, Field, NonNegativeInt, PositiveInt, computed_field
from pydantic_settings import BaseSettings
from configs.feature.hosted_service import HostedServiceConfig
class SecurityConfig(BaseModel):
class SecurityConfig(BaseSettings):
"""
Secret Key configs
"""
@@ -17,8 +18,12 @@ class SecurityConfig(BaseModel):
default=None,
)
RESET_PASSWORD_TOKEN_EXPIRY_HOURS: PositiveInt = Field(
description='Expiry time in hours for reset token',
default=24,
)
class AppExecutionConfig(BaseModel):
class AppExecutionConfig(BaseSettings):
"""
App Execution configs
"""
@@ -28,7 +33,7 @@ class AppExecutionConfig(BaseModel):
)
class CodeExecutionSandboxConfig(BaseModel):
class CodeExecutionSandboxConfig(BaseSettings):
"""
Code Execution Sandbox configs
"""
@@ -43,36 +48,36 @@ class CodeExecutionSandboxConfig(BaseModel):
)
class EndpointConfig(BaseModel):
class EndpointConfig(BaseSettings):
"""
Module URL configs
"""
CONSOLE_API_URL: str = Field(
description='The backend URL prefix of the console API.'
'used to concatenate the login authorization callback or notion integration callback.',
default='https://cloud.dify.ai',
default='',
)
CONSOLE_WEB_URL: str = Field(
description='The front-end URL prefix of the console web.'
'used to concatenate some front-end addresses and for CORS configuration use.',
default='https://cloud.dify.ai',
default='',
)
SERVICE_API_URL: str = Field(
description='Service API Url prefix.'
'used to display Service API Base Url to the front-end.',
default='https://api.dify.ai',
default='',
)
APP_WEB_URL: str = Field(
description='WebApp Url prefix.'
'used to display WebAPP API Base Url to the front-end.',
default='https://udify.app',
default='',
)
class FileAccessConfig(BaseModel):
class FileAccessConfig(BaseSettings):
"""
File Access configs
"""
@@ -82,7 +87,7 @@ class FileAccessConfig(BaseModel):
'Url is signed and has expiration time.',
validation_alias=AliasChoices('FILES_URL', 'CONSOLE_API_URL'),
alias_priority=1,
default='https://cloud.dify.ai',
default='',
)
FILES_ACCESS_TIMEOUT: int = Field(
@@ -91,7 +96,7 @@ class FileAccessConfig(BaseModel):
)
class FileUploadConfig(BaseModel):
class FileUploadConfig(BaseSettings):
"""
File Uploading configs
"""
@@ -116,7 +121,7 @@ class FileUploadConfig(BaseModel):
)
class HttpConfig(BaseModel):
class HttpConfig(BaseSettings):
"""
HTTP configs
"""
@@ -148,7 +153,7 @@ class HttpConfig(BaseModel):
return self.inner_WEB_API_CORS_ALLOW_ORIGINS.split(',')
class InnerAPIConfig(BaseModel):
class InnerAPIConfig(BaseSettings):
"""
Inner API configs
"""
@@ -163,7 +168,7 @@ class InnerAPIConfig(BaseModel):
)
class LoggingConfig(BaseModel):
class LoggingConfig(BaseSettings):
"""
Logging configs
"""
@@ -195,7 +200,7 @@ class LoggingConfig(BaseModel):
)
class ModelLoadBalanceConfig(BaseModel):
class ModelLoadBalanceConfig(BaseSettings):
"""
Model load balance configs
"""
@@ -205,7 +210,7 @@ class ModelLoadBalanceConfig(BaseModel):
)
class BillingConfig(BaseModel):
class BillingConfig(BaseSettings):
"""
Platform Billing Configurations
"""
@@ -215,7 +220,7 @@ class BillingConfig(BaseModel):
)
class UpdateConfig(BaseModel):
class UpdateConfig(BaseSettings):
"""
Update configs
"""
@@ -225,7 +230,7 @@ class UpdateConfig(BaseModel):
)
class WorkflowConfig(BaseModel):
class WorkflowConfig(BaseSettings):
"""
Workflow feature configs
"""
@@ -246,7 +251,7 @@ class WorkflowConfig(BaseModel):
)
class OAuthConfig(BaseModel):
class OAuthConfig(BaseSettings):
"""
oauth configs
"""
@@ -276,7 +281,7 @@ class OAuthConfig(BaseModel):
)
class ModerationConfig(BaseModel):
class ModerationConfig(BaseSettings):
"""
Moderation in app configs.
"""
@@ -288,7 +293,7 @@ class ModerationConfig(BaseModel):
)
class ToolConfig(BaseModel):
class ToolConfig(BaseSettings):
"""
Tool configs
"""
@@ -299,7 +304,7 @@ class ToolConfig(BaseModel):
)
class MailConfig(BaseModel):
class MailConfig(BaseSettings):
"""
Mail Configurations
"""
@@ -355,7 +360,7 @@ class MailConfig(BaseModel):
)
class RagEtlConfig(BaseModel):
class RagEtlConfig(BaseSettings):
"""
RAG ETL Configurations.
"""
@@ -381,7 +386,7 @@ class RagEtlConfig(BaseModel):
)
class DataSetConfig(BaseModel):
class DataSetConfig(BaseSettings):
"""
Dataset configs
"""
@@ -392,7 +397,7 @@ class DataSetConfig(BaseModel):
)
class WorkspaceConfig(BaseModel):
class WorkspaceConfig(BaseSettings):
"""
Workspace configs
"""
@@ -403,7 +408,7 @@ class WorkspaceConfig(BaseModel):
)
class IndexingConfig(BaseModel):
class IndexingConfig(BaseSettings):
"""
Indexing configs.
"""
@@ -414,7 +419,7 @@ class IndexingConfig(BaseModel):
)
class ImageFormatConfig(BaseModel):
class ImageFormatConfig(BaseSettings):
MULTIMODAL_SEND_IMAGE_FORMAT: str = Field(
description='multi model send image format, support base64, url, default is base64',
default='base64',
+10 -9
View File
@@ -1,9 +1,10 @@
from typing import Optional
from pydantic import BaseModel, Field, NonNegativeInt
from pydantic import Field, NonNegativeInt
from pydantic_settings import BaseSettings
class HostedOpenAiConfig(BaseModel):
class HostedOpenAiConfig(BaseSettings):
"""
Hosted OpenAI service config
"""
@@ -68,7 +69,7 @@ class HostedOpenAiConfig(BaseModel):
)
class HostedAzureOpenAiConfig(BaseModel):
class HostedAzureOpenAiConfig(BaseSettings):
"""
Hosted OpenAI service config
"""
@@ -94,7 +95,7 @@ class HostedAzureOpenAiConfig(BaseModel):
)
class HostedAnthropicConfig(BaseModel):
class HostedAnthropicConfig(BaseSettings):
"""
Hosted Azure OpenAI service config
"""
@@ -125,7 +126,7 @@ class HostedAnthropicConfig(BaseModel):
)
class HostedMinmaxConfig(BaseModel):
class HostedMinmaxConfig(BaseSettings):
"""
Hosted Minmax service config
"""
@@ -136,7 +137,7 @@ class HostedMinmaxConfig(BaseModel):
)
class HostedSparkConfig(BaseModel):
class HostedSparkConfig(BaseSettings):
"""
Hosted Spark service config
"""
@@ -147,7 +148,7 @@ class HostedSparkConfig(BaseModel):
)
class HostedZhipuAIConfig(BaseModel):
class HostedZhipuAIConfig(BaseSettings):
"""
Hosted Minmax service config
"""
@@ -158,7 +159,7 @@ class HostedZhipuAIConfig(BaseModel):
)
class HostedModerationConfig(BaseModel):
class HostedModerationConfig(BaseSettings):
"""
Hosted Moderation service config
"""
@@ -174,7 +175,7 @@ class HostedModerationConfig(BaseModel):
)
class HostedFetchAppTemplateConfig(BaseModel):
class HostedFetchAppTemplateConfig(BaseSettings):
"""
Hosted Moderation service config
"""
+17 -6
View File
@@ -1,6 +1,7 @@
from typing import Any, Optional
from pydantic import BaseModel, Field, NonNegativeInt, PositiveInt, computed_field
from pydantic import Field, NonNegativeInt, PositiveInt, computed_field
from pydantic_settings import BaseSettings
from configs.middleware.cache.redis_config import RedisConfig
from configs.middleware.storage.aliyun_oss_storage_config import AliyunOSSStorageConfig
@@ -22,7 +23,7 @@ from configs.middleware.vdb.tidb_vector_config import TiDBVectorConfig
from configs.middleware.vdb.weaviate_config import WeaviateConfig
class StorageConfig(BaseModel):
class StorageConfig(BaseSettings):
STORAGE_TYPE: str = Field(
description='storage type,'
' default to `local`,'
@@ -36,14 +37,14 @@ class StorageConfig(BaseModel):
)
class VectorStoreConfig(BaseModel):
class VectorStoreConfig(BaseSettings):
VECTOR_STORE: Optional[str] = Field(
description='vector store type',
default=None,
)
class KeywordStoreConfig(BaseModel):
class KeywordStoreConfig(BaseSettings):
KEYWORD_STORE: str = Field(
description='keyword store type',
default='jieba',
@@ -81,6 +82,11 @@ class DatabaseConfig:
default='',
)
DB_EXTRAS: str = Field(
description='db extras options. Example: keepalives_idle=60&keepalives=1',
default='',
)
SQLALCHEMY_DATABASE_URI_SCHEME: str = Field(
description='db uri scheme',
default='postgresql',
@@ -89,7 +95,12 @@ class DatabaseConfig:
@computed_field
@property
def SQLALCHEMY_DATABASE_URI(self) -> str:
db_extras = f"?client_encoding={self.DB_CHARSET}" if self.DB_CHARSET else ""
db_extras = (
f"{self.DB_EXTRAS}&client_encoding={self.DB_CHARSET}"
if self.DB_CHARSET
else self.DB_EXTRAS
).strip("&")
db_extras = f"?{db_extras}" if db_extras else ""
return (f"{self.SQLALCHEMY_DATABASE_URI_SCHEME}://"
f"{self.DB_USERNAME}:{self.DB_PASSWORD}@{self.DB_HOST}:{self.DB_PORT}/{self.DB_DATABASE}"
f"{db_extras}")
@@ -114,7 +125,7 @@ class DatabaseConfig:
default=False,
)
SQLALCHEMY_ECHO: bool = Field(
SQLALCHEMY_ECHO: bool | str = Field(
description='whether to enable SqlAlchemy echo',
default=False,
)
+3 -2
View File
@@ -1,9 +1,10 @@
from typing import Optional
from pydantic import BaseModel, Field, NonNegativeInt, PositiveInt
from pydantic import Field, NonNegativeInt, PositiveInt
from pydantic_settings import BaseSettings
class RedisConfig(BaseModel):
class RedisConfig(BaseSettings):
"""
Redis configs
"""
@@ -1,9 +1,10 @@
from typing import Optional
from pydantic import BaseModel, Field
from pydantic import Field
from pydantic_settings import BaseSettings
class AliyunOSSStorageConfig(BaseModel):
class AliyunOSSStorageConfig(BaseSettings):
"""
Aliyun storage configs
"""
@@ -1,9 +1,10 @@
from typing import Optional
from pydantic import BaseModel, Field
from pydantic import Field
from pydantic_settings import BaseSettings
class S3StorageConfig(BaseModel):
class S3StorageConfig(BaseSettings):
"""
S3 storage configs
"""
@@ -1,9 +1,10 @@
from typing import Optional
from pydantic import BaseModel, Field
from pydantic import Field
from pydantic_settings import BaseSettings
class AzureBlobStorageConfig(BaseModel):
class AzureBlobStorageConfig(BaseSettings):
"""
Azure Blob storage configs
"""
@@ -1,9 +1,10 @@
from typing import Optional
from pydantic import BaseModel, Field
from pydantic import Field
from pydantic_settings import BaseSettings
class GoogleCloudStorageConfig(BaseModel):
class GoogleCloudStorageConfig(BaseSettings):
"""
Google Cloud storage configs
"""
@@ -1,9 +1,10 @@
from typing import Optional
from pydantic import BaseModel, Field
from pydantic import Field
from pydantic_settings import BaseSettings
class OCIStorageConfig(BaseModel):
class OCIStorageConfig(BaseSettings):
"""
OCI storage configs
"""
@@ -1,9 +1,10 @@
from typing import Optional
from pydantic import BaseModel, Field
from pydantic import Field
from pydantic_settings import BaseSettings
class TencentCloudCOSStorageConfig(BaseModel):
class TencentCloudCOSStorageConfig(BaseSettings):
"""
Tencent Cloud COS storage configs
"""
+3 -2
View File
@@ -1,9 +1,10 @@
from typing import Optional
from pydantic import BaseModel, Field, PositiveInt
from pydantic import Field, PositiveInt
from pydantic_settings import BaseSettings
class ChromaConfig(BaseModel):
class ChromaConfig(BaseSettings):
"""
Chroma configs
"""
+3 -2
View File
@@ -1,9 +1,10 @@
from typing import Optional
from pydantic import BaseModel, Field, PositiveInt
from pydantic import Field, PositiveInt
from pydantic_settings import BaseSettings
class MilvusConfig(BaseModel):
class MilvusConfig(BaseSettings):
"""
Milvus configs
"""
@@ -1,9 +1,10 @@
from typing import Optional
from pydantic import BaseModel, Field, PositiveInt
from pydantic import Field, PositiveInt
from pydantic_settings import BaseSettings
class OpenSearchConfig(BaseModel):
class OpenSearchConfig(BaseSettings):
"""
OpenSearch configs
"""
+3 -2
View File
@@ -1,9 +1,10 @@
from typing import Optional
from pydantic import BaseModel, Field, PositiveInt
from pydantic import Field, PositiveInt
from pydantic_settings import BaseSettings
class OracleConfig(BaseModel):
class OracleConfig(BaseSettings):
"""
ORACLE configs
"""
@@ -1,9 +1,10 @@
from typing import Optional
from pydantic import BaseModel, Field, PositiveInt
from pydantic import Field, PositiveInt
from pydantic_settings import BaseSettings
class PGVectorConfig(BaseModel):
class PGVectorConfig(BaseSettings):
"""
PGVector configs
"""
@@ -1,9 +1,10 @@
from typing import Optional
from pydantic import BaseModel, Field, PositiveInt
from pydantic import Field, PositiveInt
from pydantic_settings import BaseSettings
class PGVectoRSConfig(BaseModel):
class PGVectoRSConfig(BaseSettings):
"""
PGVectoRS configs
"""
+3 -2
View File
@@ -1,9 +1,10 @@
from typing import Optional
from pydantic import BaseModel, Field, NonNegativeInt, PositiveInt
from pydantic import Field, NonNegativeInt, PositiveInt
from pydantic_settings import BaseSettings
class QdrantConfig(BaseModel):
class QdrantConfig(BaseSettings):
"""
Qdrant configs
"""
+3 -2
View File
@@ -1,9 +1,10 @@
from typing import Optional
from pydantic import BaseModel, Field, PositiveInt
from pydantic import Field, PositiveInt
from pydantic_settings import BaseSettings
class RelytConfig(BaseModel):
class RelytConfig(BaseSettings):
"""
Relyt configs
"""
@@ -1,9 +1,10 @@
from typing import Optional
from pydantic import BaseModel, Field, PositiveInt
from pydantic import Field, NonNegativeInt, PositiveInt
from pydantic_settings import BaseSettings
class TencentVectorDBConfig(BaseModel):
class TencentVectorDBConfig(BaseSettings):
"""
Tencent Vector configs
"""
@@ -24,7 +25,7 @@ class TencentVectorDBConfig(BaseModel):
)
TENCENT_VECTOR_DB_USERNAME: Optional[str] = Field(
description='Tencent Vector password',
description='Tencent Vector username',
default=None,
)
@@ -38,7 +39,12 @@ class TencentVectorDBConfig(BaseModel):
default=1,
)
TENCENT_VECTOR_DB_REPLICAS: PositiveInt = Field(
TENCENT_VECTOR_DB_REPLICAS: NonNegativeInt = Field(
description='Tencent Vector replicas',
default=2,
)
TENCENT_VECTOR_DB_DATABASE: Optional[str] = Field(
description='Tencent Vector Database',
default=None,
)
@@ -1,9 +1,10 @@
from typing import Optional
from pydantic import BaseModel, Field, PositiveInt
from pydantic import Field, PositiveInt
from pydantic_settings import BaseSettings
class TiDBVectorConfig(BaseModel):
class TiDBVectorConfig(BaseSettings):
"""
TiDB Vector configs
"""
@@ -1,9 +1,10 @@
from typing import Optional
from pydantic import BaseModel, Field, PositiveInt
from pydantic import Field, PositiveInt
from pydantic_settings import BaseSettings
class WeaviateConfig(BaseModel):
class WeaviateConfig(BaseSettings):
"""
Weaviate configs
"""
+4 -3
View File
@@ -1,14 +1,15 @@
from pydantic import BaseModel, Field
from pydantic import Field
from pydantic_settings import BaseSettings
class PackagingInfo(BaseModel):
class PackagingInfo(BaseSettings):
"""
Packaging build information
"""
CURRENT_VERSION: str = Field(
description='Dify version',
default='0.6.12-fix1',
default='0.6.13',
)
COMMIT_SHA: str = Field(
+1 -1
View File
@@ -14,7 +14,7 @@ language_timezone_mapping = {
'vi-VN': 'Asia/Ho_Chi_Minh',
'ro-RO': 'Europe/Bucharest',
'pl-PL': 'Europe/Warsaw',
'hi-IN': 'Asia/Kolkata'
'hi-IN': 'Asia/Kolkata',
}
languages = list(language_timezone_mapping.keys())
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -30,7 +30,7 @@ from .app import (
)
# Import auth controllers
from .auth import activate, data_source_bearer_auth, data_source_oauth, login, oauth
from .auth import activate, data_source_bearer_auth, data_source_oauth, forgot_password, login, oauth
# Import billing controllers
from .billing import billing
@@ -6,6 +6,7 @@ from flask_login import current_user
from flask_restful import Resource
from werkzeug.exceptions import Forbidden
from configs import dify_config
from controllers.console import api
from libs.login import login_required
from libs.oauth_data_source import NotionOAuth
@@ -16,11 +17,11 @@ from ..wraps import account_initialization_required
def get_oauth_providers():
with current_app.app_context():
notion_oauth = NotionOAuth(client_id=current_app.config.get('NOTION_CLIENT_ID'),
client_secret=current_app.config.get(
'NOTION_CLIENT_SECRET'),
redirect_uri=current_app.config.get(
'CONSOLE_API_URL') + '/console/api/oauth/data-source/callback/notion')
if not dify_config.NOTION_CLIENT_ID or not dify_config.NOTION_CLIENT_SECRET:
return {}
notion_oauth = NotionOAuth(client_id=dify_config.NOTION_CLIENT_ID,
client_secret=dify_config.NOTION_CLIENT_SECRET,
redirect_uri=dify_config.CONSOLE_API_URL + '/console/api/oauth/data-source/callback/notion')
OAUTH_PROVIDERS = {
'notion': notion_oauth
@@ -39,8 +40,10 @@ class OAuthDataSource(Resource):
print(vars(oauth_provider))
if not oauth_provider:
return {'error': 'Invalid provider'}, 400
if current_app.config.get('NOTION_INTEGRATION_TYPE') == 'internal':
internal_secret = current_app.config.get('NOTION_INTERNAL_SECRET')
if dify_config.NOTION_INTEGRATION_TYPE == 'internal':
internal_secret = dify_config.NOTION_INTERNAL_SECRET
if not internal_secret:
return {'error': 'Internal secret is not set'},
oauth_provider.save_internal_access_token(internal_secret)
return { 'data': '' }
else:
@@ -60,13 +63,13 @@ class OAuthDataSourceCallback(Resource):
if 'code' in request.args:
code = request.args.get('code')
return redirect(f'{current_app.config.get("CONSOLE_WEB_URL")}?type=notion&code={code}')
return redirect(f'{dify_config.CONSOLE_WEB_URL}?type=notion&code={code}')
elif 'error' in request.args:
error = request.args.get('error')
return redirect(f'{current_app.config.get("CONSOLE_WEB_URL")}?type=notion&error={error}')
return redirect(f'{dify_config.CONSOLE_WEB_URL}?type=notion&error={error}')
else:
return redirect(f'{current_app.config.get("CONSOLE_WEB_URL")}?type=notion&error=Access denied')
return redirect(f'{dify_config.CONSOLE_WEB_URL}?type=notion&error=Access denied')
class OAuthDataSourceBinding(Resource):
+25
View File
@@ -5,3 +5,28 @@ class ApiKeyAuthFailedError(BaseHTTPException):
error_code = 'auth_failed'
description = "{message}"
code = 500
class InvalidEmailError(BaseHTTPException):
error_code = 'invalid_email'
description = "The email address is not valid."
code = 400
class PasswordMismatchError(BaseHTTPException):
error_code = 'password_mismatch'
description = "The passwords do not match."
code = 400
class InvalidTokenError(BaseHTTPException):
error_code = 'invalid_or_expired_token'
description = "The token is invalid or has expired."
code = 400
class PasswordResetRateLimitExceededError(BaseHTTPException):
error_code = 'password_reset_rate_limit_exceeded'
description = "Password reset rate limit exceeded. Try again later."
code = 429
@@ -0,0 +1,107 @@
import base64
import logging
import secrets
from flask_restful import Resource, reqparse
from controllers.console import api
from controllers.console.auth.error import (
InvalidEmailError,
InvalidTokenError,
PasswordMismatchError,
PasswordResetRateLimitExceededError,
)
from controllers.console.setup import setup_required
from extensions.ext_database import db
from libs.helper import email as email_validate
from libs.password import hash_password, valid_password
from models.account import Account
from services.account_service import AccountService
from services.errors.account import RateLimitExceededError
class ForgotPasswordSendEmailApi(Resource):
@setup_required
def post(self):
parser = reqparse.RequestParser()
parser.add_argument('email', type=str, required=True, location='json')
args = parser.parse_args()
email = args['email']
if not email_validate(email):
raise InvalidEmailError()
account = Account.query.filter_by(email=email).first()
if account:
try:
AccountService.send_reset_password_email(account=account)
except RateLimitExceededError:
logging.warning(f"Rate limit exceeded for email: {account.email}")
raise PasswordResetRateLimitExceededError()
else:
# Return success to avoid revealing email registration status
logging.warning(f"Attempt to reset password for unregistered email: {email}")
return {"result": "success"}
class ForgotPasswordCheckApi(Resource):
@setup_required
def post(self):
parser = reqparse.RequestParser()
parser.add_argument('token', type=str, required=True, nullable=False, location='json')
args = parser.parse_args()
token = args['token']
reset_data = AccountService.get_reset_password_data(token)
if reset_data is None:
return {'is_valid': False, 'email': None}
return {'is_valid': True, 'email': reset_data.get('email')}
class ForgotPasswordResetApi(Resource):
@setup_required
def post(self):
parser = reqparse.RequestParser()
parser.add_argument('token', type=str, required=True, nullable=False, location='json')
parser.add_argument('new_password', type=valid_password, required=True, nullable=False, location='json')
parser.add_argument('password_confirm', type=valid_password, required=True, nullable=False, location='json')
args = parser.parse_args()
new_password = args['new_password']
password_confirm = args['password_confirm']
if str(new_password).strip() != str(password_confirm).strip():
raise PasswordMismatchError()
token = args['token']
reset_data = AccountService.get_reset_password_data(token)
if reset_data is None:
raise InvalidTokenError()
AccountService.revoke_reset_password_token(token)
salt = secrets.token_bytes(16)
base64_salt = base64.b64encode(salt).decode()
password_hashed = hash_password(new_password, salt)
base64_password_hashed = base64.b64encode(password_hashed).decode()
account = Account.query.filter_by(email=reset_data.get('email')).first()
account.password = base64_password_hashed
account.password_salt = base64_salt
db.session.commit()
return {'result': 'success'}
api.add_resource(ForgotPasswordSendEmailApi, '/forgot-password')
api.add_resource(ForgotPasswordCheckApi, '/forgot-password/validity')
api.add_resource(ForgotPasswordResetApi, '/forgot-password/resets')
+19 -19
View File
@@ -1,7 +1,7 @@
from typing import cast
import flask_login
from flask import current_app, request
from flask import request
from flask_restful import Resource, reqparse
import services
@@ -56,14 +56,14 @@ class LogoutApi(Resource):
class ResetPasswordApi(Resource):
@setup_required
def get(self):
parser = reqparse.RequestParser()
parser.add_argument('email', type=email, required=True, location='json')
args = parser.parse_args()
# parser = reqparse.RequestParser()
# parser.add_argument('email', type=email, required=True, location='json')
# args = parser.parse_args()
# import mailchimp_transactional as MailchimpTransactional
# from mailchimp_transactional.api_client import ApiClientError
account = {'email': args['email']}
# account = {'email': args['email']}
# account = AccountService.get_by_email(args['email'])
# if account is None:
# raise ValueError('Email not found')
@@ -71,22 +71,22 @@ class ResetPasswordApi(Resource):
# AccountService.update_password(account, new_password)
# todo: Send email
MAILCHIMP_API_KEY = current_app.config['MAILCHIMP_TRANSACTIONAL_API_KEY']
# MAILCHIMP_API_KEY = current_app.config['MAILCHIMP_TRANSACTIONAL_API_KEY']
# mailchimp = MailchimpTransactional(MAILCHIMP_API_KEY)
message = {
'from_email': 'noreply@example.com',
'to': [{'email': account.email}],
'subject': 'Reset your Dify password',
'html': """
<p>Dear User,</p>
<p>The Dify team has generated a new password for you, details as follows:</p>
<p><strong>{new_password}</strong></p>
<p>Please change your password to log in as soon as possible.</p>
<p>Regards,</p>
<p>The Dify Team</p>
"""
}
# message = {
# 'from_email': 'noreply@example.com',
# 'to': [{'email': account['email']}],
# 'subject': 'Reset your Dify password',
# 'html': """
# <p>Dear User,</p>
# <p>The Dify team has generated a new password for you, details as follows:</p>
# <p><strong>{new_password}</strong></p>
# <p>Please change your password to log in as soon as possible.</p>
# <p>Regards,</p>
# <p>The Dify Team</p>
# """
# }
# response = mailchimp.messages.send({
# 'message': message,
+21 -23
View File
@@ -6,6 +6,7 @@ import requests
from flask import current_app, redirect, request
from flask_restful import Resource
from configs import dify_config
from constants.languages import languages
from extensions.ext_database import db
from libs.helper import get_remote_ip
@@ -18,22 +19,24 @@ from .. import api
def get_oauth_providers():
with current_app.app_context():
github_oauth = GitHubOAuth(client_id=current_app.config.get('GITHUB_CLIENT_ID'),
client_secret=current_app.config.get(
'GITHUB_CLIENT_SECRET'),
redirect_uri=current_app.config.get(
'CONSOLE_API_URL') + '/console/api/oauth/authorize/github')
if not dify_config.GITHUB_CLIENT_ID or not dify_config.GITHUB_CLIENT_SECRET:
github_oauth = None
else:
github_oauth = GitHubOAuth(
client_id=dify_config.GITHUB_CLIENT_ID,
client_secret=dify_config.GITHUB_CLIENT_SECRET,
redirect_uri=dify_config.CONSOLE_API_URL + '/console/api/oauth/authorize/github',
)
if not dify_config.GOOGLE_CLIENT_ID or not dify_config.GOOGLE_CLIENT_SECRET:
google_oauth = None
else:
google_oauth = GoogleOAuth(
client_id=dify_config.GOOGLE_CLIENT_ID,
client_secret=dify_config.GOOGLE_CLIENT_SECRET,
redirect_uri=dify_config.CONSOLE_API_URL + '/console/api/oauth/authorize/google',
)
google_oauth = GoogleOAuth(client_id=current_app.config.get('GOOGLE_CLIENT_ID'),
client_secret=current_app.config.get(
'GOOGLE_CLIENT_SECRET'),
redirect_uri=current_app.config.get(
'CONSOLE_API_URL') + '/console/api/oauth/authorize/google')
OAUTH_PROVIDERS = {
'github': github_oauth,
'google': google_oauth
}
OAUTH_PROVIDERS = {'github': github_oauth, 'google': google_oauth}
return OAUTH_PROVIDERS
@@ -63,8 +66,7 @@ class OAuthCallback(Resource):
token = oauth_provider.get_access_token(code)
user_info = oauth_provider.get_user_info(token)
except requests.exceptions.HTTPError as e:
logging.exception(
f"An error occurred during the OAuth process with {provider}: {e.response.text}")
logging.exception(f'An error occurred during the OAuth process with {provider}: {e.response.text}')
return {'error': 'OAuth process failed'}, 400
account = _generate_account(provider, user_info)
@@ -81,7 +83,7 @@ class OAuthCallback(Resource):
token = AccountService.login(account, ip_address=get_remote_ip(request))
return redirect(f'{current_app.config.get("CONSOLE_WEB_URL")}?console_token={token}')
return redirect(f'{dify_config.CONSOLE_WEB_URL}?console_token={token}')
def _get_account_by_openid_or_email(provider: str, user_info: OAuthUserInfo) -> Optional[Account]:
@@ -101,11 +103,7 @@ def _generate_account(provider: str, user_info: OAuthUserInfo):
# Create account
account_name = user_info.name if user_info.name else 'Dify'
account = RegisterService.register(
email=user_info.email,
name=account_name,
password=None,
open_id=user_info.id,
provider=provider
email=user_info.email, name=account_name, password=None, open_id=user_info.id, provider=provider
)
# Set interface language
@@ -245,6 +245,8 @@ class AccountIntegrateApi(Resource):
return {'data': integrate_data}
# Register API resources
api.add_resource(AccountInitApi, '/account/init')
api.add_resource(AccountProfileApi, '/account/profile')
+4
View File
@@ -114,6 +114,10 @@ class VariableEntity(BaseModel):
default: Optional[str] = None
hint: Optional[str] = None
@property
def name(self) -> str:
return self.variable
class ExternalDataVariableEntity(BaseModel):
"""
+48 -44
View File
@@ -1,52 +1,56 @@
from collections.abc import Mapping
from typing import Any, Optional
from core.app.app_config.entities import AppConfig, VariableEntity
class BaseAppGenerator:
def _get_cleaned_inputs(self, user_inputs: dict, app_config: AppConfig):
if user_inputs is None:
user_inputs = {}
filtered_inputs = {}
def _get_cleaned_inputs(self, user_inputs: Optional[Mapping[str, Any]], app_config: AppConfig) -> Mapping[str, Any]:
user_inputs = user_inputs or {}
# Filter input variables from form configuration, handle required fields, default values, and option values
variables = app_config.variables
for variable_config in variables:
variable = variable_config.variable
if (variable not in user_inputs
or user_inputs[variable] is None
or (isinstance(user_inputs[variable], str) and user_inputs[variable] == '')):
if variable_config.required:
raise ValueError(f"{variable} is required in input form")
else:
filtered_inputs[variable] = variable_config.default if variable_config.default is not None else ""
continue
value = user_inputs[variable]
if value is not None:
if variable_config.type != VariableEntity.Type.NUMBER and not isinstance(value, str):
raise ValueError(f"{variable} in input form must be a string")
elif variable_config.type == VariableEntity.Type.NUMBER and isinstance(value, str):
if '.' in value:
value = float(value)
else:
value = int(value)
if variable_config.type == VariableEntity.Type.SELECT:
options = variable_config.options if variable_config.options is not None else []
if value not in options:
raise ValueError(f"{variable} in input form must be one of the following: {options}")
elif variable_config.type in [VariableEntity.Type.TEXT_INPUT, VariableEntity.Type.PARAGRAPH]:
if variable_config.max_length is not None:
max_length = variable_config.max_length
if len(value) > max_length:
raise ValueError(f'{variable} in input form must be less than {max_length} characters')
if value and isinstance(value, str):
filtered_inputs[variable] = value.replace('\x00', '')
else:
filtered_inputs[variable] = value if value is not None else None
filtered_inputs = {var.name: self._validate_input(inputs=user_inputs, var=var) for var in variables}
filtered_inputs = {k: self._sanitize_value(v) for k, v in filtered_inputs.items()}
return filtered_inputs
def _validate_input(self, *, inputs: Mapping[str, Any], var: VariableEntity):
user_input_value = inputs.get(var.name)
if var.required and not user_input_value:
raise ValueError(f'{var.name} is required in input form')
if not var.required and not user_input_value:
# TODO: should we return None here if the default value is None?
return var.default or ''
if (
var.type
in (
VariableEntity.Type.TEXT_INPUT,
VariableEntity.Type.SELECT,
VariableEntity.Type.PARAGRAPH,
)
and user_input_value
and not isinstance(user_input_value, str)
):
raise ValueError(f"(type '{var.type}') {var.name} in input form must be a string")
if var.type == VariableEntity.Type.NUMBER and isinstance(user_input_value, str):
# may raise ValueError if user_input_value is not a valid number
try:
if '.' in user_input_value:
return float(user_input_value)
else:
return int(user_input_value)
except ValueError:
raise ValueError(f"{var.name} in input form must be a valid number")
if var.type == VariableEntity.Type.SELECT:
options = var.options or []
if user_input_value not in options:
raise ValueError(f'{var.name} in input form must be one of the following: {options}')
elif var.type in (VariableEntity.Type.TEXT_INPUT, VariableEntity.Type.PARAGRAPH):
if var.max_length and user_input_value and len(user_input_value) > var.max_length:
raise ValueError(f'{var.name} in input form must be less than {var.max_length} characters')
return user_input_value
def _sanitize_value(self, value: Any) -> Any:
if isinstance(value, str):
return value.replace('\x00', '')
return value
+2 -4
View File
@@ -94,7 +94,6 @@ class WorkflowAppGenerator(BaseAppGenerator):
application_generate_entity=application_generate_entity,
invoke_from=invoke_from,
stream=stream,
call_depth=call_depth,
)
def _generate(
@@ -104,7 +103,6 @@ class WorkflowAppGenerator(BaseAppGenerator):
application_generate_entity: WorkflowAppGenerateEntity,
invoke_from: InvokeFrom,
stream: bool = True,
call_depth: int = 0
) -> Union[dict, Generator[dict, None, None]]:
"""
Generate App response.
@@ -166,10 +164,10 @@ class WorkflowAppGenerator(BaseAppGenerator):
"""
if not node_id:
raise ValueError('node_id is required')
if args.get('inputs') is None:
raise ValueError('inputs is required')
extras = {
"auto_generate_conversation_name": False
}
@@ -1,7 +1,6 @@
import os
import requests
from configs import dify_config
from models.api_based_extension import APIBasedExtensionPoint
@@ -31,10 +30,10 @@ class APIBasedExtensionRequestor:
try:
# proxy support for security
proxies = None
if os.environ.get("SSRF_PROXY_HTTP_URL") and os.environ.get("SSRF_PROXY_HTTPS_URL"):
if dify_config.SSRF_PROXY_HTTP_URL and dify_config.SSRF_PROXY_HTTPS_URL:
proxies = {
'http': os.environ.get("SSRF_PROXY_HTTP_URL"),
'https': os.environ.get("SSRF_PROXY_HTTPS_URL"),
'http': dify_config.SSRF_PROXY_HTTP_URL,
'https': dify_config.SSRF_PROXY_HTTPS_URL,
}
response = requests.request(
+1 -1
View File
@@ -186,7 +186,7 @@ class MessageFileParser:
}
response = requests.head(url, headers=headers, allow_redirects=True)
if response.status_code == 200:
if response.status_code in {200, 304}:
return True, ""
else:
return False, "URL does not exist."
@@ -1,5 +1,4 @@
import logging
import os
import time
from enum import Enum
from threading import Lock
@@ -9,6 +8,7 @@ from httpx import get, post
from pydantic import BaseModel
from yarl import URL
from configs import dify_config
from core.helper.code_executor.entities import CodeDependency
from core.helper.code_executor.javascript.javascript_transformer import NodeJsTemplateTransformer
from core.helper.code_executor.jinja2.jinja2_transformer import Jinja2TemplateTransformer
@@ -18,8 +18,8 @@ from core.helper.code_executor.template_transformer import TemplateTransformer
logger = logging.getLogger(__name__)
# Code Executor
CODE_EXECUTION_ENDPOINT = os.environ.get('CODE_EXECUTION_ENDPOINT', 'http://sandbox:8194')
CODE_EXECUTION_API_KEY = os.environ.get('CODE_EXECUTION_API_KEY', 'dify-sandbox')
CODE_EXECUTION_ENDPOINT = dify_config.CODE_EXECUTION_ENDPOINT
CODE_EXECUTION_API_KEY = dify_config.CODE_EXECUTION_API_KEY
CODE_EXECUTION_TIMEOUT= (10, 60)
+26 -12
View File
@@ -12,7 +12,8 @@ from core.model_runtime.entities.message_entities import (
UserPromptMessage,
)
from extensions.ext_database import db
from models.model import AppMode, Conversation, Message
from models.model import AppMode, Conversation, Message, MessageFile
from models.workflow import WorkflowRun
class TokenBufferMemory:
@@ -30,33 +31,46 @@ class TokenBufferMemory:
app_record = self.conversation.app
# fetch limited messages, and return reversed
query = db.session.query(Message).filter(
query = db.session.query(
Message.id,
Message.query,
Message.answer,
Message.created_at,
Message.workflow_run_id
).filter(
Message.conversation_id == self.conversation.id,
Message.answer != ''
).order_by(Message.created_at.desc())
if message_limit and message_limit > 0:
messages = query.limit(message_limit).all()
message_limit = message_limit if message_limit <= 500 else 500
else:
messages = query.all()
message_limit = 500
messages = query.limit(message_limit).all()
messages = list(reversed(messages))
message_file_parser = MessageFileParser(
tenant_id=app_record.tenant_id,
app_id=app_record.id
)
prompt_messages = []
for message in messages:
files = message.message_files
files = db.session.query(MessageFile).filter(MessageFile.message_id == message.id).all()
if files:
file_extra_config = None
if self.conversation.mode not in [AppMode.ADVANCED_CHAT.value, AppMode.WORKFLOW.value]:
file_extra_config = FileUploadConfigManager.convert(message.app_model_config.to_dict())
file_extra_config = FileUploadConfigManager.convert(self.conversation.model_config)
else:
file_extra_config = FileUploadConfigManager.convert(
message.workflow_run.workflow.features_dict,
is_vision=False
)
if message.workflow_run_id:
workflow_run = (db.session.query(WorkflowRun)
.filter(WorkflowRun.id == message.workflow_run_id).first())
if workflow_run:
file_extra_config = FileUploadConfigManager.convert(
workflow_run.workflow.features_dict,
is_vision=False
)
if file_extra_config:
file_objs = message_file_parser.transform_message_files(
@@ -136,4 +150,4 @@ class TokenBufferMemory:
message = f"{role}: {m.content}"
string_messages.append(message)
return "\n".join(string_messages)
return "\n".join(string_messages)
@@ -5,6 +5,8 @@ model_type: llm
features:
- agent-thought
- vision
- tool-call
- stream-tool-call
model_properties:
mode: chat
context_size: 200000
@@ -5,6 +5,8 @@ model_type: llm
features:
- agent-thought
- vision
- tool-call
- stream-tool-call
model_properties:
mode: chat
context_size: 200000
@@ -5,6 +5,8 @@ model_type: llm
features:
- agent-thought
- vision
- tool-call
- stream-tool-call
model_properties:
mode: chat
context_size: 200000
@@ -5,6 +5,8 @@ model_type: llm
features:
- agent-thought
- vision
- tool-call
- stream-tool-call
model_properties:
mode: chat
context_size: 200000
@@ -29,6 +29,7 @@ from core.model_runtime.entities.message_entities import (
PromptMessageTool,
SystemPromptMessage,
TextPromptMessageContent,
ToolPromptMessage,
UserPromptMessage,
)
from core.model_runtime.errors.invoke import (
@@ -68,7 +69,7 @@ class BedrockLargeLanguageModel(LargeLanguageModel):
# TODO: consolidate different invocation methods for models based on base model capabilities
# invoke anthropic models via boto3 client
if "anthropic" in model:
return self._generate_anthropic(model, credentials, prompt_messages, model_parameters, stop, stream, user)
return self._generate_anthropic(model, credentials, prompt_messages, model_parameters, stop, stream, user, tools)
# invoke Cohere models via boto3 client
if "cohere.command-r" in model:
return self._generate_cohere_chat(model, credentials, prompt_messages, model_parameters, stop, stream, user, tools)
@@ -151,7 +152,7 @@ class BedrockLargeLanguageModel(LargeLanguageModel):
def _generate_anthropic(self, model: str, credentials: dict, prompt_messages: list[PromptMessage], model_parameters: dict,
stop: Optional[list[str]] = None, stream: bool = True, user: Optional[str] = None) -> Union[LLMResult, Generator]:
stop: Optional[list[str]] = None, stream: bool = True, user: Optional[str] = None, tools: Optional[list[PromptMessageTool]] = None,) -> Union[LLMResult, Generator]:
"""
Invoke Anthropic large language model
@@ -171,23 +172,24 @@ class BedrockLargeLanguageModel(LargeLanguageModel):
system, prompt_message_dicts = self._convert_converse_prompt_messages(prompt_messages)
inference_config, additional_model_fields = self._convert_converse_api_model_parameters(model_parameters, stop)
parameters = {
'modelId': model,
'messages': prompt_message_dicts,
'inferenceConfig': inference_config,
'additionalModelRequestFields': additional_model_fields,
}
if system and len(system) > 0:
parameters['system'] = system
if tools:
parameters['toolConfig'] = self._convert_converse_tool_config(tools=tools)
if stream:
response = bedrock_client.converse_stream(
modelId=model,
messages=prompt_message_dicts,
system=system,
inferenceConfig=inference_config,
additionalModelRequestFields=additional_model_fields
)
response = bedrock_client.converse_stream(**parameters)
return self._handle_converse_stream_response(model, credentials, response, prompt_messages)
else:
response = bedrock_client.converse(
modelId=model,
messages=prompt_message_dicts,
system=system,
inferenceConfig=inference_config,
additionalModelRequestFields=additional_model_fields
)
response = bedrock_client.converse(**parameters)
return self._handle_converse_response(model, credentials, response, prompt_messages)
def _handle_converse_response(self, model: str, credentials: dict, response: dict,
@@ -246,12 +248,18 @@ class BedrockLargeLanguageModel(LargeLanguageModel):
output_tokens = 0
finish_reason = None
index = 0
tool_calls: list[AssistantPromptMessage.ToolCall] = []
tool_use = {}
for chunk in response['stream']:
if 'messageStart' in chunk:
return_model = model
elif 'messageStop' in chunk:
finish_reason = chunk['messageStop']['stopReason']
elif 'contentBlockStart' in chunk:
tool = chunk['contentBlockStart']['start']['toolUse']
tool_use['toolUseId'] = tool['toolUseId']
tool_use['name'] = tool['name']
elif 'metadata' in chunk:
input_tokens = chunk['metadata']['usage']['inputTokens']
output_tokens = chunk['metadata']['usage']['outputTokens']
@@ -260,29 +268,49 @@ class BedrockLargeLanguageModel(LargeLanguageModel):
model=return_model,
prompt_messages=prompt_messages,
delta=LLMResultChunkDelta(
index=index + 1,
index=index,
message=AssistantPromptMessage(
content=''
content='',
tool_calls=tool_calls
),
finish_reason=finish_reason,
usage=usage
)
)
elif 'contentBlockDelta' in chunk:
chunk_text = chunk['contentBlockDelta']['delta']['text'] if chunk['contentBlockDelta']['delta']['text'] else ''
full_assistant_content += chunk_text
assistant_prompt_message = AssistantPromptMessage(
content=chunk_text if chunk_text else '',
)
index = chunk['contentBlockDelta']['contentBlockIndex']
yield LLMResultChunk(
model=model,
prompt_messages=prompt_messages,
delta=LLMResultChunkDelta(
index=index,
message=assistant_prompt_message,
delta = chunk['contentBlockDelta']['delta']
if 'text' in delta:
chunk_text = delta['text'] if delta['text'] else ''
full_assistant_content += chunk_text
assistant_prompt_message = AssistantPromptMessage(
content=chunk_text if chunk_text else '',
)
)
index = chunk['contentBlockDelta']['contentBlockIndex']
yield LLMResultChunk(
model=model,
prompt_messages=prompt_messages,
delta=LLMResultChunkDelta(
index=index+1,
message=assistant_prompt_message,
)
)
elif 'toolUse' in delta:
if 'input' not in tool_use:
tool_use['input'] = ''
tool_use['input'] += delta['toolUse']['input']
elif 'contentBlockStop' in chunk:
if 'input' in tool_use:
tool_call = AssistantPromptMessage.ToolCall(
id=tool_use['toolUseId'],
type='function',
function=AssistantPromptMessage.ToolCall.ToolCallFunction(
name=tool_use['name'],
arguments=tool_use['input']
)
)
tool_calls.append(tool_call)
tool_use = {}
except Exception as ex:
raise InvokeError(str(ex))
@@ -312,16 +340,10 @@ class BedrockLargeLanguageModel(LargeLanguageModel):
"""
system = []
first_loop = True
for message in prompt_messages:
if isinstance(message, SystemPromptMessage):
message.content=message.content.strip()
if first_loop:
system=message.content
first_loop=False
else:
system+="\n"
system+=message.content
system.append({"text": message.content})
prompt_message_dicts = []
for message in prompt_messages:
@@ -330,6 +352,25 @@ class BedrockLargeLanguageModel(LargeLanguageModel):
return system, prompt_message_dicts
def _convert_converse_tool_config(self, tools: Optional[list[PromptMessageTool]] = None) -> dict:
tool_config = {}
configs = []
if tools:
for tool in tools:
configs.append(
{
"toolSpec": {
"name": tool.name,
"description": tool.description,
"inputSchema": {
"json": tool.parameters
}
}
}
)
tool_config["tools"] = configs
return tool_config
def _convert_prompt_message_to_dict(self, message: PromptMessage) -> dict:
"""
Convert PromptMessage to dict
@@ -379,10 +420,32 @@ class BedrockLargeLanguageModel(LargeLanguageModel):
message_dict = {"role": "user", "content": sub_messages}
elif isinstance(message, AssistantPromptMessage):
message = cast(AssistantPromptMessage, message)
message_dict = {"role": "assistant", "content": [{'text': message.content}]}
if message.tool_calls:
message_dict = {
"role": "assistant", "content":[{
"toolUse": {
"toolUseId": message.tool_calls[0].id,
"name": message.tool_calls[0].function.name,
"input": json.loads(message.tool_calls[0].function.arguments)
}
}]
}
else:
message_dict = {"role": "assistant", "content": [{'text': message.content}]}
elif isinstance(message, SystemPromptMessage):
message = cast(SystemPromptMessage, message)
message_dict = [{'text': message.content}]
elif isinstance(message, ToolPromptMessage):
message = cast(ToolPromptMessage, message)
message_dict = {
"role": "user",
"content": [{
"toolResult": {
"toolUseId": message.tool_call_id,
"content": [{"json": {"text": message.content}}]
}
}]
}
else:
raise ValueError(f"Got unknown type {message}")
@@ -401,11 +464,13 @@ class BedrockLargeLanguageModel(LargeLanguageModel):
"""
prefix = model.split('.')[0]
model_name = model.split('.')[1]
if isinstance(prompt_messages, str):
prompt = prompt_messages
else:
prompt = self._convert_messages_to_prompt(prompt_messages, prefix, model_name)
return self._get_num_tokens_by_gpt2(prompt)
def validate_credentials(self, model: str, credentials: dict) -> None:
@@ -489,11 +554,16 @@ class BedrockLargeLanguageModel(LargeLanguageModel):
content = message.content
if isinstance(message, UserPromptMessage):
message_text = f"{human_prompt_prefix} {content} {human_prompt_postfix}"
body = content
if (isinstance(content, list)):
body = "".join([c.data for c in content if c.type == PromptMessageContentType.TEXT])
message_text = f"{human_prompt_prefix} {body} {human_prompt_postfix}"
elif isinstance(message, AssistantPromptMessage):
message_text = f"{ai_prompt} {content}"
elif isinstance(message, SystemPromptMessage):
message_text = content
elif isinstance(message, ToolPromptMessage):
message_text = f"{human_prompt_prefix} {message.content}"
else:
raise ValueError(f"Got unknown type {message}")
@@ -111,5 +111,71 @@ ModelConfigs = {
'mode': 'chat',
},
'features': [],
},
'Moonshot-v1-8k': {
'req_params': {
'max_prompt_tokens': 8192,
'max_new_tokens': 4096,
},
'model_properties': {
'context_size': 8192,
'mode': 'chat',
},
'features': [],
},
'Moonshot-v1-32k': {
'req_params': {
'max_prompt_tokens': 32768,
'max_new_tokens': 16384,
},
'model_properties': {
'context_size': 32768,
'mode': 'chat',
},
'features': [],
},
'Moonshot-v1-128k': {
'req_params': {
'max_prompt_tokens': 131072,
'max_new_tokens': 65536,
},
'model_properties': {
'context_size': 131072,
'mode': 'chat',
},
'features': [],
},
'GLM3-130B': {
'req_params': {
'max_prompt_tokens': 8192,
'max_new_tokens': 4096,
},
'model_properties': {
'context_size': 8192,
'mode': 'chat',
},
'features': [],
},
'GLM3-130B-Fin': {
'req_params': {
'max_prompt_tokens': 8192,
'max_new_tokens': 4096,
},
'model_properties': {
'context_size': 8192,
'mode': 'chat',
},
'features': [],
},
'Mistral-7B': {
'req_params': {
'max_prompt_tokens': 8192,
'max_new_tokens': 2048,
},
'model_properties': {
'context_size': 8192,
'mode': 'chat',
},
'features': [],
}
}
@@ -120,12 +120,6 @@ model_credential_schema:
show_on:
- variable: __model_type
value: llm
- label:
en_US: Skylark2-pro-4k
value: Skylark2-pro-4k
show_on:
- variable: __model_type
value: llm
- label:
en_US: Llama3-8B
value: Llama3-8B
@@ -138,6 +132,42 @@ model_credential_schema:
show_on:
- variable: __model_type
value: llm
- label:
en_US: Moonshot-v1-8k
value: Moonshot-v1-8k
show_on:
- variable: __model_type
value: llm
- label:
en_US: Moonshot-v1-32k
value: Moonshot-v1-32k
show_on:
- variable: __model_type
value: llm
- label:
en_US: Moonshot-v1-128k
value: Moonshot-v1-128k
show_on:
- variable: __model_type
value: llm
- label:
en_US: GLM3-130B
value: GLM3-130B
show_on:
- variable: __model_type
value: llm
- label:
en_US: GLM3-130B-Fin
value: GLM3-130B-Fin
show_on:
- variable: __model_type
value: llm
- label:
en_US: Mistral-7B
value: Mistral-7B
show_on:
- variable: __model_type
value: llm
- label:
en_US: Doubao-embedding
value: Doubao-embedding
@@ -181,7 +211,7 @@ model_credential_schema:
zh_Hans: 模型上下文长度
en_US: Model Context Size
type: text-input
default: '4096'
default: "4096"
placeholder:
zh_Hans: 输入您的模型上下文长度
en_US: Enter your Model Context Size
@@ -195,7 +225,7 @@ model_credential_schema:
label:
zh_Hans: 最大 token 上限
en_US: Upper Bound for Max Tokens
default: '4096'
default: "4096"
type: text-input
placeholder:
zh_Hans: 输入您的模型最大 token 上限
@@ -29,10 +29,8 @@ class ZhipuAI(HttpClient):
http_client: httpx.Client | None = None,
custom_headers: Mapping[str, str] | None = None
) -> None:
# if api_key is None:
# api_key = os.environ.get("ZHIPUAI_API_KEY")
if api_key is None:
raise ZhipuAIError("未提供api_key,请通过参数或环境变量提供")
raise ZhipuAIError("No api_key provided, please provide it through parameters or environment variables")
self.api_key = api_key
if base_url is None:
@@ -7,6 +7,8 @@ from typing import Any, Union, cast
import httpx
import pydantic
from httpx import URL, Timeout
from tenacity import retry
from tenacity.stop import stop_after_attempt
from . import _errors
from ._base_type import NOT_GIVEN, Body, Data, Headers, NotGiven, Query, RequestFiles, ResponseT
@@ -221,6 +223,7 @@ class HttpClient:
def __exit__(self, exc_type, exc_val, exc_tb):
self.close()
@retry(stop=stop_after_attempt(ZHIPUAI_DEFAULT_MAX_RETRIES))
def request(
self,
*,
-21
View File
@@ -1,21 +0,0 @@
from typing import Any, Literal
from pydantic import BaseModel
from pydantic.version import VERSION as PYDANTIC_VERSION
PYDANTIC_V2 = PYDANTIC_VERSION.startswith("2.")
if PYDANTIC_V2:
from pydantic_core import Url as Url
def _model_dump(
model: BaseModel, mode: Literal["json", "python"] = "json", **kwargs: Any
) -> Any:
return model.model_dump(mode=mode, **kwargs)
else:
from pydantic import AnyUrl as Url # noqa: F401
def _model_dump(
model: BaseModel, mode: Literal["json", "python"] = "json", **kwargs: Any
) -> Any:
return model.dict(**kwargs)
+6 -10
View File
@@ -8,16 +8,20 @@ from ipaddress import IPv4Address, IPv4Interface, IPv4Network, IPv6Address, IPv6
from pathlib import Path, PurePath
from re import Pattern
from types import GeneratorType
from typing import Any, Optional, Union
from typing import Any, Literal, Optional, Union
from uuid import UUID
from pydantic import BaseModel
from pydantic.networks import AnyUrl, NameEmail
from pydantic.types import SecretBytes, SecretStr
from pydantic_core import Url
from pydantic_extra_types.color import Color
from ._compat import PYDANTIC_V2, Url, _model_dump
def _model_dump(
model: BaseModel, mode: Literal["json", "python"] = "json", **kwargs: Any
) -> Any:
return model.model_dump(mode=mode, **kwargs)
# Taken from Pydantic v1 as is
def isoformat(o: Union[datetime.date, datetime.time]) -> str:
@@ -109,12 +113,6 @@ def jsonable_encoder(
if isinstance(obj, encoder_type):
return encoder_instance(obj)
if isinstance(obj, BaseModel):
# TODO: remove when deprecating Pydantic v1
encoders: dict[Any, Any] = {}
if not PYDANTIC_V2:
encoders = getattr(obj.__config__, "json_encoders", {}) # type: ignore[attr-defined]
if custom_encoder:
encoders.update(custom_encoder)
obj_dict = _model_dump(
obj,
mode="json",
@@ -131,8 +129,6 @@ def jsonable_encoder(
obj_dict,
exclude_none=exclude_none,
exclude_defaults=exclude_defaults,
# TODO: remove when deprecating Pydantic v1
custom_encoder=encoders,
sqlalchemy_safe=sqlalchemy_safe,
)
if dataclasses.is_dataclass(obj):
+1 -1
View File
@@ -27,7 +27,7 @@ class LangfuseConfig(BaseTracingConfig):
def set_value(cls, v, info: ValidationInfo):
if v is None or v == "":
v = 'https://api.langfuse.com'
if not v.startswith('https://') or not v.startswith('http://'):
if not v.startswith('https://') and not v.startswith('http://'):
raise ValueError('host must start with https:// or http://')
return v
+14 -1
View File
@@ -107,7 +107,20 @@ class LangFuseDataTrace(BaseTraceInstance):
# through workflow_run_id get all_nodes_execution
workflow_nodes_executions = (
db.session.query(WorkflowNodeExecution)
db.session.query(
WorkflowNodeExecution.id,
WorkflowNodeExecution.tenant_id,
WorkflowNodeExecution.app_id,
WorkflowNodeExecution.title,
WorkflowNodeExecution.node_type,
WorkflowNodeExecution.status,
WorkflowNodeExecution.inputs,
WorkflowNodeExecution.outputs,
WorkflowNodeExecution.created_at,
WorkflowNodeExecution.elapsed_time,
WorkflowNodeExecution.process_data,
WorkflowNodeExecution.execution_metadata,
)
.filter(WorkflowNodeExecution.workflow_run_id == trace_info.workflow_run_id)
.all()
)
@@ -100,7 +100,20 @@ class LangSmithDataTrace(BaseTraceInstance):
# through workflow_run_id get all_nodes_execution
workflow_nodes_executions = (
db.session.query(WorkflowNodeExecution)
db.session.query(
WorkflowNodeExecution.id,
WorkflowNodeExecution.tenant_id,
WorkflowNodeExecution.app_id,
WorkflowNodeExecution.title,
WorkflowNodeExecution.node_type,
WorkflowNodeExecution.status,
WorkflowNodeExecution.inputs,
WorkflowNodeExecution.outputs,
WorkflowNodeExecution.created_at,
WorkflowNodeExecution.elapsed_time,
WorkflowNodeExecution.process_data,
WorkflowNodeExecution.execution_metadata,
)
.filter(WorkflowNodeExecution.workflow_run_id == trace_info.workflow_run_id)
.all()
)
+15 -16
View File
@@ -140,11 +140,10 @@ class NotionExtractor(BaseExtractor):
def _get_notion_block_data(self, page_id: str) -> list[str]:
result_lines_arr = []
cur_block_id = page_id
start_cursor = None
block_url = BLOCK_CHILD_URL_TMPL.format(block_id=page_id)
while True:
block_url = BLOCK_CHILD_URL_TMPL.format(block_id=cur_block_id)
query_dict: dict[str, Any] = {}
query_dict: dict[str, Any] = {} if not start_cursor else {'start_cursor': start_cursor}
res = requests.request(
"GET",
block_url,
@@ -153,7 +152,7 @@ class NotionExtractor(BaseExtractor):
"Content-Type": "application/json",
"Notion-Version": "2022-06-28",
},
json=query_dict
params=query_dict
)
data = res.json()
for result in data["results"]:
@@ -191,16 +190,16 @@ class NotionExtractor(BaseExtractor):
if data["next_cursor"] is None:
break
else:
cur_block_id = data["next_cursor"]
start_cursor = data["next_cursor"]
return result_lines_arr
def _read_block(self, block_id: str, num_tabs: int = 0) -> str:
"""Read a block."""
result_lines_arr = []
cur_block_id = block_id
start_cursor = None
block_url = BLOCK_CHILD_URL_TMPL.format(block_id=block_id)
while True:
block_url = BLOCK_CHILD_URL_TMPL.format(block_id=cur_block_id)
query_dict: dict[str, Any] = {}
query_dict: dict[str, Any] = {} if not start_cursor else {'start_cursor': start_cursor}
res = requests.request(
"GET",
@@ -210,7 +209,7 @@ class NotionExtractor(BaseExtractor):
"Content-Type": "application/json",
"Notion-Version": "2022-06-28",
},
json=query_dict
params=query_dict
)
data = res.json()
if 'results' not in data or data["results"] is None:
@@ -249,7 +248,7 @@ class NotionExtractor(BaseExtractor):
if data["next_cursor"] is None:
break
else:
cur_block_id = data["next_cursor"]
start_cursor = data["next_cursor"]
result_lines = "\n".join(result_lines_arr)
return result_lines
@@ -258,10 +257,10 @@ class NotionExtractor(BaseExtractor):
"""Read table rows."""
done = False
result_lines_arr = []
cur_block_id = block_id
start_cursor = None
block_url = BLOCK_CHILD_URL_TMPL.format(block_id=block_id)
while not done:
block_url = BLOCK_CHILD_URL_TMPL.format(block_id=cur_block_id)
query_dict: dict[str, Any] = {}
query_dict: dict[str, Any] = {} if not start_cursor else {'start_cursor': start_cursor}
res = requests.request(
"GET",
@@ -271,7 +270,7 @@ class NotionExtractor(BaseExtractor):
"Content-Type": "application/json",
"Notion-Version": "2022-06-28",
},
json=query_dict
params=query_dict
)
data = res.json()
# get table headers text
@@ -300,7 +299,7 @@ class NotionExtractor(BaseExtractor):
done = True
break
else:
cur_block_id = data["next_cursor"]
start_cursor = data["next_cursor"]
result_lines = "\n".join(result_lines_arr)
return result_lines
+28 -18
View File
@@ -5,6 +5,7 @@ Here, we will use GoogleSearch as an example to demonstrate how to quickly integ
## 1. Prepare the Tool Provider yaml
### Introduction
This yaml declares a new tool provider, and includes information like the provider's name, icon, author, and other details that are fetched by the frontend for display.
### Example
@@ -28,9 +29,11 @@ identity: # Basic information of the tool provider
- search
```
- The `identity` field is mandatory, it contains the basic information of the tool provider, including author, name, label, description, icon, etc.
- The icon needs to be placed in the `_assets` folder of the current module, you can refer to [here](../../provider/builtin/google/_assets/icon.svg).
- The `tags` field is optional, it is used to classify the provider, and the frontend can filter the provider according to the tag, for all tags, they have been listed below:
- The `identity` field is mandatory, it contains the basic information of the tool provider, including author, name, label, description, icon, etc.
- The icon needs to be placed in the `_assets` folder of the current module, you can refer to [here](../../provider/builtin/google/_assets/icon.svg).
- The `tags` field is optional, it is used to classify the provider, and the frontend can filter the provider according to the tag, for all tags, they have been listed below:
```python
class ToolLabelEnum(Enum):
SEARCH = 'search'
@@ -56,6 +59,7 @@ identity: # Basic information of the tool provider
Google, as a third-party tool, uses the API provided by SerpApi, which requires an API Key to use. This means that this tool needs a credential to use. For tools like `wikipedia`, there is no need to fill in the credential field, you can refer to [here](../../provider/builtin/wikipedia/wikipedia.yaml).
After configuring the credential field, the effect is as follows:
```yaml
identity:
author: Dify
@@ -87,6 +91,7 @@ credentials_for_provider: # Credential field
- `type`: Credential field type, currently can be either `secret-input`, `text-input`, or `select` , corresponding to password input box, text input box, and drop-down box, respectively. If set to `secret-input`, it will mask the input content on the frontend, and the backend will encrypt the input content.
## 3. Prepare Tool yaml
A provider can have multiple tools, each tool needs a yaml file to describe, this file contains the basic information, parameters, output, etc. of the tool.
Still taking GoogleSearch as an example, we need to create a `tools` module under the `google` module, and create `tools/google_search.yaml`, the content is as follows.
@@ -140,21 +145,22 @@ parameters: # Parameter list
- The `identity` field is mandatory, it contains the basic information of the tool, including name, author, label, description, etc.
- `parameters` Parameter list
- `name` Parameter name, unique, no duplication with other parameters
- `type` Parameter type, currently supports `string`, `number`, `boolean`, `select`, `secret-input` four types, corresponding to string, number, boolean, drop-down box, and encrypted input box, respectively. For sensitive information, we recommend using `secret-input` type
- `required` Required or not
- In `llm` mode, if the parameter is required, the Agent is required to infer this parameter
- In `form` mode, if the parameter is required, the user is required to fill in this parameter on the frontend before the conversation starts
- `options` Parameter options
- In `llm` mode, Dify will pass all options to LLM, LLM can infer based on these options
- In `form` mode, when `type` is `select`, the frontend will display these options
- `default` Default value
- `label` Parameter label, for frontend display
- `human_description` Introduction for frontend display, supports multiple languages
- `llm_description` Introduction passed to LLM, in order to make LLM better understand this parameter, we suggest to write as detailed information about this parameter as possible here, so that LLM can understand this parameter
- `form` Form type, currently supports `llm`, `form` two types, corresponding to Agent self-inference and frontend filling
- `name` Parameter name, unique, no duplication with other parameters
- `type` Parameter type, currently supports `string`, `number`, `boolean`, `select`, `secret-input` four types, corresponding to string, number, boolean, drop-down box, and encrypted input box, respectively. For sensitive information, we recommend using `secret-input` type
- `required` Required or not
- In `llm` mode, if the parameter is required, the Agent is required to infer this parameter
- In `form` mode, if the parameter is required, the user is required to fill in this parameter on the frontend before the conversation starts
- `options` Parameter options
- In `llm` mode, Dify will pass all options to LLM, LLM can infer based on these options
- In `form` mode, when `type` is `select`, the frontend will display these options
- `default` Default value
- `label` Parameter label, for frontend display
- `human_description` Introduction for frontend display, supports multiple languages
- `llm_description` Introduction passed to LLM, in order to make LLM better understand this parameter, we suggest to write as detailed information about this parameter as possible here, so that LLM can understand this parameter
- `form` Form type, currently supports `llm`, `form` two types, corresponding to Agent self-inference and frontend filling
## 4. Add Tool Logic
After completing the tool configuration, we can start writing the tool code that defines how it is invoked.
Create `google_search.py` under the `google/tools` module, the content is as follows.
@@ -176,7 +182,7 @@ class GoogleSearchTool(BuiltinTool):
query = tool_parameters['query']
result_type = tool_parameters['result_type']
api_key = self.runtime.credentials['serpapi_api_key']
# TODO: search with serpapi
# Search with serpapi
result = SerpAPI(api_key).run(query, result_type=result_type)
if result_type == 'text':
@@ -185,12 +191,15 @@ class GoogleSearchTool(BuiltinTool):
```
### Parameters
The overall logic of the tool is in the `_invoke` method, this method accepts two parameters: `user_id` and `tool_parameters`, which represent the user ID and tool parameters respectively
### Return Data
When the tool returns, you can choose to return one message or multiple messages, here we return one message, using `create_text_message` and `create_link_message` can create a text message or a link message.
## 5. Add Provider Code
Finally, we need to create a provider class under the provider module to implement the provider's credential verification logic. If the credential verification fails, it will throw a `ToolProviderCredentialValidationError` exception.
Create `google.py` under the `google` module, the content is as follows.
@@ -227,8 +236,9 @@ class GoogleProvider(BuiltinToolProviderController):
```
## Completion
After the above steps are completed, we can see this tool on the frontend, and it can be used in the Agent.
Of course, because google_search needs a credential, before using it, you also need to input your credentials on the frontend.
![Alt text](../zh_Hans/images/index/image-2.png)
![Alt text](../zh_Hans/images/index/image-2.png)
@@ -31,6 +31,12 @@ parameters:
- value: claude-3-haiku
label:
en_US: Claude 3
- value: llama-3-70b
label:
en_US: Llama 3
- value: mixtral-8x7b
label:
en_US: Mixtral
default: gpt-3.5
label:
en_US: Choose Model
@@ -6,7 +6,7 @@ identity:
zh_CN: Firecrawl
description:
en_US: Firecrawl API integration for web crawling and scraping.
zh_CN: Firecrawl API 集成,用于网页爬取和数据抓取。
zh_Hans: Firecrawl API 集成,用于网页爬取和数据抓取。
icon: icon.svg
tags:
- search
@@ -17,20 +17,22 @@ credentials_for_provider:
required: true
label:
en_US: Firecrawl API Key
zh_CN: Firecrawl API 密钥
zh_Hans: Firecrawl API 密钥
placeholder:
en_US: Please input your Firecrawl API key
zh_CN: 请输入您的 Firecrawl API 密钥
zh_Hans: 请输入您的 Firecrawl API 密钥,如果是自托管版本,可以随意填写密钥
help:
en_US: Get your Firecrawl API key from your Firecrawl account settings.
zh_CN: 从您的 Firecrawl 账户设置中获取 Firecrawl API 密钥。
en_US: Get your Firecrawl API key from your Firecrawl account settings.If you are using a self-hosted version, you may enter any key at your convenience.
zh_Hans: 从您的 Firecrawl 账户设置中获取 Firecrawl API 密钥。如果是自托管版本,可以随意填写密钥。
url: https://www.firecrawl.dev/account
base_url:
type: text-input
required: false
label:
en_US: Firecrawl server's Base URL
zh_Hans: Firecrawl服务器的API URL
pt_BR: Firecrawl server's Base URL
placeholder:
en_US: https://www.firecrawl.dev
zh_HansL: https://www.firecrawl.dev
pt_BR: https://www.firecrawl.dev
@@ -1,98 +1,93 @@
import time
from collections.abc import Mapping
from typing import Any
import requests
from requests.exceptions import HTTPError
class FirecrawlApp:
def __init__(self, api_key=None, base_url=None):
def __init__(self, api_key: str | None = None, base_url: str | None = None):
self.api_key = api_key
self.base_url = base_url or 'https://api.firecrawl.dev'
if self.api_key is None and self.base_url == 'https://api.firecrawl.dev':
raise ValueError('No API key provided')
if not self.api_key:
raise ValueError("API key is required")
def scrape_url(self, url, params=None) -> dict:
def _prepare_headers(self, idempotency_key: str | None = None):
headers = {
'Content-Type': 'application/json',
'Authorization': f'Bearer {self.api_key}'
}
json_data = {'url': url}
if params:
json_data.update(params)
response = requests.post(
f'{self.base_url}/v0/scrape',
headers=headers,
json=json_data
)
if response.status_code == 200:
response = response.json()
if response['success'] == True:
return response['data']
else:
raise Exception(f'Failed to scrape URL. Error: {response["error"]}')
if idempotency_key:
headers['Idempotency-Key'] = idempotency_key
return headers
elif response.status_code in [402, 409, 500]:
error_message = response.json().get('error', 'Unknown error occurred')
raise Exception(f'Failed to scrape URL. Status code: {response.status_code}. Error: {error_message}')
else:
raise Exception(f'Failed to scrape URL. Status code: {response.status_code}')
def crawl_url(self, url, params=None, wait_until_done=True, timeout=2) -> str:
headers = self._prepare_headers()
json_data = {'url': url}
if params:
json_data.update(params)
response = self._post_request(f'{self.base_url}/v0/crawl', json_data, headers)
if response.status_code == 200:
job_id = response.json().get('jobId')
if wait_until_done:
return self._monitor_job_status(job_id, headers, timeout)
else:
return {'jobId': job_id}
else:
self._handle_error(response, 'start crawl job')
def check_crawl_status(self, job_id) -> dict:
headers = self._prepare_headers()
response = self._get_request(f'{self.base_url}/v0/crawl/status/{job_id}', headers)
if response.status_code == 200:
return response.json()
else:
self._handle_error(response, 'check crawl status')
def _prepare_headers(self):
return {
'Content-Type': 'application/json',
'Authorization': f'Bearer {self.api_key}'
}
def _post_request(self, url, data, headers):
return requests.post(url, headers=headers, json=data)
def _get_request(self, url, headers):
return requests.get(url, headers=headers)
def _monitor_job_status(self, job_id, headers, timeout):
while True:
status_response = self._get_request(f'{self.base_url}/v0/crawl/status/{job_id}', headers)
if status_response.status_code == 200:
status_data = status_response.json()
if status_data['status'] == 'completed':
if 'data' in status_data:
return status_data['data']
else:
raise Exception('Crawl job completed but no data was returned')
elif status_data['status'] in ['active', 'paused', 'pending', 'queued']:
if timeout < 2:
timeout = 2
time.sleep(timeout) # Wait for the specified timeout before checking again
def _request(
self,
method: str,
url: str,
data: Mapping[str, Any] | None = None,
headers: Mapping[str, str] | None = None,
retries: int = 3,
backoff_factor: float = 0.3,
) -> Mapping[str, Any] | None:
for i in range(retries):
try:
response = requests.request(method, url, json=data, headers=headers)
response.raise_for_status()
return response.json()
except requests.exceptions.RequestException as e:
if i < retries - 1:
time.sleep(backoff_factor * (2 ** i))
else:
raise Exception(f'Crawl job failed or was stopped. Status: {status_data["status"]}')
else:
self._handle_error(status_response, 'check crawl status')
raise
return None
def _handle_error(self, response, action):
if response.status_code in [402, 409, 500]:
error_message = response.json().get('error', 'Unknown error occurred')
raise Exception(f'Failed to {action}. Status code: {response.status_code}. Error: {error_message}')
else:
raise Exception(f'Unexpected error occurred while trying to {action}. Status code: {response.status_code}')
def scrape_url(self, url: str, **kwargs):
endpoint = f'{self.base_url}/v0/scrape'
headers = self._prepare_headers()
data = {'url': url, **kwargs}
response = self._request('POST', endpoint, data, headers)
if response is None:
raise HTTPError("Failed to scrape URL after multiple retries")
return response
def search(self, query: str, **kwargs):
endpoint = f'{self.base_url}/v0/search'
headers = self._prepare_headers()
data = {'query': query, **kwargs}
response = self._request('POST', endpoint, data, headers)
if response is None:
raise HTTPError("Failed to perform search after multiple retries")
return response
def crawl_url(
self, url: str, wait: bool = False, poll_interval: int = 5, idempotency_key: str | None = None, **kwargs
):
endpoint = f'{self.base_url}/v0/crawl'
headers = self._prepare_headers(idempotency_key)
data = {'url': url, **kwargs}
response = self._request('POST', endpoint, data, headers)
if response is None:
raise HTTPError("Failed to initiate crawl after multiple retries")
job_id: str = response['jobId']
if wait:
return self._monitor_job_status(job_id=job_id, poll_interval=poll_interval)
return job_id
def check_crawl_status(self, job_id: str):
endpoint = f'{self.base_url}/v0/crawl/status/{job_id}'
headers = self._prepare_headers()
response = self._request('GET', endpoint, headers=headers)
if response is None:
raise HTTPError(f"Failed to check status for job {job_id} after multiple retries")
return response
def _monitor_job_status(self, job_id: str, poll_interval: int):
while True:
status = self.check_crawl_status(job_id)
if status['status'] == 'completed':
return status
elif status['status'] == 'failed':
raise HTTPError(f'Job {job_id} failed: {status["error"]}')
time.sleep(poll_interval)
@@ -1,3 +1,4 @@
import json
from typing import Any, Union
from core.tools.entities.tool_entities import ToolInvokeMessage
@@ -7,7 +8,6 @@ from core.tools.tool.builtin_tool import BuiltinTool
class CrawlTool(BuiltinTool):
def _invoke(self, user_id: str, tool_parameters: dict[str, Any]) -> Union[ToolInvokeMessage, list[ToolInvokeMessage]]:
# initialize the app object with the api key
app = FirecrawlApp(api_key=self.runtime.credentials['firecrawl_api_key'], base_url=self.runtime.credentials['base_url'])
options = {
@@ -21,29 +21,16 @@ class CrawlTool(BuiltinTool):
}
}
# crawl the url
crawl_result = app.crawl_url(
url=tool_parameters['url'],
params=options,
wait_until_done=True,
wait=True
)
# reformat crawl result
crawl_output = "**Crawl Result**\n\n"
try:
for result in crawl_result:
crawl_output += f"**- Title:** {result.get('metadata', {}).get('title', '')}\n"
crawl_output += f"**- Description:** {result.get('metadata', {}).get('description', '')}\n"
crawl_output += f"**- URL:** {result.get('metadata', {}).get('ogUrl', '')}\n\n"
crawl_output += f"**- Web Content:**\n{result.get('markdown', '')}\n\n"
crawl_output += "---\n\n"
except Exception as e:
crawl_output += f"An error occurred: {str(e)}\n"
crawl_output += f"**- Title:** {result.get('metadata', {}).get('title', '')}\n"
crawl_output += f"**- Description:** {result.get('metadata', {}).get('description','')}\n"
crawl_output += f"**- URL:** {result.get('metadata', {}).get('ogUrl', '')}\n\n"
crawl_output += f"**- Web Content:**\n{result.get('markdown', '')}\n\n"
crawl_output += "---\n\n"
if not isinstance(crawl_result, str):
crawl_result = json.dumps(crawl_result, ensure_ascii=False, indent=4)
return self.create_text_message(crawl_output)
if not crawl_result:
return self.create_text_message("Crawl request failed.")
return self.create_text_message(crawl_result)
@@ -0,0 +1,26 @@
import json
from typing import Any, Union
from core.tools.entities.tool_entities import ToolInvokeMessage
from core.tools.provider.builtin.firecrawl.firecrawl_appx import FirecrawlApp
from core.tools.tool.builtin_tool import BuiltinTool
class ScrapeTool(BuiltinTool):
def _invoke(self, user_id: str, tool_parameters: dict[str, Any]) -> Union[ToolInvokeMessage, list[ToolInvokeMessage]]:
app = FirecrawlApp(api_key=self.runtime.credentials['firecrawl_api_key'], base_url=self.runtime.credentials['base_url'])
crawl_result = app.scrape_url(
url=tool_parameters['url'],
wait=True
)
if isinstance(crawl_result, dict):
result_message = json.dumps(crawl_result, ensure_ascii=False, indent=4)
else:
result_message = str(crawl_result)
if not crawl_result:
return self.create_text_message("Scrape request failed.")
return self.create_text_message(result_message)
@@ -0,0 +1,23 @@
identity:
name: scrape
author: ahasasjeb
label:
en_US: Scrape
zh_Hans: 抓取
description:
human:
en_US: Extract data from a single URL.
zh_Hans: 从单个URL抓取数据。
llm: This tool is designed to scrape URL and output the content in Markdown format.
parameters:
- name: url
type: string
required: true
label:
en_US: URL to scrape
zh_Hans: 要抓取的URL
human_description:
en_US: The URL of the website to scrape and extract data from.
zh_Hans: 要抓取并提取数据的网站URL。
llm_description: The URL of the website that needs to be crawled. This is a required parameter.
form: llm
@@ -0,0 +1,26 @@
import json
from typing import Any, Union
from core.tools.entities.tool_entities import ToolInvokeMessage
from core.tools.provider.builtin.firecrawl.firecrawl_appx import FirecrawlApp
from core.tools.tool.builtin_tool import BuiltinTool
class SearchTool(BuiltinTool):
def _invoke(self, user_id: str, tool_parameters: dict[str, Any]) -> Union[ToolInvokeMessage, list[ToolInvokeMessage]]:
app = FirecrawlApp(api_key=self.runtime.credentials['firecrawl_api_key'], base_url=self.runtime.credentials['base_url'])
crawl_result = app.search(
query=tool_parameters['keyword'],
wait=True
)
if isinstance(crawl_result, dict):
result_message = json.dumps(crawl_result, ensure_ascii=False, indent=4)
else:
result_message = str(crawl_result)
if not crawl_result:
return self.create_text_message("Search request failed.")
return self.create_text_message(result_message)
@@ -0,0 +1,23 @@
identity:
name: search
author: ahasasjeb
label:
en_US: Search
zh_Hans: 搜索
description:
human:
en_US: Search, and output in Markdown format
zh_Hans: 搜索,并且以Markdown格式输出
llm: This tool can perform online searches and convert the results to Markdown format.
parameters:
- name: keyword
type: string
required: true
label:
en_US: keyword
zh_Hans: 关键词
human_description:
en_US: Input keywords to use Firecrawl API for search.
zh_Hans: 输入关键词即可使用Firecrawl API进行搜索。
llm_description: Efficiently extract keywords from user text.
form: llm
@@ -1,3 +1,4 @@
import json
from typing import Any, Union
from yarl import URL
@@ -26,6 +27,15 @@ class JinaReaderTool(BuiltinTool):
if 'api_key' in self.runtime.credentials and self.runtime.credentials.get('api_key'):
headers['Authorization'] = "Bearer " + self.runtime.credentials.get('api_key')
request_params = tool_parameters.get('request_params')
if request_params is not None and request_params != '':
try:
request_params = json.loads(request_params)
if not isinstance(request_params, dict):
raise ValueError("request_params must be a JSON object")
except (json.JSONDecodeError, ValueError) as e:
raise ValueError(f"Invalid request_params: {e}")
target_selector = tool_parameters.get('target_selector')
if target_selector is not None and target_selector != '':
headers['X-Target-Selector'] = target_selector
@@ -53,7 +63,8 @@ class JinaReaderTool(BuiltinTool):
response = ssrf_proxy.get(
str(URL(self._jina_reader_endpoint + url)),
headers=headers,
timeout=(10, 60)
params=request_params,
timeout=(10, 60),
)
if tool_parameters.get('summary', False):
@@ -25,6 +25,22 @@ parameters:
pt_BR: used for linking to webpages
llm_description: url for scraping
form: llm
- name: request_params
type: string
required: false
label:
en_US: Request params
zh_Hans: 请求参数
pt_BR: Request params
human_description:
en_US: |
request parameters, format: {"key1": "value1", "key2": "value2"}
zh_Hans: |
请求参数,格式:{"key1": "value1", "key2": "value2"}
pt_BR: |
request parameters, format: {"key1": "value1", "key2": "value2"}
llm_description: request parameters
form: llm
- name: target_selector
type: string
required: false
@@ -20,7 +20,7 @@ class SlackWebhookTool(BuiltinTool):
webhook_url = tool_parameters.get('webhook_url', '')
if not webhook_url.startswith('https://hooks.slack.com/services/'):
if not webhook_url.startswith('https://hooks.slack.com/'):
return self.create_text_message(
f'Invalid parameter webhook_url ${webhook_url}, not a valid Slack webhook URL')
+4 -1
View File
@@ -249,9 +249,12 @@ class ApiTool(Tool):
elif property['type'] == 'null':
if value is None:
return None
elif property['type'] == 'object':
elif property['type'] == 'object' or property['type'] == 'array':
if isinstance(value, str):
try:
# an array str like '[1,2]' also can convert to list [1,2] through json.loads
# json not support single quote, but we can support it
value = value.replace("'", '"')
return json.loads(value)
except ValueError:
return value
+6 -6
View File
@@ -154,7 +154,7 @@ class ToolManager:
'invoke_from': invoke_from,
'tool_invoke_from': tool_invoke_from,
})
elif provider_type == 'api':
if tenant_id is None:
raise ValueError('tenant id is required for api provider')
@@ -201,7 +201,7 @@ class ToolManager:
init runtime parameter
"""
parameter_value = parameters.get(parameter_rule.name)
if not parameter_value:
if not parameter_value and parameter_value != 0:
# get default value
parameter_value = parameter_rule.default
if not parameter_value and parameter_rule.required:
@@ -321,14 +321,14 @@ class ToolManager:
if cls._builtin_providers_loaded:
yield from list(cls._builtin_providers.values())
return
with cls._builtin_provider_lock:
if cls._builtin_providers_loaded:
yield from list(cls._builtin_providers.values())
return
yield from cls._list_builtin_providers()
@classmethod
def _list_builtin_providers(cls) -> Generator[BuiltinToolProviderController, None, None]:
"""
@@ -492,7 +492,7 @@ class ToolManager:
controller = ApiToolProviderController.from_db(
provider,
ApiProviderAuthType.API_KEY if provider.credentials['auth_type'] == 'api_key' else
ApiProviderAuthType.API_KEY if provider.credentials['auth_type'] == 'api_key' else
ApiProviderAuthType.NONE
)
controller.load_bundled_tools(provider.tools)
+7 -7
View File
@@ -1,6 +1,6 @@
import os
from typing import Optional, Union, cast
from configs import dify_config
from core.helper.code_executor.code_executor import CodeExecutionException, CodeExecutor, CodeLanguage
from core.helper.code_executor.code_node_provider import CodeNodeProvider
from core.helper.code_executor.javascript.javascript_code_provider import JavascriptCodeProvider
@@ -11,14 +11,14 @@ from core.workflow.nodes.base_node import BaseNode
from core.workflow.nodes.code.entities import CodeNodeData
from models.workflow import WorkflowNodeExecutionStatus
MAX_NUMBER = int(os.environ.get('CODE_MAX_NUMBER', '9223372036854775807'))
MIN_NUMBER = int(os.environ.get('CODE_MIN_NUMBER', '-9223372036854775808'))
MAX_NUMBER = dify_config.CODE_MAX_NUMBER
MIN_NUMBER = dify_config.CODE_MIN_NUMBER
MAX_PRECISION = 20
MAX_DEPTH = 5
MAX_STRING_LENGTH = int(os.environ.get('CODE_MAX_STRING_LENGTH', '80000'))
MAX_STRING_ARRAY_LENGTH = int(os.environ.get('CODE_MAX_STRING_ARRAY_LENGTH', '30'))
MAX_OBJECT_ARRAY_LENGTH = int(os.environ.get('CODE_MAX_OBJECT_ARRAY_LENGTH', '30'))
MAX_NUMBER_ARRAY_LENGTH = int(os.environ.get('CODE_MAX_NUMBER_ARRAY_LENGTH', '1000'))
MAX_STRING_LENGTH = dify_config.CODE_MAX_STRING_LENGTH
MAX_STRING_ARRAY_LENGTH = dify_config.CODE_MAX_STRING_ARRAY_LENGTH
MAX_OBJECT_ARRAY_LENGTH = dify_config.CODE_MAX_OBJECT_ARRAY_LENGTH
MAX_NUMBER_ARRAY_LENGTH = dify_config.CODE_MAX_NUMBER_ARRAY_LENGTH
class CodeNode(BaseNode):
@@ -1,57 +1,61 @@
import os
from typing import Literal, Optional, Union
from pydantic import BaseModel, ValidationInfo, field_validator
from configs import dify_config
from core.workflow.entities.base_node_data_entities import BaseNodeData
MAX_CONNECT_TIMEOUT = int(os.environ.get('HTTP_REQUEST_MAX_CONNECT_TIMEOUT', '300'))
MAX_READ_TIMEOUT = int(os.environ.get('HTTP_REQUEST_MAX_READ_TIMEOUT', '600'))
MAX_WRITE_TIMEOUT = int(os.environ.get('HTTP_REQUEST_MAX_WRITE_TIMEOUT', '600'))
MAX_CONNECT_TIMEOUT = dify_config.HTTP_REQUEST_MAX_CONNECT_TIMEOUT
MAX_READ_TIMEOUT = dify_config.HTTP_REQUEST_MAX_READ_TIMEOUT
MAX_WRITE_TIMEOUT = dify_config.HTTP_REQUEST_MAX_WRITE_TIMEOUT
class HttpRequestNodeAuthorizationConfig(BaseModel):
type: Literal[None, 'basic', 'bearer', 'custom']
api_key: Union[None, str] = None
header: Union[None, str] = None
class HttpRequestNodeAuthorization(BaseModel):
type: Literal['no-auth', 'api-key']
config: Optional[HttpRequestNodeAuthorizationConfig] = None
@field_validator('config', mode='before')
@classmethod
def check_config(cls, v: HttpRequestNodeAuthorizationConfig, values: ValidationInfo):
"""
Check config, if type is no-auth, config should be None, otherwise it should be a dict.
"""
if values.data['type'] == 'no-auth':
return None
else:
if not v or not isinstance(v, dict):
raise ValueError('config should be a dict')
return v
class HttpRequestNodeBody(BaseModel):
type: Literal['none', 'form-data', 'x-www-form-urlencoded', 'raw-text', 'json']
data: Union[None, str] = None
class HttpRequestNodeTimeout(BaseModel):
connect: int = MAX_CONNECT_TIMEOUT
read: int = MAX_READ_TIMEOUT
write: int = MAX_WRITE_TIMEOUT
class HttpRequestNodeData(BaseNodeData):
"""
Code Node Data.
"""
class Authorization(BaseModel):
# TODO[pydantic]: The `Config` class inherits from another class, please create the `model_config` manually.
# Check https://docs.pydantic.dev/dev-v2/migration/#changes-to-config for more information.
class Config(BaseModel):
type: Literal[None, 'basic', 'bearer', 'custom']
api_key: Union[None, str] = None
header: Union[None, str] = None
type: Literal['no-auth', 'api-key']
config: Optional[Config] = None
@field_validator('config', mode='before')
@classmethod
def check_config(cls, v: Config, values: ValidationInfo):
"""
Check config, if type is no-auth, config should be None, otherwise it should be a dict.
"""
if values.data['type'] == 'no-auth':
return None
else:
if not v or not isinstance(v, dict):
raise ValueError('config should be a dict')
return v
class Body(BaseModel):
type: Literal['none', 'form-data', 'x-www-form-urlencoded', 'raw-text', 'json']
data: Union[None, str] = None
class Timeout(BaseModel):
connect: Optional[int] = MAX_CONNECT_TIMEOUT
read: Optional[int] = MAX_READ_TIMEOUT
write: Optional[int] = MAX_WRITE_TIMEOUT
method: Literal['get', 'post', 'put', 'patch', 'delete', 'head']
url: str
authorization: Authorization
authorization: HttpRequestNodeAuthorization
headers: str
params: str
body: Optional[Body] = None
timeout: Optional[Timeout] = None
body: Optional[HttpRequestNodeBody] = None
timeout: Optional[HttpRequestNodeTimeout] = None
mask_authorization_header: Optional[bool] = True
@@ -1,5 +1,4 @@
import json
import os
from copy import deepcopy
from random import randint
from typing import Any, Optional, Union
@@ -8,22 +7,28 @@ from urllib.parse import urlencode
import httpx
import core.helper.ssrf_proxy as ssrf_proxy
from configs import dify_config
from core.workflow.entities.variable_entities import VariableSelector
from core.workflow.entities.variable_pool import ValueType, VariablePool
from core.workflow.nodes.http_request.entities import HttpRequestNodeData
from core.workflow.nodes.http_request.entities import (
HttpRequestNodeAuthorization,
HttpRequestNodeBody,
HttpRequestNodeData,
HttpRequestNodeTimeout,
)
from core.workflow.utils.variable_template_parser import VariableTemplateParser
MAX_BINARY_SIZE = int(os.environ.get('HTTP_REQUEST_NODE_MAX_BINARY_SIZE', 1024 * 1024 * 10)) # 10MB
READABLE_MAX_BINARY_SIZE = f'{MAX_BINARY_SIZE / 1024 / 1024:.2f}MB'
MAX_TEXT_SIZE = int(os.environ.get('HTTP_REQUEST_NODE_MAX_TEXT_SIZE', 1024 * 1024)) # 1MB
READABLE_MAX_TEXT_SIZE = f'{MAX_TEXT_SIZE / 1024 / 1024:.2f}MB'
MAX_BINARY_SIZE = dify_config.HTTP_REQUEST_NODE_MAX_BINARY_SIZE
READABLE_MAX_BINARY_SIZE = dify_config.HTTP_REQUEST_NODE_READABLE_MAX_BINARY_SIZE
MAX_TEXT_SIZE = dify_config.HTTP_REQUEST_NODE_MAX_TEXT_SIZE
READABLE_MAX_TEXT_SIZE = dify_config.HTTP_REQUEST_NODE_READABLE_MAX_TEXT_SIZE
class HttpExecutorResponse:
headers: dict[str, str]
response: httpx.Response
def __init__(self, response: httpx.Response = None):
def __init__(self, response: httpx.Response):
self.response = response
self.headers = dict(response.headers) if isinstance(self.response, httpx.Response) else {}
@@ -40,7 +45,6 @@ class HttpExecutorResponse:
def get_content_type(self) -> str:
return self.headers.get('content-type', '')
def extract_file(self) -> tuple[str, bytes]:
"""
extract file from response if content type is file related
@@ -88,17 +92,21 @@ class HttpExecutorResponse:
class HttpExecutor:
server_url: str
method: str
authorization: HttpRequestNodeData.Authorization
authorization: HttpRequestNodeAuthorization
params: dict[str, Any]
headers: dict[str, Any]
body: Union[None, str]
files: Union[None, dict[str, Any]]
boundary: str
variable_selectors: list[VariableSelector]
timeout: HttpRequestNodeData.Timeout
timeout: HttpRequestNodeTimeout
def __init__(self, node_data: HttpRequestNodeData, timeout: HttpRequestNodeData.Timeout,
variable_pool: Optional[VariablePool] = None):
def __init__(
self,
node_data: HttpRequestNodeData,
timeout: HttpRequestNodeTimeout,
variable_pool: Optional[VariablePool] = None,
):
self.server_url = node_data.url
self.method = node_data.method
self.authorization = node_data.authorization
@@ -113,11 +121,11 @@ class HttpExecutor:
self._init_template(node_data, variable_pool)
@staticmethod
def _is_json_body(body: HttpRequestNodeData.Body):
def _is_json_body(body: HttpRequestNodeBody):
"""
check if body is json
"""
if body and body.type == 'json':
if body and body.type == 'json' and body.data:
try:
json.loads(body.data)
return True
@@ -146,7 +154,6 @@ class HttpExecutor:
return result
def _init_template(self, node_data: HttpRequestNodeData, variable_pool: Optional[VariablePool] = None):
# extract all template in url
self.server_url, server_url_variable_selectors = self._format_template(node_data.url, variable_pool)
@@ -178,9 +185,7 @@ class HttpExecutor:
body = self._to_dict(body_data)
if node_data.body.type == 'form-data':
self.files = {
k: ('', v) for k, v in body.items()
}
self.files = {k: ('', v) for k, v in body.items()}
random_str = lambda n: ''.join([chr(randint(97, 122)) for _ in range(n)])
self.boundary = f'----WebKitFormBoundary{random_str(16)}'
@@ -192,13 +197,24 @@ class HttpExecutor:
elif node_data.body.type == 'none':
self.body = ''
self.variable_selectors = (server_url_variable_selectors + params_variable_selectors
+ headers_variable_selectors + body_data_variable_selectors)
self.variable_selectors = (
server_url_variable_selectors
+ params_variable_selectors
+ headers_variable_selectors
+ body_data_variable_selectors
)
def _assembling_headers(self) -> dict[str, Any]:
authorization = deepcopy(self.authorization)
headers = deepcopy(self.headers) or {}
if self.authorization.type == 'api-key':
if self.authorization.config is None:
raise ValueError('self.authorization config is required')
if authorization.config is None:
raise ValueError('authorization config is required')
if authorization.config.type != 'bearer' and authorization.config.header is None:
raise ValueError('authorization config header is required')
if self.authorization.config.api_key is None:
raise ValueError('api_key is required')
@@ -216,7 +232,7 @@ class HttpExecutor:
def _validate_and_parse_response(self, response: httpx.Response) -> HttpExecutorResponse:
"""
validate the response
validate the response
"""
if isinstance(response, httpx.Response):
executor_response = HttpExecutorResponse(response)
@@ -226,24 +242,26 @@ class HttpExecutor:
if executor_response.is_file:
if executor_response.size > MAX_BINARY_SIZE:
raise ValueError(
f'File size is too large, max size is {READABLE_MAX_BINARY_SIZE}, but current size is {executor_response.readable_size}.')
f'File size is too large, max size is {READABLE_MAX_BINARY_SIZE}, but current size is {executor_response.readable_size}.'
)
else:
if executor_response.size > MAX_TEXT_SIZE:
raise ValueError(
f'Text size is too large, max size is {READABLE_MAX_TEXT_SIZE}, but current size is {executor_response.readable_size}.')
f'Text size is too large, max size is {READABLE_MAX_TEXT_SIZE}, but current size is {executor_response.readable_size}.'
)
return executor_response
def _do_http_request(self, headers: dict[str, Any]) -> httpx.Response:
"""
do http request depending on api bundle
do http request depending on api bundle
"""
kwargs = {
'url': self.server_url,
'headers': headers,
'params': self.params,
'timeout': (self.timeout.connect, self.timeout.read, self.timeout.write),
'follow_redirects': True
'follow_redirects': True,
}
if self.method in ('get', 'head', 'post', 'put', 'delete', 'patch'):
@@ -306,8 +324,9 @@ class HttpExecutor:
return raw_request
def _format_template(self, template: str, variable_pool: VariablePool, escape_quotes: bool = False) \
-> tuple[str, list[VariableSelector]]:
def _format_template(
self, template: str, variable_pool: Optional[VariablePool], escape_quotes: bool = False
) -> tuple[str, list[VariableSelector]]:
"""
format template
"""
@@ -318,14 +337,13 @@ class HttpExecutor:
variable_value_mapping = {}
for variable_selector in variable_selectors:
value = variable_pool.get_variable_value(
variable_selector=variable_selector.value_selector,
target_value_type=ValueType.STRING
variable_selector=variable_selector.value_selector, target_value_type=ValueType.STRING
)
if value is None:
raise ValueError(f'Variable {variable_selector.variable} not found')
if escape_quotes:
if escape_quotes and isinstance(value, str):
value = value.replace('"', '\\"')
variable_value_mapping[variable_selector.variable] = value
@@ -5,6 +5,7 @@ from typing import cast
from core.file.file_obj import FileTransferMethod, FileType, FileVar
from core.tools.tool_file_manager import ToolFileManager
from core.workflow.entities.base_node_data_entities import BaseNodeData
from core.workflow.entities.node_entities import NodeRunResult, NodeType
from core.workflow.entities.variable_pool import VariablePool
from core.workflow.nodes.base_node import BaseNode
@@ -13,49 +14,50 @@ from core.workflow.nodes.http_request.entities import (
MAX_READ_TIMEOUT,
MAX_WRITE_TIMEOUT,
HttpRequestNodeData,
HttpRequestNodeTimeout,
)
from core.workflow.nodes.http_request.http_executor import HttpExecutor, HttpExecutorResponse
from models.workflow import WorkflowNodeExecutionStatus
HTTP_REQUEST_DEFAULT_TIMEOUT = HttpRequestNodeData.Timeout(connect=min(10, MAX_CONNECT_TIMEOUT),
read=min(60, MAX_READ_TIMEOUT),
write=min(20, MAX_WRITE_TIMEOUT))
HTTP_REQUEST_DEFAULT_TIMEOUT = HttpRequestNodeTimeout(
connect=min(10, MAX_CONNECT_TIMEOUT),
read=min(60, MAX_READ_TIMEOUT),
write=min(20, MAX_WRITE_TIMEOUT),
)
class HttpRequestNode(BaseNode):
_node_data_cls = HttpRequestNodeData
node_type = NodeType.HTTP_REQUEST
_node_type = NodeType.HTTP_REQUEST
@classmethod
def get_default_config(cls) -> dict:
def get_default_config(cls, filters: dict | None = None) -> dict:
return {
"type": "http-request",
"config": {
"method": "get",
"authorization": {
"type": "no-auth",
'type': 'http-request',
'config': {
'method': 'get',
'authorization': {
'type': 'no-auth',
},
"body": {
"type": "none"
},
"timeout": {
'body': {'type': 'none'},
'timeout': {
**HTTP_REQUEST_DEFAULT_TIMEOUT.model_dump(),
"max_connect_timeout": MAX_CONNECT_TIMEOUT,
"max_read_timeout": MAX_READ_TIMEOUT,
"max_write_timeout": MAX_WRITE_TIMEOUT,
}
'max_connect_timeout': MAX_CONNECT_TIMEOUT,
'max_read_timeout': MAX_READ_TIMEOUT,
'max_write_timeout': MAX_WRITE_TIMEOUT,
},
},
}
def _run(self, variable_pool: VariablePool) -> NodeRunResult:
node_data: HttpRequestNodeData = cast(self._node_data_cls, self.node_data)
node_data: HttpRequestNodeData = cast(HttpRequestNodeData, self.node_data)
# init http executor
http_executor = None
try:
http_executor = HttpExecutor(node_data=node_data,
timeout=self._get_request_timeout(node_data),
variable_pool=variable_pool)
http_executor = HttpExecutor(
node_data=node_data, timeout=self._get_request_timeout(node_data), variable_pool=variable_pool
)
# invoke http executor
response = http_executor.invoke()
@@ -70,7 +72,7 @@ class HttpRequestNode(BaseNode):
return NodeRunResult(
status=WorkflowNodeExecutionStatus.FAILED,
error=str(e),
process_data=process_data
process_data=process_data,
)
files = self.extract_files(http_executor.server_url, response)
@@ -85,34 +87,32 @@ class HttpRequestNode(BaseNode):
},
process_data={
'request': http_executor.to_raw_request(
mask_authorization_header=node_data.mask_authorization_header
mask_authorization_header=node_data.mask_authorization_header,
),
}
},
)
def _get_request_timeout(self, node_data: HttpRequestNodeData) -> HttpRequestNodeData.Timeout:
def _get_request_timeout(self, node_data: HttpRequestNodeData) -> HttpRequestNodeTimeout:
timeout = node_data.timeout
if timeout is None:
return HTTP_REQUEST_DEFAULT_TIMEOUT
if timeout.connect is None:
timeout.connect = HTTP_REQUEST_DEFAULT_TIMEOUT.connect
timeout.connect = timeout.connect or HTTP_REQUEST_DEFAULT_TIMEOUT.connect
timeout.connect = min(timeout.connect, MAX_CONNECT_TIMEOUT)
if timeout.read is None:
timeout.read = HTTP_REQUEST_DEFAULT_TIMEOUT.read
timeout.read = timeout.read or HTTP_REQUEST_DEFAULT_TIMEOUT.read
timeout.read = min(timeout.read, MAX_READ_TIMEOUT)
if timeout.write is None:
timeout.write = HTTP_REQUEST_DEFAULT_TIMEOUT.write
timeout.write = timeout.write or HTTP_REQUEST_DEFAULT_TIMEOUT.write
timeout.write = min(timeout.write, MAX_WRITE_TIMEOUT)
return timeout
@classmethod
def _extract_variable_selector_to_variable_mapping(cls, node_data: HttpRequestNodeData) -> dict[str, list[str]]:
def _extract_variable_selector_to_variable_mapping(cls, node_data: BaseNodeData) -> dict[str, list[str]]:
"""
Extract variable selector to variable mapping
:param node_data: node data
:return:
"""
node_data = cast(HttpRequestNodeData, node_data)
try:
http_executor = HttpExecutor(node_data=node_data, timeout=HTTP_REQUEST_DEFAULT_TIMEOUT)
@@ -124,7 +124,7 @@ class HttpRequestNode(BaseNode):
return variable_mapping
except Exception as e:
logging.exception(f"Failed to extract variable selector to variable mapping: {e}")
logging.exception(f'Failed to extract variable selector to variable mapping: {e}')
return {}
def extract_files(self, url: str, response: HttpExecutorResponse) -> list[FileVar]:
@@ -144,21 +144,23 @@ class HttpRequestNode(BaseNode):
extension = guess_extension(mimetype) or '.bin'
tool_file = ToolFileManager.create_file_by_raw(
user_id=self.user_id,
tenant_id=self.tenant_id,
conversation_id=None,
file_binary=file_binary,
user_id=self.user_id,
tenant_id=self.tenant_id,
conversation_id=None,
file_binary=file_binary,
mimetype=mimetype,
)
files.append(FileVar(
tenant_id=self.tenant_id,
type=FileType.IMAGE,
transfer_method=FileTransferMethod.TOOL_FILE,
related_id=tool_file.id,
filename=filename,
extension=extension,
mime_type=mimetype,
))
files.append(
FileVar(
tenant_id=self.tenant_id,
type=FileType.IMAGE,
transfer_method=FileTransferMethod.TOOL_FILE,
related_id=tool_file.id,
filename=filename,
extension=extension,
mime_type=mimetype,
)
)
return files
@@ -365,7 +365,7 @@ class ParameterExtractorNode(LLMNode):
files=[],
context='',
memory_config=node_data.memory,
memory=memory,
memory=None,
model_config=model_config
)
+3 -5
View File
@@ -1,5 +1,4 @@
from collections.abc import Generator
from contextlib import closing
from datetime import datetime, timedelta, timezone
from azure.storage.blob import AccountSasPermissions, BlobServiceClient, ResourceTypes, generate_account_sas
@@ -38,10 +37,9 @@ class AzureStorage(BaseStorage):
def generate(filename: str = filename) -> Generator:
blob = client.get_blob_client(container=self.bucket_name, blob=filename)
with closing(blob.download_blob()) as blob_stream:
while chunk := blob_stream.readall():
yield chunk
blob_data = blob.download_blob()
for chunk in blob_data.chunks():
yield from chunk
return generate()
def download(self, filename, target_filepath):
+103 -4
View File
@@ -1,18 +1,23 @@
import json
import logging
import random
import re
import string
import subprocess
import time
import uuid
from collections.abc import Generator
from datetime import datetime
from hashlib import sha256
from typing import Union
from typing import Any, Optional, Union
from zoneinfo import available_timezones
from flask import Response, stream_with_context
from flask import Response, current_app, stream_with_context
from flask_restful import fields
from extensions.ext_redis import redis_client
from models.account import Account
def run(script):
return subprocess.getstatusoutput('source /root/.bashrc && ' + script)
@@ -46,12 +51,12 @@ def uuid_value(value):
error = ('{value} is not a valid uuid.'
.format(value=value))
raise ValueError(error)
def alphanumeric(value: str):
# check if the value is alphanumeric and underlined
if re.match(r'^[a-zA-Z0-9_]+$', value):
return value
raise ValueError(f'{value} is not a valid alphanumeric value')
def timestamp_value(timestamp):
@@ -163,3 +168,97 @@ def compact_generate_response(response: Union[dict, Generator]) -> Response:
return Response(stream_with_context(generate()), status=200,
mimetype='text/event-stream')
class TokenManager:
@classmethod
def generate_token(cls, account: Account, token_type: str, additional_data: dict = None) -> str:
old_token = cls._get_current_token_for_account(account.id, token_type)
if old_token:
if isinstance(old_token, bytes):
old_token = old_token.decode('utf-8')
cls.revoke_token(old_token, token_type)
token = str(uuid.uuid4())
token_data = {
'account_id': account.id,
'email': account.email,
'token_type': token_type
}
if additional_data:
token_data.update(additional_data)
expiry_hours = current_app.config[f'{token_type.upper()}_TOKEN_EXPIRY_HOURS']
token_key = cls._get_token_key(token, token_type)
redis_client.setex(
token_key,
expiry_hours * 60 * 60,
json.dumps(token_data)
)
cls._set_current_token_for_account(account.id, token, token_type, expiry_hours)
return token
@classmethod
def _get_token_key(cls, token: str, token_type: str) -> str:
return f'{token_type}:token:{token}'
@classmethod
def revoke_token(cls, token: str, token_type: str):
token_key = cls._get_token_key(token, token_type)
redis_client.delete(token_key)
@classmethod
def get_token_data(cls, token: str, token_type: str) -> Optional[dict[str, Any]]:
key = cls._get_token_key(token, token_type)
token_data_json = redis_client.get(key)
if token_data_json is None:
logging.warning(f"{token_type} token {token} not found with key {key}")
return None
token_data = json.loads(token_data_json)
return token_data
@classmethod
def _get_current_token_for_account(cls, account_id: str, token_type: str) -> Optional[str]:
key = cls._get_account_token_key(account_id, token_type)
current_token = redis_client.get(key)
return current_token
@classmethod
def _set_current_token_for_account(cls, account_id: str, token: str, token_type: str, expiry_hours: int):
key = cls._get_account_token_key(account_id, token_type)
redis_client.setex(key, expiry_hours * 60 * 60, token)
@classmethod
def _get_account_token_key(cls, account_id: str, token_type: str) -> str:
return f'{token_type}:account:{account_id}'
class RateLimiter:
def __init__(self, prefix: str, max_attempts: int, time_window: int):
self.prefix = prefix
self.max_attempts = max_attempts
self.time_window = time_window
def _get_key(self, email: str) -> str:
return f"{self.prefix}:{email}"
def is_rate_limited(self, email: str) -> bool:
key = self._get_key(email)
current_time = int(time.time())
window_start_time = current_time - self.time_window
redis_client.zremrangebyscore(key, '-inf', window_start_time)
attempts = redis_client.zcard(key)
if attempts and int(attempts) >= self.max_attempts:
return True
return False
def increment_rate_limit(self, email: str):
key = self._get_key(email)
current_time = int(time.time())
redis_client.zadd(key, {current_time: current_time})
redis_client.expire(key, self.time_window * 2)
+601 -696
View File
File diff suppressed because it is too large Load Diff
+1 -3
View File
@@ -115,7 +115,6 @@ chardet = "~5.1.0"
cohere = "~5.2.4"
cos-python-sdk-v5 = "1.9.30"
dashscope = { version = "~1.17.0", extras = ["tokenizer"] }
firecrawl-py = "0.0.5"
flask = "~3.0.1"
flask-compress = "~1.14"
flask-cors = "~4.0.0"
@@ -164,7 +163,6 @@ redis = { version = "~5.0.3", extras = ["hiredis"] }
replicate = "~0.22.0"
resend = "~0.7.0"
safetensors = "~0.4.3"
scikit-learn = "1.2.2"
sentry-sdk = { version = "~1.39.2", extras = ["flask"] }
sqlalchemy = "~2.0.29"
tencentcloud-sdk-python-hunyuan = "~3.0.1158"
@@ -186,7 +184,7 @@ zhipuai = "1.0.7"
arxiv = "2.1.0"
matplotlib = "~3.8.2"
newspaper3k = "0.2.8"
duckduckgo-search = "~6.1.5"
duckduckgo-search = "^6.1.8"
jsonpath-ng = "1.6.1"
numexpr = "~2.9.0"
opensearch-py = "2.4.0"
+33
View File
@@ -13,6 +13,7 @@ from werkzeug.exceptions import Unauthorized
from constants.languages import language_timezone_mapping, languages
from events.tenant_event import tenant_was_created
from extensions.ext_redis import redis_client
from libs.helper import RateLimiter, TokenManager
from libs.passport import PassportService
from libs.password import compare_password, hash_password, valid_password
from libs.rsa import generate_key_pair
@@ -29,14 +30,22 @@ from services.errors.account import (
LinkAccountIntegrateError,
MemberNotInTenantError,
NoPermissionError,
RateLimitExceededError,
RoleAlreadyAssignedError,
TenantNotFound,
)
from tasks.mail_invite_member_task import send_invite_member_mail_task
from tasks.mail_reset_password_task import send_reset_password_mail_task
class AccountService:
reset_password_rate_limiter = RateLimiter(
prefix="reset_password_rate_limit",
max_attempts=5,
time_window=60 * 60
)
@staticmethod
def load_user(user_id: str) -> Account:
account = Account.query.filter_by(id=user_id).first()
@@ -222,9 +231,33 @@ class AccountService:
return None
return AccountService.load_user(account_id)
@classmethod
def send_reset_password_email(cls, account):
if cls.reset_password_rate_limiter.is_rate_limited(account.email):
raise RateLimitExceededError(f"Rate limit exceeded for email: {account.email}. Please try again later.")
token = TokenManager.generate_token(account, 'reset_password')
send_reset_password_mail_task.delay(
language=account.interface_language,
to=account.email,
token=token
)
cls.reset_password_rate_limiter.increment_rate_limit(account.email)
return token
@classmethod
def revoke_reset_password_token(cls, token: str):
TokenManager.revoke_token(token, 'reset_password')
@classmethod
def get_reset_password_data(cls, token: str) -> Optional[dict[str, Any]]:
return TokenManager.get_token_data(token, 'reset_password')
def _get_login_cache_key(*, account_id: str, token: str):
return f"account_login:{account_id}:{token}"
class TenantService:
@staticmethod
+5
View File
@@ -51,3 +51,8 @@ class MemberNotInTenantError(BaseServiceError):
class RoleAlreadyAssignedError(BaseServiceError):
pass
class RateLimitExceededError(BaseServiceError):
pass
+2 -55
View File
@@ -1,13 +1,6 @@
import logging
import time
import numpy as np
from sklearn.manifold import TSNE
from core.embedding.cached_embedding import CacheEmbedding
from core.model_manager import ModelManager
from core.model_runtime.entities.model_entities import ModelType
from core.rag.datasource.entity.embedding import Embeddings
from core.rag.datasource.retrieval_service import RetrievalService
from core.rag.models.document import Document
from core.rag.retrieval.retrival_methods import RetrievalMethod
@@ -45,17 +38,6 @@ class HitTestingService:
if not retrieval_model:
retrieval_model = dataset.retrieval_model if dataset.retrieval_model else default_retrieval_model
# get embedding model
model_manager = ModelManager()
embedding_model = model_manager.get_model_instance(
tenant_id=dataset.tenant_id,
model_type=ModelType.TEXT_EMBEDDING,
provider=dataset.embedding_model_provider,
model=dataset.embedding_model
)
embeddings = CacheEmbedding(embedding_model)
all_documents = RetrievalService.retrieve(retrival_method=retrieval_model['search_method'],
dataset_id=dataset.id,
query=query,
@@ -80,20 +62,10 @@ class HitTestingService:
db.session.add(dataset_query)
db.session.commit()
return cls.compact_retrieve_response(dataset, embeddings, query, all_documents)
return cls.compact_retrieve_response(dataset, query, all_documents)
@classmethod
def compact_retrieve_response(cls, dataset: Dataset, embeddings: Embeddings, query: str, documents: list[Document]):
text_embeddings = [
embeddings.embed_query(query)
]
text_embeddings.extend(embeddings.embed_documents([document.page_content for document in documents]))
tsne_position_data = cls.get_tsne_positions_from_embeddings(text_embeddings)
query_position = tsne_position_data.pop(0)
def compact_retrieve_response(cls, dataset: Dataset, query: str, documents: list[Document]):
i = 0
records = []
for document in documents:
@@ -113,7 +85,6 @@ class HitTestingService:
record = {
"segment": segment,
"score": document.metadata.get('score', None),
"tsne_position": tsne_position_data[i]
}
records.append(record)
@@ -123,34 +94,10 @@ class HitTestingService:
return {
"query": {
"content": query,
"tsne_position": query_position,
},
"records": records
}
@classmethod
def get_tsne_positions_from_embeddings(cls, embeddings: list):
embedding_length = len(embeddings)
if embedding_length <= 1:
return [{'x': 0, 'y': 0}]
noise = np.random.normal(0, 1e-4, np.array(embeddings).shape)
concatenate_data = np.array(embeddings) + noise
concatenate_data = concatenate_data.reshape(embedding_length, -1)
perplexity = embedding_length / 2 + 1
if perplexity >= embedding_length:
perplexity = max(embedding_length - 1, 1)
tsne = TSNE(n_components=2, perplexity=perplexity, early_exaggeration=12.0)
data_tsne = tsne.fit_transform(concatenate_data)
tsne_position_data = []
for i in range(len(data_tsne)):
tsne_position_data.append({'x': float(data_tsne[i][0]), 'y': float(data_tsne[i][1])})
return tsne_position_data
@classmethod
def hit_testing_args_check(cls, args):
query = args['query']
+6 -1
View File
@@ -110,7 +110,12 @@ class RecommendedAppService:
if response.status_code != 200:
raise ValueError(f'fetch recommended apps failed, status code: {response.status_code}')
return response.json()
result = response.json()
if "categories" in result:
result["categories"] = sorted(result["categories"])
return result
@classmethod
def _fetch_recommended_apps_from_builtin(cls, language: str) -> dict:
+5 -6
View File
@@ -39,16 +39,15 @@ def send_invite_member_mail_task(language: str, to: str, token: str, inviter_nam
mail.send(to=to, subject="立即加入 Dify 工作空间", html=html_content)
else:
html_content = render_template('invite_member_mail_template_en-US.html',
to=to,
inviter_name=inviter_name,
workspace_name=workspace_name,
url=url)
to=to,
inviter_name=inviter_name,
workspace_name=workspace_name,
url=url)
mail.send(to=to, subject="Join Dify Workspace Now", html=html_content)
end_at = time.perf_counter()
logging.info(
click.style('Send invite member mail to {} succeeded: latency: {}'.format(to, end_at - start_at),
fg='green'))
except Exception:
logging.exception("Send invite member mail to {} failed".format(to))
logging.exception("Send invite member mail to {} failed".format(to))
+44
View File
@@ -0,0 +1,44 @@
import logging
import time
import click
from celery import shared_task
from flask import current_app, render_template
from extensions.ext_mail import mail
@shared_task(queue='mail')
def send_reset_password_mail_task(language: str, to: str, token: str):
"""
Async Send reset password mail
:param language: Language in which the email should be sent (e.g., 'en', 'zh')
:param to: Recipient email address
:param token: Reset password token to be included in the email
"""
if not mail.is_inited():
return
logging.info(click.style('Start password reset mail to {}'.format(to), fg='green'))
start_at = time.perf_counter()
# send reset password mail using different languages
try:
url = f'{current_app.config.get("CONSOLE_WEB_URL")}/forgot-password?token={token}'
if language == 'zh-Hans':
html_content = render_template('reset_password_mail_template_zh-CN.html',
to=to,
url=url)
mail.send(to=to, subject="重置您的 Dify 密码", html=html_content)
else:
html_content = render_template('reset_password_mail_template_en-US.html',
to=to,
url=url)
mail.send(to=to, subject="Reset Your Dify Password", html=html_content)
end_at = time.perf_counter()
logging.info(
click.style('Send password reset mail to {} succeeded: latency: {}'.format(to, end_at - start_at),
fg='green'))
except Exception:
logging.exception("Send password reset mail to {} failed".format(to))
@@ -0,0 +1,72 @@
<!DOCTYPE html>
<html>
<head>
<style>
body {
font-family: 'Arial', sans-serif;
line-height: 16pt;
color: #374151;
background-color: #E5E7EB;
margin: 0;
padding: 0;
}
.container {
width: 100%;
max-width: 560px;
margin: 40px auto;
padding: 20px;
background-color: #F3F4F6;
border-radius: 8px;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
}
.header {
text-align: center;
margin-bottom: 20px;
}
.header img {
max-width: 100px;
height: auto;
}
.button {
display: inline-block;
padding: 12px 24px;
background-color: #2970FF;
color: white;
text-decoration: none;
border-radius: 4px;
text-align: center;
transition: background-color 0.3s ease;
}
.button:hover {
background-color: #265DD4;
}
.footer {
font-size: 0.9em;
color: #777777;
margin-top: 30px;
}
.content {
margin-top: 20px;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<img src="https://cloud.dify.ai/logo/logo-site.png" alt="Dify Logo">
</div>
<div class="content">
<p>Dear {{ to }},</p>
<p>We have received a request to reset your password. If you initiated this request, please click the button below to reset your password:</p>
<p style="text-align: center;"><a style="color: #fff; text-decoration: none" class="button" href="{{ url }}">Reset Password</a></p>
<p>If you did not request a password reset, please ignore this email and your account will remain secure.</p>
</div>
<div class="footer">
<p>Best regards,</p>
<p>Dify Team</p>
<p>Please do not reply directly to this email; it is automatically sent by the system.</p>
</div>
</div>
</body>
</html>
@@ -0,0 +1,72 @@
<!DOCTYPE html>
<html>
<head>
<style>
body {
font-family: 'Arial', sans-serif;
line-height: 16pt;
color: #374151;
background-color: #E5E7EB;
margin: 0;
padding: 0;
}
.container {
width: 100%;
max-width: 560px;
margin: 40px auto;
padding: 20px;
background-color: #F3F4F6;
border-radius: 8px;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
}
.header {
text-align: center;
margin-bottom: 20px;
}
.header img {
max-width: 100px;
height: auto;
}
.button {
display: inline-block;
padding: 12px 24px;
background-color: #2970FF;
color: white;
text-decoration: none;
border-radius: 4px;
text-align: center;
transition: background-color 0.3s ease;
}
.button:hover {
background-color: #265DD4;
}
.footer {
font-size: 0.9em;
color: #777777;
margin-top: 30px;
}
.content {
margin-top: 20px;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<img src="https://cloud.dify.ai/logo/logo-site.png" alt="Dify Logo">
</div>
<div class="content">
<p>尊敬的 {{ to }}</p>
<p>我们收到了您关于重置密码的请求。如果是您本人操作,请点击以下按钮重置您的密码:</p>
<p style="text-align: center;"><a style="color: #fff; text-decoration: none" class="button" href="{{ url }}">重置密码</a></p>
<p>如果您没有请求重置密码,请忽略此邮件,您的账户信息将保持安全。</p>
</div>
<div class="footer">
<p>此致,</p>
<p>Dify 团队</p>
<p>请不要直接回复此电子邮件;由系统自动发送。</p>
</div>
</div>
</body>
</html>

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