diff --git a/.devcontainer/post_create_command.sh b/.devcontainer/post_create_command.sh
index c53c26bb9a..cc8eb552b0 100755
--- a/.devcontainer/post_create_command.sh
+++ b/.devcontainer/post_create_command.sh
@@ -2,10 +2,10 @@
npm add -g pnpm@10.8.0
cd web && pnpm install
-pipx install poetry
+pipx install uv
-echo 'alias start-api="cd /workspaces/dify/api && poetry run python -m flask run --host 0.0.0.0 --port=5001 --debug"' >> ~/.bashrc
-echo 'alias start-worker="cd /workspaces/dify/api && poetry run python -m celery -A app.celery worker -P gevent -c 1 --loglevel INFO -Q dataset,generation,mail,ops_trace,app_deletion"' >> ~/.bashrc
+echo 'alias start-api="cd /workspaces/dify/api && uv run python -m flask run --host 0.0.0.0 --port=5001 --debug"' >> ~/.bashrc
+echo 'alias start-worker="cd /workspaces/dify/api && uv run python -m celery -A app.celery worker -P gevent -c 1 --loglevel INFO -Q dataset,generation,mail,ops_trace,app_deletion"' >> ~/.bashrc
echo 'alias start-web="cd /workspaces/dify/web && pnpm dev"' >> ~/.bashrc
echo 'alias start-containers="cd /workspaces/dify/docker && docker-compose -f docker-compose.middleware.yaml -p dify --env-file middleware.env up -d"' >> ~/.bashrc
echo 'alias stop-containers="cd /workspaces/dify/docker && docker-compose -f docker-compose.middleware.yaml -p dify --env-file middleware.env down"' >> ~/.bashrc
diff --git a/.devcontainer/post_start_command.sh b/.devcontainer/post_start_command.sh
index 56e87614ba..0c16d74c72 100755
--- a/.devcontainer/post_start_command.sh
+++ b/.devcontainer/post_start_command.sh
@@ -1,3 +1,3 @@
#!/bin/bash
-cd api && poetry install
\ No newline at end of file
+cd api && uv sync
diff --git a/.github/actions/setup-poetry/action.yml b/.github/actions/setup-poetry/action.yml
deleted file mode 100644
index a15eb25327..0000000000
--- a/.github/actions/setup-poetry/action.yml
+++ /dev/null
@@ -1,36 +0,0 @@
-name: Setup Poetry and Python
-
-inputs:
- python-version:
- description: Python version to use and the Poetry installed with
- required: true
- default: '3.11'
- poetry-version:
- description: Poetry version to set up
- required: true
- default: '2.0.1'
- poetry-lockfile:
- description: Path to the Poetry lockfile to restore cache from
- required: true
- default: ''
-
-runs:
- using: composite
- steps:
- - name: Set up Python ${{ inputs.python-version }}
- uses: actions/setup-python@v5
- with:
- python-version: ${{ inputs.python-version }}
- cache: pip
-
- - name: Install Poetry
- shell: bash
- run: pip install poetry==${{ inputs.poetry-version }}
-
- - name: Restore Poetry cache
- if: ${{ inputs.poetry-lockfile != '' }}
- uses: actions/setup-python@v5
- with:
- python-version: ${{ inputs.python-version }}
- cache: poetry
- cache-dependency-path: ${{ inputs.poetry-lockfile }}
diff --git a/.github/actions/setup-uv/action.yml b/.github/actions/setup-uv/action.yml
new file mode 100644
index 0000000000..a596be63f7
--- /dev/null
+++ b/.github/actions/setup-uv/action.yml
@@ -0,0 +1,34 @@
+name: Setup UV and Python
+
+inputs:
+ python-version:
+ description: Python version to use and the UV installed with
+ required: true
+ default: '3.12'
+ uv-version:
+ description: UV version to set up
+ required: true
+ default: '0.6.14'
+ uv-lockfile:
+ description: Path to the UV lockfile to restore cache from
+ required: true
+ default: ''
+ enable-cache:
+ required: true
+ default: true
+
+runs:
+ using: composite
+ steps:
+ - name: Set up Python ${{ inputs.python-version }}
+ uses: actions/setup-python@v5
+ with:
+ python-version: ${{ inputs.python-version }}
+
+ - name: Install uv
+ uses: astral-sh/setup-uv@v5
+ with:
+ version: ${{ inputs.uv-version }}
+ python-version: ${{ inputs.python-version }}
+ enable-cache: ${{ inputs.enable-cache }}
+ cache-dependency-glob: ${{ inputs.uv-lockfile }}
diff --git a/.github/workflows/api-tests.yml b/.github/workflows/api-tests.yml
index dca8e640c7..02583cda06 100644
--- a/.github/workflows/api-tests.yml
+++ b/.github/workflows/api-tests.yml
@@ -17,6 +17,9 @@ jobs:
test:
name: API Tests
runs-on: ubuntu-latest
+ defaults:
+ run:
+ shell: bash
strategy:
matrix:
python-version:
@@ -27,40 +30,44 @@ jobs:
- name: Checkout code
uses: actions/checkout@v4
with:
- fetch-depth: 0
persist-credentials: false
- - name: Setup Poetry and Python ${{ matrix.python-version }}
- uses: ./.github/actions/setup-poetry
+ - name: Setup UV and Python
+ uses: ./.github/actions/setup-uv
with:
python-version: ${{ matrix.python-version }}
- poetry-lockfile: api/poetry.lock
+ uv-lockfile: api/uv.lock
- - name: Check Poetry lockfile
- run: |
- poetry check -C api --lock
- poetry show -C api
+ - name: Check UV lockfile
+ run: uv lock --project api --check
- name: Install dependencies
- run: poetry install -C api --with dev
-
- - name: Check dependencies in pyproject.toml
- run: poetry run -P api bash dev/pytest/pytest_artifacts.sh
+ run: uv sync --project api --dev
- name: Run Unit tests
- run: poetry run -P api bash dev/pytest/pytest_unit_tests.sh
+ run: |
+ uv run --project api bash dev/pytest/pytest_unit_tests.sh
+ # Extract coverage percentage and create a summary
+ TOTAL_COVERAGE=$(python -c 'import json; print(json.load(open("coverage.json"))["totals"]["percent_covered_display"])')
+
+ # Create a detailed coverage summary
+ echo "### Test Coverage Summary :test_tube:" >> $GITHUB_STEP_SUMMARY
+ echo "Total Coverage: ${TOTAL_COVERAGE}%" >> $GITHUB_STEP_SUMMARY
+ echo "\`\`\`" >> $GITHUB_STEP_SUMMARY
+ uv run --project api coverage report >> $GITHUB_STEP_SUMMARY
+ echo "\`\`\`" >> $GITHUB_STEP_SUMMARY
- name: Run dify config tests
- run: poetry run -P api python dev/pytest/pytest_config_tests.py
+ run: uv run --project api dev/pytest/pytest_config_tests.py
- - name: Cache MyPy
+ - name: MyPy Cache
uses: actions/cache@v4
with:
path: api/.mypy_cache
- key: mypy-${{ matrix.python-version }}-${{ runner.os }}-${{ hashFiles('api/poetry.lock') }}
+ key: mypy-${{ matrix.python-version }}-${{ runner.os }}-${{ hashFiles('api/uv.lock') }}
- - name: Run mypy
- run: dev/run-mypy
+ - name: Run MyPy Checks
+ run: dev/mypy-check
- name: Set up dotenvs
run: |
@@ -80,4 +87,4 @@ jobs:
ssrf_proxy
- name: Run Workflow
- run: poetry run -P api bash dev/pytest/pytest_workflow.sh
+ run: uv run --project api bash dev/pytest/pytest_workflow.sh
diff --git a/.github/workflows/db-migration-test.yml b/.github/workflows/db-migration-test.yml
index 69bff839a6..5181546b4a 100644
--- a/.github/workflows/db-migration-test.yml
+++ b/.github/workflows/db-migration-test.yml
@@ -24,13 +24,13 @@ jobs:
fetch-depth: 0
persist-credentials: false
- - name: Setup Poetry and Python
- uses: ./.github/actions/setup-poetry
+ - name: Setup UV and Python
+ uses: ./.github/actions/setup-uv
with:
- poetry-lockfile: api/poetry.lock
+ uv-lockfile: api/uv.lock
- name: Install dependencies
- run: poetry install -C api
+ run: uv sync --project api
- name: Prepare middleware env
run: |
@@ -54,6 +54,4 @@ jobs:
- name: Run DB Migration
env:
DEBUG: true
- run: |
- cd api
- poetry run python -m flask upgrade-db
+ run: uv run --directory api flask upgrade-db
diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml
index cf7e77b4b8..cadc1b5507 100644
--- a/.github/workflows/docker-build.yml
+++ b/.github/workflows/docker-build.yml
@@ -42,6 +42,7 @@ jobs:
with:
push: false
context: "{{defaultContext}}:${{ matrix.context }}"
+ file: "${{ matrix.file }}"
platforms: ${{ matrix.platform }}
cache-from: type=gha
cache-to: type=gha,mode=max
diff --git a/.github/workflows/style.yml b/.github/workflows/style.yml
index 625930b5f5..98e5fd5150 100644
--- a/.github/workflows/style.yml
+++ b/.github/workflows/style.yml
@@ -18,7 +18,6 @@ jobs:
- name: Checkout code
uses: actions/checkout@v4
with:
- fetch-depth: 0
persist-credentials: false
- name: Check changed files
@@ -29,24 +28,27 @@ jobs:
api/**
.github/workflows/style.yml
- - name: Setup Poetry and Python
+ - name: Setup UV and Python
if: steps.changed-files.outputs.any_changed == 'true'
- uses: ./.github/actions/setup-poetry
+ uses: ./.github/actions/setup-uv
+ with:
+ uv-lockfile: api/uv.lock
+ enable-cache: false
- name: Install dependencies
if: steps.changed-files.outputs.any_changed == 'true'
- run: poetry install -C api --only lint
+ run: uv sync --project api --dev
- name: Ruff check
if: steps.changed-files.outputs.any_changed == 'true'
run: |
- poetry run -C api ruff --version
- poetry run -C api ruff check ./
- poetry run -C api ruff format --check ./
+ uv run --directory api ruff --version
+ uv run --directory api ruff check ./
+ uv run --directory api ruff format --check ./
- name: Dotenv check
if: steps.changed-files.outputs.any_changed == 'true'
- run: poetry run -P api dotenv-linter ./api/.env.example ./web/.env.example
+ run: uv run --project api dotenv-linter ./api/.env.example ./web/.env.example
- name: Lint hints
if: failure()
@@ -63,7 +65,6 @@ jobs:
- name: Checkout code
uses: actions/checkout@v4
with:
- fetch-depth: 0
persist-credentials: false
- name: Check changed files
@@ -102,7 +103,6 @@ jobs:
- name: Checkout code
uses: actions/checkout@v4
with:
- fetch-depth: 0
persist-credentials: false
- name: Check changed files
@@ -133,7 +133,6 @@ jobs:
- name: Checkout code
uses: actions/checkout@v4
with:
- fetch-depth: 0
persist-credentials: false
- name: Check changed files
diff --git a/.github/workflows/tool-test-sdks.yaml b/.github/workflows/tool-test-sdks.yaml
index a6e48d1359..b1ccd7417a 100644
--- a/.github/workflows/tool-test-sdks.yaml
+++ b/.github/workflows/tool-test-sdks.yaml
@@ -27,7 +27,6 @@ jobs:
steps:
- uses: actions/checkout@v4
with:
- fetch-depth: 0
persist-credentials: false
- name: Use Node.js ${{ matrix.node-version }}
diff --git a/.github/workflows/vdb-tests.yml b/.github/workflows/vdb-tests.yml
index 5e3f7a557a..c784817e72 100644
--- a/.github/workflows/vdb-tests.yml
+++ b/.github/workflows/vdb-tests.yml
@@ -8,7 +8,7 @@ on:
- api/core/rag/datasource/**
- docker/**
- .github/workflows/vdb-tests.yml
- - api/poetry.lock
+ - api/uv.lock
- api/pyproject.toml
concurrency:
@@ -29,22 +29,19 @@ jobs:
- name: Checkout code
uses: actions/checkout@v4
with:
- fetch-depth: 0
persist-credentials: false
- - name: Setup Poetry and Python ${{ matrix.python-version }}
- uses: ./.github/actions/setup-poetry
+ - name: Setup UV and Python
+ uses: ./.github/actions/setup-uv
with:
python-version: ${{ matrix.python-version }}
- poetry-lockfile: api/poetry.lock
+ uv-lockfile: api/uv.lock
- - name: Check Poetry lockfile
- run: |
- poetry check -C api --lock
- poetry show -C api
+ - name: Check UV lockfile
+ run: uv lock --project api --check
- name: Install dependencies
- run: poetry install -C api --with dev
+ run: uv sync --project api --dev
- name: Set up dotenvs
run: |
@@ -80,7 +77,7 @@ jobs:
elasticsearch
- name: Check TiDB Ready
- run: poetry run -P api python api/tests/integration_tests/vdb/tidb_vector/check_tiflash_ready.py
+ run: uv run --project api python api/tests/integration_tests/vdb/tidb_vector/check_tiflash_ready.py
- name: Test Vector Stores
- run: poetry run -P api bash dev/pytest/pytest_vdb.sh
+ run: uv run --project api bash dev/pytest/pytest_vdb.sh
diff --git a/.github/workflows/web-tests.yml b/.github/workflows/web-tests.yml
index 85e8b99473..37cfdc5c1e 100644
--- a/.github/workflows/web-tests.yml
+++ b/.github/workflows/web-tests.yml
@@ -23,7 +23,6 @@ jobs:
- name: Checkout code
uses: actions/checkout@v4
with:
- fetch-depth: 0
persist-credentials: false
- name: Check changed files
diff --git a/.gitignore b/.gitignore
index 819a249581..8818ab6f65 100644
--- a/.gitignore
+++ b/.gitignore
@@ -46,6 +46,7 @@ htmlcov/
.cache
nosetests.xml
coverage.xml
+coverage.json
*.cover
*.py,cover
.hypothesis/
diff --git a/CONTRIBUTING_CN.md b/CONTRIBUTING_CN.md
index 0478d2e1fa..69ae7071bb 100644
--- a/CONTRIBUTING_CN.md
+++ b/CONTRIBUTING_CN.md
@@ -6,7 +6,7 @@
本指南和 Dify 一样在不断完善中。如果有任何滞后于项目实际情况的地方,恳请谅解,我们也欢迎任何改进建议。
-关于许可证,请花一分钟阅读我们简短的[许可和贡献者协议](./LICENSE)。社区同时也遵循[行为准则](https://github.com/langgenius/.github/blob/main/CODE_OF_CONDUCT.md)。
+关于许可证,请花一分钟阅读我们简短的[许可和贡献者协议](./LICENSE)。同时也请遵循社区[行为准则](https://github.com/langgenius/.github/blob/main/CODE_OF_CONDUCT.md)。
## 开始之前
diff --git a/README.md b/README.md
index 87ebc9bafc..65e8001dd2 100644
--- a/README.md
+++ b/README.md
@@ -8,7 +8,7 @@
Dify Cloud ·
Self-hosting ·
Documentation ·
- Enterprise inquiry
+ Dify edition overview
diff --git a/README_AR.md b/README_AR.md
index e58f59da5d..4f93802fda 100644
--- a/README_AR.md
+++ b/README_AR.md
@@ -4,7 +4,7 @@
Dify Cloud ·
الاستضافة الذاتية ·
التوثيق ·
- استفسار الشركات (للإنجليزية فقط)
+ نظرة عامة على منتجات Dify
diff --git a/README_BN.md b/README_BN.md
index 3ebc81af5d..7599fae9ff 100644
--- a/README_BN.md
+++ b/README_BN.md
@@ -8,7 +8,7 @@
ডিফাই ক্লাউড ·
সেল্ফ-হোস্টিং ·
ডকুমেন্টেশন ·
- ব্যাবসায়িক অনুসন্ধান
+ Dify পণ্যের রূপভেদ
diff --git a/README_CN.md b/README_CN.md
index 6d3c601100..973629f459 100644
--- a/README_CN.md
+++ b/README_CN.md
@@ -4,7 +4,7 @@
Dify 云服务 ·
自托管 ·
文档 ·
- (需用英文)常见问题解答 / 联系团队
+ Dify 产品形态总览
diff --git a/README_DE.md b/README_DE.md
index b3b9bf3221..738c0e3b67 100644
--- a/README_DE.md
+++ b/README_DE.md
@@ -8,7 +8,7 @@
Dify Cloud ·
Selbstgehostetes ·
Dokumentation ·
- Anfrage an Unternehmen
+ Überblick über die Dify-Produkte
diff --git a/README_ES.md b/README_ES.md
index d14afdd2eb..212268b73d 100644
--- a/README_ES.md
+++ b/README_ES.md
@@ -4,7 +4,7 @@
Dify Cloud ·
Auto-alojamiento ·
Documentación ·
- Consultas empresariales (en inglés)
+ Resumen de las ediciones de Dify
diff --git a/README_FR.md b/README_FR.md
index 031196303e..89eea7d058 100644
--- a/README_FR.md
+++ b/README_FR.md
@@ -4,7 +4,7 @@
Dify Cloud ·
Auto-hébergement ·
Documentation ·
- Demande d’entreprise (en anglais seulement)
+ Présentation des différentes offres Dify
diff --git a/README_JA.md b/README_JA.md
index 3b7a6f50db..adca219753 100644
--- a/README_JA.md
+++ b/README_JA.md
@@ -4,7 +4,7 @@
Dify Cloud ·
セルフホスティング ·
ドキュメント ·
- 企業のお問い合わせ(英語のみ)
+ Difyの各種エディションについて
diff --git a/README_KL.md b/README_KL.md
index ccadb77274..17e6c9d509 100644
--- a/README_KL.md
+++ b/README_KL.md
@@ -4,7 +4,7 @@
Dify Cloud ·
Self-hosting ·
Documentation ·
- Commercial enquiries
+ Dify product editions
diff --git a/README_KR.md b/README_KR.md
index c1a98f8b68..d44723f9b6 100644
--- a/README_KR.md
+++ b/README_KR.md
@@ -4,7 +4,7 @@
Dify 클라우드 ·
셀프-호스팅 ·
문서 ·
- 기업 문의 (영어만 가능)
+ Dify 제품 에디션 안내
diff --git a/README_PT.md b/README_PT.md
index 5b3c782645..9dc2207279 100644
--- a/README_PT.md
+++ b/README_PT.md
@@ -8,7 +8,7 @@
Dify Cloud ·
Auto-hospedagem ·
Documentação ·
- Consultas empresariais
+ Visão geral das edições do Dify
diff --git a/README_SI.md b/README_SI.md
index 7c0867c776..caa5975973 100644
--- a/README_SI.md
+++ b/README_SI.md
@@ -8,7 +8,7 @@
Dify Cloud ·
Samostojno gostovanje ·
Dokumentacija ·
- Povpraševanje za podjetja
+ Pregled ponudb izdelkov Dify
diff --git a/README_TR.md b/README_TR.md
index f8890b00ef..ab2853a019 100644
--- a/README_TR.md
+++ b/README_TR.md
@@ -4,7 +4,7 @@
Dify Bulut ·
Kendi Sunucunuzda Barındırma ·
Dokümantasyon ·
- Yalnızca İngilizce: Kurumsal Sorgulama
+ Dify ürün seçeneklerine genel bakış
diff --git a/README_TW.md b/README_TW.md
index 260f1e80ac..8263a22b64 100644
--- a/README_TW.md
+++ b/README_TW.md
@@ -8,7 +8,7 @@
Dify 雲端服務 ·
自行託管 ·
說明文件 ·
- 企業諮詢
+ 產品方案概覽
diff --git a/README_VI.md b/README_VI.md
index 15d2d5ae80..852ed7aaa0 100644
--- a/README_VI.md
+++ b/README_VI.md
@@ -4,7 +4,7 @@
Dify Cloud ·
Tự triển khai ·
Tài liệu ·
- Yêu cầu doanh nghiệp
+ Tổng quan các lựa chọn sản phẩm Dify
diff --git a/api/.env.example b/api/.env.example
index af95a4fe2d..b5820fcdc2 100644
--- a/api/.env.example
+++ b/api/.env.example
@@ -165,6 +165,7 @@ MILVUS_URI=http://127.0.0.1:19530
MILVUS_TOKEN=
MILVUS_USER=root
MILVUS_PASSWORD=Milvus
+MILVUS_ANALYZER_PARAMS=
# MyScale configuration
MYSCALE_HOST=127.0.0.1
@@ -423,6 +424,12 @@ WORKFLOW_CALL_MAX_DEPTH=5
WORKFLOW_PARALLEL_DEPTH_LIMIT=3
MAX_VARIABLE_SIZE=204800
+# Workflow storage configuration
+# Options: rdbms, hybrid
+# rdbms: Use only the relational database (default)
+# hybrid: Save new data to object storage, read from both object storage and RDBMS
+WORKFLOW_NODE_EXECUTION_STORAGE=rdbms
+
# App configuration
APP_MAX_EXECUTION_TIME=1200
APP_MAX_ACTIVE_REQUESTS=0
@@ -475,4 +482,7 @@ OTEL_MAX_QUEUE_SIZE=2048
OTEL_MAX_EXPORT_BATCH_SIZE=512
OTEL_METRIC_EXPORT_INTERVAL=60000
OTEL_BATCH_EXPORT_TIMEOUT=10000
-OTEL_METRIC_EXPORT_TIMEOUT=30000
\ No newline at end of file
+OTEL_METRIC_EXPORT_TIMEOUT=30000
+
+# Prevent Clickjacking
+ALLOW_EMBED=false
diff --git a/api/Dockerfile b/api/Dockerfile
index fbfbd47741..cff696ff56 100644
--- a/api/Dockerfile
+++ b/api/Dockerfile
@@ -3,20 +3,11 @@ FROM python:3.12-slim-bookworm AS base
WORKDIR /app/api
-# Install Poetry
-ENV POETRY_VERSION=2.0.1
+# Install uv
+ENV UV_VERSION=0.6.14
-# if you located in China, you can use aliyun mirror to speed up
-# RUN pip install --no-cache-dir poetry==${POETRY_VERSION} -i https://mirrors.aliyun.com/pypi/simple/
-
-RUN pip install --no-cache-dir poetry==${POETRY_VERSION}
+RUN pip install --no-cache-dir uv==${UV_VERSION}
-# Configure Poetry
-ENV POETRY_CACHE_DIR=/tmp/poetry_cache
-ENV POETRY_NO_INTERACTION=1
-ENV POETRY_VIRTUALENVS_IN_PROJECT=true
-ENV POETRY_VIRTUALENVS_CREATE=true
-ENV POETRY_REQUESTS_TIMEOUT=15
FROM base AS packages
@@ -27,8 +18,8 @@ RUN apt-get update \
&& apt-get install -y --no-install-recommends gcc g++ libc-dev libffi-dev libgmp-dev libmpfr-dev libmpc-dev
# Install Python dependencies
-COPY pyproject.toml poetry.lock ./
-RUN poetry install --sync --no-cache --no-root
+COPY pyproject.toml uv.lock ./
+RUN uv sync --locked
# production stage
FROM base AS production
diff --git a/api/README.md b/api/README.md
index c3abc25be1..c542f11b16 100644
--- a/api/README.md
+++ b/api/README.md
@@ -3,7 +3,10 @@
## Usage
> [!IMPORTANT]
-> In the v0.6.12 release, we deprecated `pip` as the package management tool for Dify API Backend service and replaced it with `poetry`.
+>
+> In the v1.3.0 release, `poetry` has been replaced with
+> [`uv`](https://docs.astral.sh/uv/) as the package manager
+> for Dify API backend service.
1. Start the docker-compose stack
@@ -37,19 +40,19 @@
4. Create environment.
- Dify API service uses [Poetry](https://python-poetry.org/docs/) to manage dependencies. First, you need to add the poetry shell plugin, if you don't have it already, in order to run in a virtual environment. [Note: Poetry shell is no longer a native command so you need to install the poetry plugin beforehand]
+ Dify API service uses [UV](https://docs.astral.sh/uv/) to manage dependencies.
+ First, you need to add the uv package manager, if you don't have it already.
```bash
- poetry self add poetry-plugin-shell
+ pip install uv
+ # Or on macOS
+ brew install uv
```
-
- Then, You can execute `poetry shell` to activate the environment.
5. Install dependencies
```bash
- poetry env use 3.12
- poetry install
+ uv sync --dev
```
6. Run migrate
@@ -57,21 +60,21 @@
Before the first launch, migrate the database to the latest version.
```bash
- poetry run python -m flask db upgrade
+ uv run flask db upgrade
```
7. Start backend
```bash
- poetry run python -m flask run --host 0.0.0.0 --port=5001 --debug
+ uv run flask run --host 0.0.0.0 --port=5001 --debug
```
8. Start Dify [web](../web) service.
-9. Setup your application by visiting `http://localhost:3000`...
+9. Setup your application by visiting `http://localhost:3000`.
10. If you need to handle and debug the async tasks (e.g. dataset importing and documents indexing), please start the worker service.
```bash
- poetry run python -m celery -A app.celery worker -P gevent -c 1 --loglevel INFO -Q dataset,generation,mail,ops_trace,app_deletion
+ uv run celery -A app.celery worker -P gevent -c 1 --loglevel INFO -Q dataset,generation,mail,ops_trace,app_deletion
```
## Testing
@@ -79,11 +82,11 @@
1. Install dependencies for both the backend and the test environment
```bash
- poetry install -C api --with dev
+ uv sync --dev
```
2. Run the tests locally with mocked system environment variables in `tool.pytest_env` section in `pyproject.toml`
```bash
- poetry run -P api bash dev/pytest/pytest_all_tests.sh
+ uv run -P api bash dev/pytest/pytest_all_tests.sh
```
diff --git a/api/app_factory.py b/api/app_factory.py
index 1c886ac5c7..9648d770ab 100644
--- a/api/app_factory.py
+++ b/api/app_factory.py
@@ -52,8 +52,10 @@ def initialize_extensions(app: DifyApp):
ext_mail,
ext_migrate,
ext_otel,
+ ext_otel_patch,
ext_proxy_fix,
ext_redis,
+ ext_repositories,
ext_sentry,
ext_set_secretkey,
ext_storage,
@@ -74,6 +76,7 @@ def initialize_extensions(app: DifyApp):
ext_migrate,
ext_redis,
ext_storage,
+ ext_repositories,
ext_celery,
ext_login,
ext_mail,
@@ -82,6 +85,7 @@ def initialize_extensions(app: DifyApp):
ext_proxy_fix,
ext_blueprints,
ext_commands,
+ ext_otel_patch, # Apply patch before initializing OpenTelemetry
ext_otel,
]
for ext in extensions:
diff --git a/api/configs/app_config.py b/api/configs/app_config.py
index cb0adb751c..3a3ad35ee7 100644
--- a/api/configs/app_config.py
+++ b/api/configs/app_config.py
@@ -13,6 +13,7 @@ from .observability import ObservabilityConfig
from .packaging import PackagingInfo
from .remote_settings_sources import RemoteSettingsSource, RemoteSettingsSourceConfig, RemoteSettingsSourceName
from .remote_settings_sources.apollo import ApolloSettingsSource
+from .remote_settings_sources.nacos import NacosSettingsSource
logger = logging.getLogger(__name__)
@@ -34,6 +35,8 @@ class RemoteSettingsSourceFactory(PydanticBaseSettingsSource):
match remote_source_name:
case RemoteSettingsSourceName.APOLLO:
remote_source = ApolloSettingsSource(current_state)
+ case RemoteSettingsSourceName.NACOS:
+ remote_source = NacosSettingsSource(current_state)
case _:
logger.warning(f"Unsupported remote source: {remote_source_name}")
return {}
diff --git a/api/configs/feature/__init__.py b/api/configs/feature/__init__.py
index d35a74e3ee..f498dccbbc 100644
--- a/api/configs/feature/__init__.py
+++ b/api/configs/feature/__init__.py
@@ -12,7 +12,7 @@ from pydantic import (
)
from pydantic_settings import BaseSettings
-from configs.feature.hosted_service import HostedServiceConfig
+from .hosted_service import HostedServiceConfig
class SecurityConfig(BaseSettings):
@@ -519,6 +519,11 @@ class WorkflowNodeExecutionConfig(BaseSettings):
default=100,
)
+ WORKFLOW_NODE_EXECUTION_STORAGE: str = Field(
+ default="rdbms",
+ description="Storage backend for WorkflowNodeExecution. Options: 'rdbms', 'hybrid'",
+ )
+
class AuthConfig(BaseSettings):
"""
diff --git a/api/configs/middleware/__init__.py b/api/configs/middleware/__init__.py
index 15dfe0063b..c2ad24094a 100644
--- a/api/configs/middleware/__init__.py
+++ b/api/configs/middleware/__init__.py
@@ -22,6 +22,7 @@ from .vdb.baidu_vector_config import BaiduVectorDBConfig
from .vdb.chroma_config import ChromaConfig
from .vdb.couchbase_config import CouchbaseConfig
from .vdb.elasticsearch_config import ElasticsearchConfig
+from .vdb.huawei_cloud_config import HuaweiCloudConfig
from .vdb.lindorm_config import LindormConfig
from .vdb.milvus_config import MilvusConfig
from .vdb.myscale_config import MyScaleConfig
@@ -263,6 +264,7 @@ class MiddlewareConfig(
VectorStoreConfig,
AnalyticdbConfig,
ChromaConfig,
+ HuaweiCloudConfig,
MilvusConfig,
MyScaleConfig,
OpenSearchConfig,
diff --git a/api/configs/middleware/vdb/huawei_cloud_config.py b/api/configs/middleware/vdb/huawei_cloud_config.py
new file mode 100644
index 0000000000..2290c60499
--- /dev/null
+++ b/api/configs/middleware/vdb/huawei_cloud_config.py
@@ -0,0 +1,25 @@
+from typing import Optional
+
+from pydantic import Field
+from pydantic_settings import BaseSettings
+
+
+class HuaweiCloudConfig(BaseSettings):
+ """
+ Configuration settings for Huawei cloud search service
+ """
+
+ HUAWEI_CLOUD_HOSTS: Optional[str] = Field(
+ description="Hostname or IP address of the Huawei cloud search service instance",
+ default=None,
+ )
+
+ HUAWEI_CLOUD_USER: Optional[str] = Field(
+ description="Username for authenticating with Huawei cloud search service",
+ default=None,
+ )
+
+ HUAWEI_CLOUD_PASSWORD: Optional[str] = Field(
+ description="Password for authenticating with Huawei cloud search service",
+ default=None,
+ )
diff --git a/api/configs/middleware/vdb/milvus_config.py b/api/configs/middleware/vdb/milvus_config.py
index ebdf8857b9..d398ef5bd8 100644
--- a/api/configs/middleware/vdb/milvus_config.py
+++ b/api/configs/middleware/vdb/milvus_config.py
@@ -39,3 +39,8 @@ class MilvusConfig(BaseSettings):
"older versions",
default=True,
)
+
+ MILVUS_ANALYZER_PARAMS: Optional[str] = Field(
+ description='Milvus text analyzer parameters, e.g., {"type": "chinese"} for Chinese segmentation support.',
+ default=None,
+ )
diff --git a/api/configs/packaging/__init__.py b/api/configs/packaging/__init__.py
index c7aedc5b8a..a33c7727dc 100644
--- a/api/configs/packaging/__init__.py
+++ b/api/configs/packaging/__init__.py
@@ -9,7 +9,7 @@ class PackagingInfo(BaseSettings):
CURRENT_VERSION: str = Field(
description="Dify version",
- default="1.2.0",
+ default="1.3.0",
)
COMMIT_SHA: str = Field(
diff --git a/api/configs/remote_settings_sources/enums.py b/api/configs/remote_settings_sources/enums.py
index 3081f2950f..dd998cac64 100644
--- a/api/configs/remote_settings_sources/enums.py
+++ b/api/configs/remote_settings_sources/enums.py
@@ -3,3 +3,4 @@ from enum import StrEnum
class RemoteSettingsSourceName(StrEnum):
APOLLO = "apollo"
+ NACOS = "nacos"
diff --git a/api/configs/remote_settings_sources/nacos/__init__.py b/api/configs/remote_settings_sources/nacos/__init__.py
new file mode 100644
index 0000000000..b1ce8e87bc
--- /dev/null
+++ b/api/configs/remote_settings_sources/nacos/__init__.py
@@ -0,0 +1,52 @@
+import logging
+import os
+from collections.abc import Mapping
+from typing import Any
+
+from pydantic.fields import FieldInfo
+
+from .http_request import NacosHttpClient
+
+logger = logging.getLogger(__name__)
+
+from configs.remote_settings_sources.base import RemoteSettingsSource
+
+from .utils import _parse_config
+
+
+class NacosSettingsSource(RemoteSettingsSource):
+ def __init__(self, configs: Mapping[str, Any]):
+ self.configs = configs
+ self.remote_configs: dict[str, Any] = {}
+ self.async_init()
+
+ def async_init(self):
+ data_id = os.getenv("DIFY_ENV_NACOS_DATA_ID", "dify-api-env.properties")
+ group = os.getenv("DIFY_ENV_NACOS_GROUP", "nacos-dify")
+ tenant = os.getenv("DIFY_ENV_NACOS_NAMESPACE", "")
+
+ params = {"dataId": data_id, "group": group, "tenant": tenant}
+ try:
+ content = NacosHttpClient().http_request("/nacos/v1/cs/configs", method="GET", headers={}, params=params)
+ self.remote_configs = self._parse_config(content)
+ except Exception as e:
+ logger.exception("[get-access-token] exception occurred")
+ raise
+
+ def _parse_config(self, content: str) -> dict:
+ if not content:
+ return {}
+ try:
+ return _parse_config(self, content)
+ except Exception as e:
+ raise RuntimeError(f"Failed to parse config: {e}")
+
+ def get_field_value(self, field: FieldInfo, field_name: str) -> tuple[Any, str, bool]:
+ if not isinstance(self.remote_configs, dict):
+ raise ValueError(f"remote configs is not dict, but {type(self.remote_configs)}")
+
+ field_value = self.remote_configs.get(field_name)
+ if field_value is None:
+ return None, field_name, False
+
+ return field_value, field_name, False
diff --git a/api/configs/remote_settings_sources/nacos/http_request.py b/api/configs/remote_settings_sources/nacos/http_request.py
new file mode 100644
index 0000000000..2785bd955b
--- /dev/null
+++ b/api/configs/remote_settings_sources/nacos/http_request.py
@@ -0,0 +1,83 @@
+import base64
+import hashlib
+import hmac
+import logging
+import os
+import time
+
+import requests
+
+logger = logging.getLogger(__name__)
+
+
+class NacosHttpClient:
+ def __init__(self):
+ self.username = os.getenv("DIFY_ENV_NACOS_USERNAME")
+ self.password = os.getenv("DIFY_ENV_NACOS_PASSWORD")
+ self.ak = os.getenv("DIFY_ENV_NACOS_ACCESS_KEY")
+ self.sk = os.getenv("DIFY_ENV_NACOS_SECRET_KEY")
+ self.server = os.getenv("DIFY_ENV_NACOS_SERVER_ADDR", "localhost:8848")
+ self.token = None
+ self.token_ttl = 18000
+ self.token_expire_time: float = 0
+
+ def http_request(self, url, method="GET", headers=None, params=None):
+ try:
+ self._inject_auth_info(headers, params)
+ response = requests.request(method, url="http://" + self.server + url, headers=headers, params=params)
+ response.raise_for_status()
+ return response.text
+ except requests.exceptions.RequestException as e:
+ return f"Request to Nacos failed: {e}"
+
+ def _inject_auth_info(self, headers, params, module="config"):
+ headers.update({"User-Agent": "Nacos-Http-Client-In-Dify:v0.0.1"})
+
+ if module == "login":
+ return
+
+ ts = str(int(time.time() * 1000))
+
+ if self.ak and self.sk:
+ sign_str = self.get_sign_str(params["group"], params["tenant"], ts)
+ headers["Spas-AccessKey"] = self.ak
+ headers["Spas-Signature"] = self.__do_sign(sign_str, self.sk)
+ headers["timeStamp"] = ts
+ if self.username and self.password:
+ self.get_access_token(force_refresh=False)
+ params["accessToken"] = self.token
+
+ def __do_sign(self, sign_str, sk):
+ return (
+ base64.encodebytes(hmac.new(sk.encode(), sign_str.encode(), digestmod=hashlib.sha1).digest())
+ .decode()
+ .strip()
+ )
+
+ def get_sign_str(self, group, tenant, ts):
+ sign_str = ""
+ if tenant:
+ sign_str = tenant + "+"
+ if group:
+ sign_str = sign_str + group + "+"
+ if sign_str:
+ sign_str += ts
+ return sign_str
+
+ def get_access_token(self, force_refresh=False):
+ current_time = time.time()
+ if self.token and not force_refresh and self.token_expire_time > current_time:
+ return self.token
+
+ params = {"username": self.username, "password": self.password}
+ url = "http://" + self.server + "/nacos/v1/auth/login"
+ try:
+ resp = requests.request("POST", url, headers=None, params=params)
+ resp.raise_for_status()
+ response_data = resp.json()
+ self.token = response_data.get("accessToken")
+ self.token_ttl = response_data.get("tokenTtl", 18000)
+ self.token_expire_time = current_time + self.token_ttl - 10
+ except Exception as e:
+ logger.exception("[get-access-token] exception occur")
+ raise
diff --git a/api/configs/remote_settings_sources/nacos/utils.py b/api/configs/remote_settings_sources/nacos/utils.py
new file mode 100644
index 0000000000..f3372563b1
--- /dev/null
+++ b/api/configs/remote_settings_sources/nacos/utils.py
@@ -0,0 +1,31 @@
+def _parse_config(self, content: str) -> dict[str, str]:
+ config: dict[str, str] = {}
+ if not content:
+ return config
+
+ for line in content.splitlines():
+ cleaned_line = line.strip()
+ if not cleaned_line or cleaned_line.startswith(("#", "!")):
+ continue
+
+ separator_index = -1
+ for i, c in enumerate(cleaned_line):
+ if c in ("=", ":") and (i == 0 or cleaned_line[i - 1] != "\\"):
+ separator_index = i
+ break
+
+ if separator_index == -1:
+ continue
+
+ key = cleaned_line[:separator_index].strip()
+ raw_value = cleaned_line[separator_index + 1 :].strip()
+
+ try:
+ decoded_value = bytes(raw_value, "utf-8").decode("unicode_escape")
+ decoded_value = decoded_value.replace(r"\=", "=").replace(r"\:", ":")
+ except UnicodeDecodeError:
+ decoded_value = raw_value
+
+ config[key] = decoded_value
+
+ return config
diff --git a/api/controllers/common/helpers.py b/api/controllers/common/helpers.py
index 282708c037..008f1f0f7a 100644
--- a/api/controllers/common/helpers.py
+++ b/api/controllers/common/helpers.py
@@ -4,14 +4,10 @@ import platform
import re
import urllib.parse
import warnings
-from collections.abc import Mapping
-from typing import Any
from uuid import uuid4
import httpx
-from constants import DEFAULT_FILE_NUMBER_LIMITS
-
try:
import magic
except ImportError:
@@ -31,8 +27,6 @@ except ImportError:
from pydantic import BaseModel
-from configs import dify_config
-
class FileInfo(BaseModel):
filename: str
@@ -89,38 +83,3 @@ def guess_file_info_from_response(response: httpx.Response):
mimetype=mimetype,
size=int(response.headers.get("Content-Length", -1)),
)
-
-
-def get_parameters_from_feature_dict(*, features_dict: Mapping[str, Any], user_input_form: list[dict[str, Any]]):
- return {
- "opening_statement": features_dict.get("opening_statement"),
- "suggested_questions": features_dict.get("suggested_questions", []),
- "suggested_questions_after_answer": features_dict.get("suggested_questions_after_answer", {"enabled": False}),
- "speech_to_text": features_dict.get("speech_to_text", {"enabled": False}),
- "text_to_speech": features_dict.get("text_to_speech", {"enabled": False}),
- "retriever_resource": features_dict.get("retriever_resource", {"enabled": False}),
- "annotation_reply": features_dict.get("annotation_reply", {"enabled": False}),
- "more_like_this": features_dict.get("more_like_this", {"enabled": False}),
- "user_input_form": user_input_form,
- "sensitive_word_avoidance": features_dict.get(
- "sensitive_word_avoidance", {"enabled": False, "type": "", "configs": []}
- ),
- "file_upload": features_dict.get(
- "file_upload",
- {
- "image": {
- "enabled": False,
- "number_limits": DEFAULT_FILE_NUMBER_LIMITS,
- "detail": "high",
- "transfer_methods": ["remote_url", "local_file"],
- }
- },
- ),
- "system_parameters": {
- "image_file_size_limit": dify_config.UPLOAD_IMAGE_FILE_SIZE_LIMIT,
- "video_file_size_limit": dify_config.UPLOAD_VIDEO_FILE_SIZE_LIMIT,
- "audio_file_size_limit": dify_config.UPLOAD_AUDIO_FILE_SIZE_LIMIT,
- "file_size_limit": dify_config.UPLOAD_FILE_SIZE_LIMIT,
- "workflow_file_upload_limit": dify_config.WORKFLOW_FILE_UPLOAD_LIMIT,
- },
- }
diff --git a/api/controllers/console/app/annotation.py b/api/controllers/console/app/annotation.py
index 24f1020c18..fcd8ed1882 100644
--- a/api/controllers/console/app/annotation.py
+++ b/api/controllers/console/app/annotation.py
@@ -89,7 +89,7 @@ class AnnotationReplyActionStatusApi(Resource):
app_annotation_job_key = "{}_app_annotation_job_{}".format(action, str(job_id))
cache_result = redis_client.get(app_annotation_job_key)
if cache_result is None:
- raise ValueError("The job is not exist.")
+ raise ValueError("The job does not exist.")
job_status = cache_result.decode()
error_msg = ""
@@ -226,7 +226,7 @@ class AnnotationBatchImportStatusApi(Resource):
indexing_cache_key = "app_annotation_batch_import_{}".format(str(job_id))
cache_result = redis_client.get(indexing_cache_key)
if cache_result is None:
- raise ValueError("The job is not exist.")
+ raise ValueError("The job does not exist.")
job_status = cache_result.decode()
error_msg = ""
if job_status == "error":
diff --git a/api/controllers/console/app/audio.py b/api/controllers/console/app/audio.py
index 12d9157dda..7519ae96c0 100644
--- a/api/controllers/console/app/audio.py
+++ b/api/controllers/console/app/audio.py
@@ -80,8 +80,6 @@ class ChatMessageTextApi(Resource):
@account_initialization_required
@get_app_model
def post(self, app_model: App):
- from werkzeug.exceptions import InternalServerError
-
try:
parser = reqparse.RequestParser()
parser.add_argument("message_id", type=str, location="json")
diff --git a/api/controllers/console/app/generator.py b/api/controllers/console/app/generator.py
index 8518d34a8e..4046417076 100644
--- a/api/controllers/console/app/generator.py
+++ b/api/controllers/console/app/generator.py
@@ -85,5 +85,35 @@ class RuleCodeGenerateApi(Resource):
return code_result
+class RuleStructuredOutputGenerateApi(Resource):
+ @setup_required
+ @login_required
+ @account_initialization_required
+ def post(self):
+ parser = reqparse.RequestParser()
+ parser.add_argument("instruction", type=str, required=True, nullable=False, location="json")
+ parser.add_argument("model_config", type=dict, required=True, nullable=False, location="json")
+ args = parser.parse_args()
+
+ account = current_user
+ try:
+ structured_output = LLMGenerator.generate_structured_output(
+ tenant_id=account.current_tenant_id,
+ instruction=args["instruction"],
+ model_config=args["model_config"],
+ )
+ except ProviderTokenNotInitError as ex:
+ raise ProviderNotInitializeError(ex.description)
+ except QuotaExceededError:
+ raise ProviderQuotaExceededError()
+ except ModelCurrentlyNotSupportError:
+ raise ProviderModelCurrentlyNotSupportError()
+ except InvokeError as e:
+ raise CompletionRequestError(e.description)
+
+ return structured_output
+
+
api.add_resource(RuleGenerateApi, "/rule-generate")
api.add_resource(RuleCodeGenerateApi, "/rule-code-generate")
+api.add_resource(RuleStructuredOutputGenerateApi, "/rule-structured-output-generate")
diff --git a/api/controllers/console/app/workflow_app_log.py b/api/controllers/console/app/workflow_app_log.py
index 54640b1a19..d863747995 100644
--- a/api/controllers/console/app/workflow_app_log.py
+++ b/api/controllers/console/app/workflow_app_log.py
@@ -1,5 +1,4 @@
-from datetime import datetime
-
+from dateutil.parser import isoparse
from flask_restful import Resource, marshal_with, reqparse # type: ignore
from flask_restful.inputs import int_range # type: ignore
from sqlalchemy.orm import Session
@@ -41,10 +40,10 @@ class WorkflowAppLogApi(Resource):
args.status = WorkflowRunStatus(args.status) if args.status else None
if args.created_at__before:
- args.created_at__before = datetime.fromisoformat(args.created_at__before.replace("Z", "+00:00"))
+ args.created_at__before = isoparse(args.created_at__before)
if args.created_at__after:
- args.created_at__after = datetime.fromisoformat(args.created_at__after.replace("Z", "+00:00"))
+ args.created_at__after = isoparse(args.created_at__after)
# get paginate workflow app logs
workflow_app_service = WorkflowAppService()
diff --git a/api/controllers/console/auth/data_source_oauth.py b/api/controllers/console/auth/data_source_oauth.py
index e911c9a5e5..b4bd80fe2f 100644
--- a/api/controllers/console/auth/data_source_oauth.py
+++ b/api/controllers/console/auth/data_source_oauth.py
@@ -74,7 +74,9 @@ class OAuthDataSourceBinding(Resource):
if not oauth_provider:
return {"error": "Invalid provider"}, 400
if "code" in request.args:
- code = request.args.get("code")
+ code = request.args.get("code", "")
+ if not code:
+ return {"error": "Invalid code"}, 400
try:
oauth_provider.get_access_token(code)
except requests.exceptions.HTTPError as e:
diff --git a/api/controllers/console/auth/forgot_password.py b/api/controllers/console/auth/forgot_password.py
index dc0009f36e..d4a33645ab 100644
--- a/api/controllers/console/auth/forgot_password.py
+++ b/api/controllers/console/auth/forgot_password.py
@@ -16,7 +16,7 @@ from controllers.console.auth.error import (
PasswordMismatchError,
)
from controllers.console.error import AccountInFreezeError, AccountNotFound, EmailSendIpLimitError
-from controllers.console.wraps import setup_required
+from controllers.console.wraps import email_password_login_enabled, setup_required
from events.tenant_event import tenant_was_created
from extensions.ext_database import db
from libs.helper import email, extract_remote_ip
@@ -30,6 +30,7 @@ from services.feature_service import FeatureService
class ForgotPasswordSendEmailApi(Resource):
@setup_required
+ @email_password_login_enabled
def post(self):
parser = reqparse.RequestParser()
parser.add_argument("email", type=email, required=True, location="json")
@@ -62,6 +63,7 @@ class ForgotPasswordSendEmailApi(Resource):
class ForgotPasswordCheckApi(Resource):
@setup_required
+ @email_password_login_enabled
def post(self):
parser = reqparse.RequestParser()
parser.add_argument("email", type=str, required=True, location="json")
@@ -86,12 +88,21 @@ class ForgotPasswordCheckApi(Resource):
AccountService.add_forgot_password_error_rate_limit(args["email"])
raise EmailCodeError()
+ # Verified, revoke the first token
+ AccountService.revoke_reset_password_token(args["token"])
+
+ # Refresh token data by generating a new token
+ _, new_token = AccountService.generate_reset_password_token(
+ user_email, code=args["code"], additional_data={"phase": "reset"}
+ )
+
AccountService.reset_forgot_password_error_rate_limit(args["email"])
- return {"is_valid": True, "email": token_data.get("email")}
+ return {"is_valid": True, "email": token_data.get("email"), "token": new_token}
class ForgotPasswordResetApi(Resource):
@setup_required
+ @email_password_login_enabled
def post(self):
parser = reqparse.RequestParser()
parser.add_argument("token", type=str, required=True, nullable=False, location="json")
@@ -107,6 +118,9 @@ class ForgotPasswordResetApi(Resource):
reset_data = AccountService.get_reset_password_data(args["token"])
if not reset_data:
raise InvalidTokenError()
+ # Must use token in reset phase
+ if reset_data.get("phase", "") != "reset":
+ raise InvalidTokenError()
# Revoke token to prevent reuse
AccountService.revoke_reset_password_token(args["token"])
diff --git a/api/controllers/console/auth/login.py b/api/controllers/console/auth/login.py
index 41362e9fa2..16c1dcc441 100644
--- a/api/controllers/console/auth/login.py
+++ b/api/controllers/console/auth/login.py
@@ -22,7 +22,7 @@ from controllers.console.error import (
EmailSendIpLimitError,
NotAllowedCreateWorkspace,
)
-from controllers.console.wraps import setup_required
+from controllers.console.wraps import email_password_login_enabled, setup_required
from events.tenant_event import tenant_was_created
from libs.helper import email, extract_remote_ip
from libs.password import valid_password
@@ -38,6 +38,7 @@ class LoginApi(Resource):
"""Resource for user login."""
@setup_required
+ @email_password_login_enabled
def post(self):
"""Authenticate user and login."""
parser = reqparse.RequestParser()
@@ -110,6 +111,7 @@ class LogoutApi(Resource):
class ResetPasswordSendEmailApi(Resource):
@setup_required
+ @email_password_login_enabled
def post(self):
parser = reqparse.RequestParser()
parser.add_argument("email", type=email, required=True, location="json")
diff --git a/api/controllers/console/datasets/datasets.py b/api/controllers/console/datasets/datasets.py
index 4644ac6299..752d124735 100644
--- a/api/controllers/console/datasets/datasets.py
+++ b/api/controllers/console/datasets/datasets.py
@@ -664,6 +664,7 @@ class DatasetRetrievalSettingApi(Resource):
| VectorType.OPENGAUSS
| VectorType.OCEANBASE
| VectorType.TABLESTORE
+ | VectorType.HUAWEI_CLOUD
| VectorType.TENCENT
):
return {
@@ -710,6 +711,7 @@ class DatasetRetrievalSettingMockApi(Resource):
| VectorType.OCEANBASE
| VectorType.TABLESTORE
| VectorType.TENCENT
+ | VectorType.HUAWEI_CLOUD
):
return {
"retrieval_method": [
diff --git a/api/controllers/console/datasets/datasets_segments.py b/api/controllers/console/datasets/datasets_segments.py
index 1b38d0776a..696aaa94db 100644
--- a/api/controllers/console/datasets/datasets_segments.py
+++ b/api/controllers/console/datasets/datasets_segments.py
@@ -398,7 +398,7 @@ class DatasetDocumentSegmentBatchImportApi(Resource):
indexing_cache_key = "segment_batch_import_{}".format(job_id)
cache_result = redis_client.get(indexing_cache_key)
if cache_result is None:
- raise ValueError("The job is not exist.")
+ raise ValueError("The job does not exist.")
return {"job_id": job_id, "job_status": cache_result.decode()}, 200
diff --git a/api/controllers/console/explore/parameter.py b/api/controllers/console/explore/parameter.py
index 5bc74d16e7..bf9f0d6b28 100644
--- a/api/controllers/console/explore/parameter.py
+++ b/api/controllers/console/explore/parameter.py
@@ -1,10 +1,10 @@
from flask_restful import marshal_with # type: ignore
from controllers.common import fields
-from controllers.common import helpers as controller_helpers
from controllers.console import api
from controllers.console.app.error import AppUnavailableError
from controllers.console.explore.wraps import InstalledAppResource
+from core.app.app_config.common.parameters_mapping import get_parameters_from_feature_dict
from models.model import AppMode, InstalledApp
from services.app_service import AppService
@@ -36,9 +36,7 @@ class AppParameterApi(InstalledAppResource):
user_input_form = features_dict.get("user_input_form", [])
- return controller_helpers.get_parameters_from_feature_dict(
- features_dict=features_dict, user_input_form=user_input_form
- )
+ return get_parameters_from_feature_dict(features_dict=features_dict, user_input_form=user_input_form)
class ExploreAppMetaApi(InstalledAppResource):
diff --git a/api/controllers/console/workspace/endpoint.py b/api/controllers/console/workspace/endpoint.py
index a5bd2a4bcf..46dee20f8b 100644
--- a/api/controllers/console/workspace/endpoint.py
+++ b/api/controllers/console/workspace/endpoint.py
@@ -5,6 +5,7 @@ from werkzeug.exceptions import Forbidden
from controllers.console import api
from controllers.console.wraps import account_initialization_required, setup_required
from core.model_runtime.utils.encoders import jsonable_encoder
+from core.plugin.manager.exc import PluginPermissionDeniedError
from libs.login import login_required
from services.plugin.endpoint_service import EndpointService
@@ -28,15 +29,18 @@ class EndpointCreateApi(Resource):
settings = args["settings"]
name = args["name"]
- return {
- "success": EndpointService.create_endpoint(
- tenant_id=user.current_tenant_id,
- user_id=user.id,
- plugin_unique_identifier=plugin_unique_identifier,
- name=name,
- settings=settings,
- )
- }
+ try:
+ return {
+ "success": EndpointService.create_endpoint(
+ tenant_id=user.current_tenant_id,
+ user_id=user.id,
+ plugin_unique_identifier=plugin_unique_identifier,
+ name=name,
+ settings=settings,
+ )
+ }
+ except PluginPermissionDeniedError as e:
+ raise ValueError(e.description) from e
class EndpointListApi(Resource):
diff --git a/api/controllers/console/workspace/plugin.py b/api/controllers/console/workspace/plugin.py
index 302bc30905..e9c1884c60 100644
--- a/api/controllers/console/workspace/plugin.py
+++ b/api/controllers/console/workspace/plugin.py
@@ -249,6 +249,31 @@ class PluginInstallFromMarketplaceApi(Resource):
return jsonable_encoder(response)
+class PluginFetchMarketplacePkgApi(Resource):
+ @setup_required
+ @login_required
+ @account_initialization_required
+ @plugin_permission_required(install_required=True)
+ def get(self):
+ tenant_id = current_user.current_tenant_id
+
+ parser = reqparse.RequestParser()
+ parser.add_argument("plugin_unique_identifier", type=str, required=True, location="args")
+ args = parser.parse_args()
+
+ try:
+ return jsonable_encoder(
+ {
+ "manifest": PluginService.fetch_marketplace_pkg(
+ tenant_id,
+ args["plugin_unique_identifier"],
+ )
+ }
+ )
+ except PluginDaemonClientSideError as e:
+ raise ValueError(e)
+
+
class PluginFetchManifestApi(Resource):
@setup_required
@login_required
@@ -488,6 +513,7 @@ api.add_resource(PluginDeleteInstallTaskApi, "/workspaces/current/plugin/tasks/<
api.add_resource(PluginDeleteAllInstallTaskItemsApi, "/workspaces/current/plugin/tasks/delete_all")
api.add_resource(PluginDeleteInstallTaskItemApi, "/workspaces/current/plugin/tasks//delete/")
api.add_resource(PluginUninstallApi, "/workspaces/current/plugin/uninstall")
+api.add_resource(PluginFetchMarketplacePkgApi, "/workspaces/current/plugin/marketplace/pkg")
api.add_resource(PluginChangePermissionApi, "/workspaces/current/plugin/permission/change")
api.add_resource(PluginFetchPermissionApi, "/workspaces/current/plugin/permission/fetch")
diff --git a/api/controllers/console/wraps.py b/api/controllers/console/wraps.py
index 6caaae87f4..e5e8038ad7 100644
--- a/api/controllers/console/wraps.py
+++ b/api/controllers/console/wraps.py
@@ -210,3 +210,16 @@ def enterprise_license_required(view):
return view(*args, **kwargs)
return decorated
+
+
+def email_password_login_enabled(view):
+ @wraps(view)
+ def decorated(*args, **kwargs):
+ features = FeatureService.get_system_features()
+ if features.enable_email_password_login:
+ return view(*args, **kwargs)
+
+ # otherwise, return 403
+ abort(403)
+
+ return decorated
diff --git a/api/controllers/files/upload.py b/api/controllers/files/upload.py
index ca5ea54435..28ee0eecf4 100644
--- a/api/controllers/files/upload.py
+++ b/api/controllers/files/upload.py
@@ -1,3 +1,5 @@
+from mimetypes import guess_extension
+
from flask import request
from flask_restful import Resource, marshal_with # type: ignore
from werkzeug.exceptions import Forbidden
@@ -9,8 +11,8 @@ from controllers.files.error import UnsupportedFileTypeError
from controllers.inner_api.plugin.wraps import get_user
from controllers.service_api.app.error import FileTooLargeError
from core.file.helpers import verify_plugin_file_signature
+from core.tools.tool_file_manager import ToolFileManager
from fields.file_fields import file_fields
-from services.file_service import FileService
class PluginUploadFileApi(Resource):
@@ -51,19 +53,26 @@ class PluginUploadFileApi(Resource):
raise Forbidden("Invalid request.")
try:
- upload_file = FileService.upload_file(
- filename=filename,
- content=file.read(),
+ tool_file = ToolFileManager.create_file_by_raw(
+ user_id=user.id,
+ tenant_id=tenant_id,
+ file_binary=file.read(),
mimetype=mimetype,
- user=user,
- source=None,
+ filename=filename,
+ conversation_id=None,
)
+
+ extension = guess_extension(tool_file.mimetype) or ".bin"
+ preview_url = ToolFileManager.sign_file(tool_file_id=tool_file.id, extension=extension)
+ tool_file.mime_type = mimetype
+ tool_file.extension = extension
+ tool_file.preview_url = preview_url
except services.errors.file.FileTooLargeError as file_too_large_error:
raise FileTooLargeError(file_too_large_error.description)
except services.errors.file.UnsupportedFileTypeError:
raise UnsupportedFileTypeError()
- return upload_file, 201
+ return tool_file, 201
api.add_resource(PluginUploadFileApi, "/files/upload/for-plugin")
diff --git a/api/controllers/inner_api/plugin/plugin.py b/api/controllers/inner_api/plugin/plugin.py
index fe892922e9..061ad62a4a 100644
--- a/api/controllers/inner_api/plugin/plugin.py
+++ b/api/controllers/inner_api/plugin/plugin.py
@@ -13,6 +13,7 @@ from core.plugin.backwards_invocation.model import PluginModelBackwardsInvocatio
from core.plugin.backwards_invocation.node import PluginNodeBackwardsInvocation
from core.plugin.backwards_invocation.tool import PluginToolBackwardsInvocation
from core.plugin.entities.request import (
+ RequestFetchAppInfo,
RequestInvokeApp,
RequestInvokeEncrypt,
RequestInvokeLLM,
@@ -278,6 +279,17 @@ class PluginUploadFileRequestApi(Resource):
return BaseBackwardsInvocationResponse(data={"url": url}).model_dump()
+class PluginFetchAppInfoApi(Resource):
+ @setup_required
+ @plugin_inner_api_only
+ @get_user_tenant
+ @plugin_data(payload_type=RequestFetchAppInfo)
+ def post(self, user_model: Account | EndUser, tenant_model: Tenant, payload: RequestFetchAppInfo):
+ return BaseBackwardsInvocationResponse(
+ data=PluginAppBackwardsInvocation.fetch_app_info(payload.app_id, tenant_model.id)
+ ).model_dump()
+
+
api.add_resource(PluginInvokeLLMApi, "/invoke/llm")
api.add_resource(PluginInvokeTextEmbeddingApi, "/invoke/text-embedding")
api.add_resource(PluginInvokeRerankApi, "/invoke/rerank")
@@ -291,3 +303,4 @@ api.add_resource(PluginInvokeAppApi, "/invoke/app")
api.add_resource(PluginInvokeEncryptApi, "/invoke/encrypt")
api.add_resource(PluginInvokeSummaryApi, "/invoke/summary")
api.add_resource(PluginUploadFileRequestApi, "/upload/file/request")
+api.add_resource(PluginFetchAppInfoApi, "/fetch/app/info")
diff --git a/api/controllers/service_api/app/app.py b/api/controllers/service_api/app/app.py
index 8388e2045d..7131e8a310 100644
--- a/api/controllers/service_api/app/app.py
+++ b/api/controllers/service_api/app/app.py
@@ -1,10 +1,10 @@
from flask_restful import Resource, marshal_with # type: ignore
from controllers.common import fields
-from controllers.common import helpers as controller_helpers
from controllers.service_api import api
from controllers.service_api.app.error import AppUnavailableError
from controllers.service_api.wraps import validate_app_token
+from core.app.app_config.common.parameters_mapping import get_parameters_from_feature_dict
from models.model import App, AppMode
from services.app_service import AppService
@@ -32,9 +32,7 @@ class AppParameterApi(Resource):
user_input_form = features_dict.get("user_input_form", [])
- return controller_helpers.get_parameters_from_feature_dict(
- features_dict=features_dict, user_input_form=user_input_form
- )
+ return get_parameters_from_feature_dict(features_dict=features_dict, user_input_form=user_input_form)
class AppMetaApi(Resource):
diff --git a/api/controllers/service_api/app/workflow.py b/api/controllers/service_api/app/workflow.py
index 2854a43505..8b10a028f3 100644
--- a/api/controllers/service_api/app/workflow.py
+++ b/api/controllers/service_api/app/workflow.py
@@ -1,6 +1,6 @@
import logging
-from datetime import datetime
+from dateutil.parser import isoparse
from flask_restful import Resource, fields, marshal_with, reqparse # type: ignore
from flask_restful.inputs import int_range # type: ignore
from sqlalchemy.orm import Session
@@ -140,10 +140,10 @@ class WorkflowAppLogApi(Resource):
args.status = WorkflowRunStatus(args.status) if args.status else None
if args.created_at__before:
- args.created_at__before = datetime.fromisoformat(args.created_at__before.replace("Z", "+00:00"))
+ args.created_at__before = isoparse(args.created_at__before)
if args.created_at__after:
- args.created_at__after = datetime.fromisoformat(args.created_at__after.replace("Z", "+00:00"))
+ args.created_at__after = isoparse(args.created_at__after)
# get paginate workflow app logs
workflow_app_service = WorkflowAppService()
diff --git a/api/controllers/service_api/dataset/dataset.py b/api/controllers/service_api/dataset/dataset.py
index 8d470b8991..e1e6f3168f 100644
--- a/api/controllers/service_api/dataset/dataset.py
+++ b/api/controllers/service_api/dataset/dataset.py
@@ -13,6 +13,7 @@ from fields.dataset_fields import dataset_detail_fields
from libs.login import current_user
from models.dataset import Dataset, DatasetPermissionEnum
from services.dataset_service import DatasetPermissionService, DatasetService
+from services.entities.knowledge_entities.knowledge_entities import RetrievalModel
def _validate_name(name):
@@ -120,8 +121,11 @@ class DatasetListApi(DatasetApiResource):
nullable=True,
required=False,
)
- args = parser.parse_args()
+ parser.add_argument("retrieval_model", type=dict, required=False, nullable=True, location="json")
+ parser.add_argument("embedding_model", type=str, required=False, nullable=True, location="json")
+ parser.add_argument("embedding_model_provider", type=str, required=False, nullable=True, location="json")
+ args = parser.parse_args()
try:
dataset = DatasetService.create_empty_dataset(
tenant_id=tenant_id,
@@ -133,6 +137,11 @@ class DatasetListApi(DatasetApiResource):
provider=args["provider"],
external_knowledge_api_id=args["external_knowledge_api_id"],
external_knowledge_id=args["external_knowledge_id"],
+ embedding_model_provider=args["embedding_model_provider"],
+ embedding_model_name=args["embedding_model"],
+ retrieval_model=RetrievalModel(**args["retrieval_model"])
+ if args["retrieval_model"] is not None
+ else None,
)
except services.errors.dataset.DatasetNameDuplicateError:
raise DatasetNameDuplicateError()
diff --git a/api/controllers/service_api/dataset/document.py b/api/controllers/service_api/dataset/document.py
index 4cc92847fe..eec6afc9ef 100644
--- a/api/controllers/service_api/dataset/document.py
+++ b/api/controllers/service_api/dataset/document.py
@@ -49,7 +49,9 @@ class DocumentAddByTextApi(DatasetApiResource):
parser.add_argument(
"indexing_technique", type=str, choices=Dataset.INDEXING_TECHNIQUE_LIST, nullable=False, location="json"
)
- parser.add_argument("retrieval_model", type=dict, required=False, nullable=False, location="json")
+ parser.add_argument("retrieval_model", type=dict, required=False, nullable=True, location="json")
+ parser.add_argument("embedding_model", type=str, required=False, nullable=True, location="json")
+ parser.add_argument("embedding_model_provider", type=str, required=False, nullable=True, location="json")
args = parser.parse_args()
dataset_id = str(dataset_id)
@@ -57,7 +59,7 @@ class DocumentAddByTextApi(DatasetApiResource):
dataset = db.session.query(Dataset).filter(Dataset.tenant_id == tenant_id, Dataset.id == dataset_id).first()
if not dataset:
- raise ValueError("Dataset is not exist.")
+ raise ValueError("Dataset does not exist.")
if not dataset.indexing_technique and not args["indexing_technique"]:
raise ValueError("indexing_technique is required.")
@@ -114,7 +116,7 @@ class DocumentUpdateByTextApi(DatasetApiResource):
dataset = db.session.query(Dataset).filter(Dataset.tenant_id == tenant_id, Dataset.id == dataset_id).first()
if not dataset:
- raise ValueError("Dataset is not exist.")
+ raise ValueError("Dataset does not exist.")
# indexing_technique is already set in dataset since this is an update
args["indexing_technique"] = dataset.indexing_technique
@@ -172,7 +174,7 @@ class DocumentAddByFileApi(DatasetApiResource):
dataset = db.session.query(Dataset).filter(Dataset.tenant_id == tenant_id, Dataset.id == dataset_id).first()
if not dataset:
- raise ValueError("Dataset is not exist.")
+ raise ValueError("Dataset does not exist.")
if not dataset.indexing_technique and not args.get("indexing_technique"):
raise ValueError("indexing_technique is required.")
@@ -239,7 +241,7 @@ class DocumentUpdateByFileApi(DatasetApiResource):
dataset = db.session.query(Dataset).filter(Dataset.tenant_id == tenant_id, Dataset.id == dataset_id).first()
if not dataset:
- raise ValueError("Dataset is not exist.")
+ raise ValueError("Dataset does not exist.")
# indexing_technique is already set in dataset since this is an update
args["indexing_technique"] = dataset.indexing_technique
@@ -303,7 +305,7 @@ class DocumentDeleteApi(DatasetApiResource):
dataset = db.session.query(Dataset).filter(Dataset.tenant_id == tenant_id, Dataset.id == dataset_id).first()
if not dataset:
- raise ValueError("Dataset is not exist.")
+ raise ValueError("Dataset does not exist.")
document = DocumentService.get_document(dataset.id, document_id)
diff --git a/api/controllers/service_api/dataset/segment.py b/api/controllers/service_api/dataset/segment.py
index 3d5869c371..2a79e15cc5 100644
--- a/api/controllers/service_api/dataset/segment.py
+++ b/api/controllers/service_api/dataset/segment.py
@@ -122,6 +122,8 @@ class SegmentApi(DatasetApiResource):
tenant_id=current_user.current_tenant_id,
status_list=args["status"],
keyword=args["keyword"],
+ page=page,
+ limit=limit,
)
response = {
diff --git a/api/controllers/web/app.py b/api/controllers/web/app.py
index 20e071c834..a84b846112 100644
--- a/api/controllers/web/app.py
+++ b/api/controllers/web/app.py
@@ -1,10 +1,10 @@
from flask_restful import marshal_with # type: ignore
from controllers.common import fields
-from controllers.common import helpers as controller_helpers
from controllers.web import api
from controllers.web.error import AppUnavailableError
from controllers.web.wraps import WebApiResource
+from core.app.app_config.common.parameters_mapping import get_parameters_from_feature_dict
from models.model import App, AppMode
from services.app_service import AppService
@@ -31,9 +31,7 @@ class AppParameterApi(WebApiResource):
user_input_form = features_dict.get("user_input_form", [])
- return controller_helpers.get_parameters_from_feature_dict(
- features_dict=features_dict, user_input_form=user_input_form
- )
+ return get_parameters_from_feature_dict(features_dict=features_dict, user_input_form=user_input_form)
class AppMeta(WebApiResource):
diff --git a/api/controllers/web/message.py b/api/controllers/web/message.py
index 494b357d46..17e9a3990f 100644
--- a/api/controllers/web/message.py
+++ b/api/controllers/web/message.py
@@ -46,6 +46,7 @@ class MessageListApi(WebApiResource):
"retriever_resources": fields.List(fields.Nested(retriever_resource_fields)),
"created_at": TimestampField,
"agent_thoughts": fields.List(fields.Nested(agent_thought_fields)),
+ "metadata": fields.Raw(attribute="message_metadata_dict"),
"status": fields.String,
"error": fields.String,
}
diff --git a/api/core/agent/base_agent_runner.py b/api/core/agent/base_agent_runner.py
index 48c92ea2db..e648613605 100644
--- a/api/core/agent/base_agent_runner.py
+++ b/api/core/agent/base_agent_runner.py
@@ -21,14 +21,13 @@ from core.model_runtime.entities import (
AssistantPromptMessage,
LLMUsage,
PromptMessage,
- PromptMessageContent,
PromptMessageTool,
SystemPromptMessage,
TextPromptMessageContent,
ToolPromptMessage,
UserPromptMessage,
)
-from core.model_runtime.entities.message_entities import ImagePromptMessageContent
+from core.model_runtime.entities.message_entities import ImagePromptMessageContent, PromptMessageContentUnionTypes
from core.model_runtime.entities.model_entities import ModelFeature
from core.model_runtime.model_providers.__base.large_language_model import LargeLanguageModel
from core.prompt.utils.extract_thread_messages import extract_thread_messages
@@ -501,7 +500,7 @@ class BaseAgentRunner(AppRunner):
)
if not file_objs:
return UserPromptMessage(content=message.query)
- prompt_message_contents: list[PromptMessageContent] = []
+ prompt_message_contents: list[PromptMessageContentUnionTypes] = []
prompt_message_contents.append(TextPromptMessageContent(data=message.query))
for file in file_objs:
prompt_message_contents.append(
diff --git a/api/core/agent/cot_agent_runner.py b/api/core/agent/cot_agent_runner.py
index ae70ff2bf1..feb8abf6ef 100644
--- a/api/core/agent/cot_agent_runner.py
+++ b/api/core/agent/cot_agent_runner.py
@@ -191,7 +191,7 @@ class CotAgentRunner(BaseAgentRunner, ABC):
# action is final answer, return final answer directly
try:
if isinstance(scratchpad.action.action_input, dict):
- final_answer = json.dumps(scratchpad.action.action_input)
+ final_answer = json.dumps(scratchpad.action.action_input, ensure_ascii=False)
elif isinstance(scratchpad.action.action_input, str):
final_answer = scratchpad.action.action_input
else:
diff --git a/api/core/agent/cot_chat_agent_runner.py b/api/core/agent/cot_chat_agent_runner.py
index 7d407a4976..5ff89bdacb 100644
--- a/api/core/agent/cot_chat_agent_runner.py
+++ b/api/core/agent/cot_chat_agent_runner.py
@@ -5,12 +5,11 @@ from core.file import file_manager
from core.model_runtime.entities import (
AssistantPromptMessage,
PromptMessage,
- PromptMessageContent,
SystemPromptMessage,
TextPromptMessageContent,
UserPromptMessage,
)
-from core.model_runtime.entities.message_entities import ImagePromptMessageContent
+from core.model_runtime.entities.message_entities import ImagePromptMessageContent, PromptMessageContentUnionTypes
from core.model_runtime.utils.encoders import jsonable_encoder
@@ -40,7 +39,7 @@ class CotChatAgentRunner(CotAgentRunner):
Organize user query
"""
if self.files:
- prompt_message_contents: list[PromptMessageContent] = []
+ prompt_message_contents: list[PromptMessageContentUnionTypes] = []
prompt_message_contents.append(TextPromptMessageContent(data=query))
# get image detail config
diff --git a/api/core/agent/fc_agent_runner.py b/api/core/agent/fc_agent_runner.py
index f45fa5c66e..a1110e7709 100644
--- a/api/core/agent/fc_agent_runner.py
+++ b/api/core/agent/fc_agent_runner.py
@@ -15,14 +15,13 @@ from core.model_runtime.entities import (
LLMResultChunkDelta,
LLMUsage,
PromptMessage,
- PromptMessageContent,
PromptMessageContentType,
SystemPromptMessage,
TextPromptMessageContent,
ToolPromptMessage,
UserPromptMessage,
)
-from core.model_runtime.entities.message_entities import ImagePromptMessageContent
+from core.model_runtime.entities.message_entities import ImagePromptMessageContent, PromptMessageContentUnionTypes
from core.prompt.agent_history_prompt_transform import AgentHistoryPromptTransform
from core.tools.entities.tool_entities import ToolInvokeMeta
from core.tools.tool_engine import ToolEngine
@@ -395,7 +394,7 @@ class FunctionCallAgentRunner(BaseAgentRunner):
Organize user query
"""
if self.files:
- prompt_message_contents: list[PromptMessageContent] = []
+ prompt_message_contents: list[PromptMessageContentUnionTypes] = []
prompt_message_contents.append(TextPromptMessageContent(data=query))
# get image detail config
diff --git a/api/core/agent/plugin_entities.py b/api/core/agent/plugin_entities.py
index 6cf3975333..9c722baa23 100644
--- a/api/core/agent/plugin_entities.py
+++ b/api/core/agent/plugin_entities.py
@@ -52,6 +52,7 @@ class AgentStrategyParameter(PluginParameter):
return cast_parameter_value(self, value)
type: AgentStrategyParameterType = Field(..., description="The type of the parameter")
+ help: Optional[I18nObject] = None
def init_frontend_parameter(self, value: Any):
return init_frontend_parameter(self, self.type, value)
diff --git a/api/core/app/app_config/common/parameters_mapping/__init__.py b/api/core/app/app_config/common/parameters_mapping/__init__.py
new file mode 100644
index 0000000000..6f1a3bf045
--- /dev/null
+++ b/api/core/app/app_config/common/parameters_mapping/__init__.py
@@ -0,0 +1,45 @@
+from collections.abc import Mapping
+from typing import Any
+
+from configs import dify_config
+from constants import DEFAULT_FILE_NUMBER_LIMITS
+
+
+def get_parameters_from_feature_dict(
+ *, features_dict: Mapping[str, Any], user_input_form: list[dict[str, Any]]
+) -> Mapping[str, Any]:
+ """
+ Mapping from feature dict to webapp parameters
+ """
+ return {
+ "opening_statement": features_dict.get("opening_statement"),
+ "suggested_questions": features_dict.get("suggested_questions", []),
+ "suggested_questions_after_answer": features_dict.get("suggested_questions_after_answer", {"enabled": False}),
+ "speech_to_text": features_dict.get("speech_to_text", {"enabled": False}),
+ "text_to_speech": features_dict.get("text_to_speech", {"enabled": False}),
+ "retriever_resource": features_dict.get("retriever_resource", {"enabled": False}),
+ "annotation_reply": features_dict.get("annotation_reply", {"enabled": False}),
+ "more_like_this": features_dict.get("more_like_this", {"enabled": False}),
+ "user_input_form": user_input_form,
+ "sensitive_word_avoidance": features_dict.get(
+ "sensitive_word_avoidance", {"enabled": False, "type": "", "configs": []}
+ ),
+ "file_upload": features_dict.get(
+ "file_upload",
+ {
+ "image": {
+ "enabled": False,
+ "number_limits": DEFAULT_FILE_NUMBER_LIMITS,
+ "detail": "high",
+ "transfer_methods": ["remote_url", "local_file"],
+ }
+ },
+ ),
+ "system_parameters": {
+ "image_file_size_limit": dify_config.UPLOAD_IMAGE_FILE_SIZE_LIMIT,
+ "video_file_size_limit": dify_config.UPLOAD_VIDEO_FILE_SIZE_LIMIT,
+ "audio_file_size_limit": dify_config.UPLOAD_AUDIO_FILE_SIZE_LIMIT,
+ "file_size_limit": dify_config.UPLOAD_FILE_SIZE_LIMIT,
+ "workflow_file_upload_limit": dify_config.WORKFLOW_FILE_UPLOAD_LIMIT,
+ },
+ }
diff --git a/api/core/app/apps/advanced_chat/generate_task_pipeline.py b/api/core/app/apps/advanced_chat/generate_task_pipeline.py
index 66f2c754bb..3bf6c330db 100644
--- a/api/core/app/apps/advanced_chat/generate_task_pipeline.py
+++ b/api/core/app/apps/advanced_chat/generate_task_pipeline.py
@@ -320,10 +320,9 @@ class AdvancedChatAppGenerateTaskPipeline:
session=session, workflow_run_id=self._workflow_run_id
)
workflow_node_execution = self._workflow_cycle_manager._handle_workflow_node_execution_retried(
- session=session, workflow_run=workflow_run, event=event
+ workflow_run=workflow_run, event=event
)
node_retry_resp = self._workflow_cycle_manager._workflow_node_retry_to_stream_response(
- session=session,
event=event,
task_id=self._application_generate_entity.task_id,
workflow_node_execution=workflow_node_execution,
@@ -341,11 +340,10 @@ class AdvancedChatAppGenerateTaskPipeline:
session=session, workflow_run_id=self._workflow_run_id
)
workflow_node_execution = self._workflow_cycle_manager._handle_node_execution_start(
- session=session, workflow_run=workflow_run, event=event
+ workflow_run=workflow_run, event=event
)
node_start_resp = self._workflow_cycle_manager._workflow_node_start_to_stream_response(
- session=session,
event=event,
task_id=self._application_generate_entity.task_id,
workflow_node_execution=workflow_node_execution,
@@ -363,11 +361,10 @@ class AdvancedChatAppGenerateTaskPipeline:
with Session(db.engine, expire_on_commit=False) as session:
workflow_node_execution = self._workflow_cycle_manager._handle_workflow_node_execution_success(
- session=session, event=event
+ event=event
)
node_finish_resp = self._workflow_cycle_manager._workflow_node_finish_to_stream_response(
- session=session,
event=event,
task_id=self._application_generate_entity.task_id,
workflow_node_execution=workflow_node_execution,
@@ -383,18 +380,15 @@ class AdvancedChatAppGenerateTaskPipeline:
| QueueNodeInLoopFailedEvent
| QueueNodeExceptionEvent,
):
- with Session(db.engine, expire_on_commit=False) as session:
- workflow_node_execution = self._workflow_cycle_manager._handle_workflow_node_execution_failed(
- session=session, event=event
- )
+ workflow_node_execution = self._workflow_cycle_manager._handle_workflow_node_execution_failed(
+ event=event
+ )
- node_finish_resp = self._workflow_cycle_manager._workflow_node_finish_to_stream_response(
- session=session,
- event=event,
- task_id=self._application_generate_entity.task_id,
- workflow_node_execution=workflow_node_execution,
- )
- session.commit()
+ node_finish_resp = self._workflow_cycle_manager._workflow_node_finish_to_stream_response(
+ event=event,
+ task_id=self._application_generate_entity.task_id,
+ workflow_node_execution=workflow_node_execution,
+ )
if node_finish_resp:
yield node_finish_resp
diff --git a/api/core/app/apps/base_app_generator.py b/api/core/app/apps/base_app_generator.py
index 5d559b96d7..a83b75cc1a 100644
--- a/api/core/app/apps/base_app_generator.py
+++ b/api/core/app/apps/base_app_generator.py
@@ -17,6 +17,7 @@ class BaseAppGenerator:
user_inputs: Optional[Mapping[str, Any]],
variables: Sequence["VariableEntity"],
tenant_id: str,
+ strict_type_validation: bool = False,
) -> Mapping[str, Any]:
user_inputs = user_inputs or {}
# Filter input variables from form configuration, handle required fields, default values, and option values
@@ -37,6 +38,7 @@ class BaseAppGenerator:
allowed_file_extensions=entity_dictionary[k].allowed_file_extensions,
allowed_file_upload_methods=entity_dictionary[k].allowed_file_upload_methods,
),
+ strict_type_validation=strict_type_validation,
)
for k, v in user_inputs.items()
if isinstance(v, dict) and entity_dictionary[k].type == VariableEntityType.FILE
diff --git a/api/core/app/apps/message_based_app_generator.py b/api/core/app/apps/message_based_app_generator.py
index 64ec6ac0c4..b1f527c0f2 100644
--- a/api/core/app/apps/message_based_app_generator.py
+++ b/api/core/app/apps/message_based_app_generator.py
@@ -153,6 +153,7 @@ class MessageBasedAppGenerator(BaseAppGenerator):
query = application_generate_entity.query or "New conversation"
else:
query = next(iter(application_generate_entity.inputs.values()), "New conversation")
+ query = query or "New conversation"
conversation_name = (query[:20] + "…") if len(query) > 20 else query
if not conversation:
diff --git a/api/core/app/apps/workflow/app_generator.py b/api/core/app/apps/workflow/app_generator.py
index cc7bcdeee1..08986b16f0 100644
--- a/api/core/app/apps/workflow/app_generator.py
+++ b/api/core/app/apps/workflow/app_generator.py
@@ -92,6 +92,7 @@ class WorkflowAppGenerator(BaseAppGenerator):
mappings=files,
tenant_id=app_model.tenant_id,
config=file_extra_config,
+ strict_type_validation=True if invoke_from == InvokeFrom.SERVICE_API else False,
)
# convert to app config
@@ -114,7 +115,10 @@ class WorkflowAppGenerator(BaseAppGenerator):
app_config=app_config,
file_upload_config=file_extra_config,
inputs=self._prepare_user_inputs(
- user_inputs=inputs, variables=app_config.variables, tenant_id=app_model.tenant_id
+ user_inputs=inputs,
+ variables=app_config.variables,
+ tenant_id=app_model.tenant_id,
+ strict_type_validation=True if invoke_from == InvokeFrom.SERVICE_API else False,
),
files=list(system_files),
user_id=user.id,
diff --git a/api/core/app/apps/workflow/generate_task_pipeline.py b/api/core/app/apps/workflow/generate_task_pipeline.py
index 14441ada40..1f998edb6a 100644
--- a/api/core/app/apps/workflow/generate_task_pipeline.py
+++ b/api/core/app/apps/workflow/generate_task_pipeline.py
@@ -279,10 +279,9 @@ class WorkflowAppGenerateTaskPipeline:
session=session, workflow_run_id=self._workflow_run_id
)
workflow_node_execution = self._workflow_cycle_manager._handle_workflow_node_execution_retried(
- session=session, workflow_run=workflow_run, event=event
+ workflow_run=workflow_run, event=event
)
response = self._workflow_cycle_manager._workflow_node_retry_to_stream_response(
- session=session,
event=event,
task_id=self._application_generate_entity.task_id,
workflow_node_execution=workflow_node_execution,
@@ -300,10 +299,9 @@ class WorkflowAppGenerateTaskPipeline:
session=session, workflow_run_id=self._workflow_run_id
)
workflow_node_execution = self._workflow_cycle_manager._handle_node_execution_start(
- session=session, workflow_run=workflow_run, event=event
+ workflow_run=workflow_run, event=event
)
node_start_response = self._workflow_cycle_manager._workflow_node_start_to_stream_response(
- session=session,
event=event,
task_id=self._application_generate_entity.task_id,
workflow_node_execution=workflow_node_execution,
@@ -313,17 +311,14 @@ class WorkflowAppGenerateTaskPipeline:
if node_start_response:
yield node_start_response
elif isinstance(event, QueueNodeSucceededEvent):
- with Session(db.engine, expire_on_commit=False) as session:
- workflow_node_execution = self._workflow_cycle_manager._handle_workflow_node_execution_success(
- session=session, event=event
- )
- node_success_response = self._workflow_cycle_manager._workflow_node_finish_to_stream_response(
- session=session,
- event=event,
- task_id=self._application_generate_entity.task_id,
- workflow_node_execution=workflow_node_execution,
- )
- session.commit()
+ workflow_node_execution = self._workflow_cycle_manager._handle_workflow_node_execution_success(
+ event=event
+ )
+ node_success_response = self._workflow_cycle_manager._workflow_node_finish_to_stream_response(
+ event=event,
+ task_id=self._application_generate_entity.task_id,
+ workflow_node_execution=workflow_node_execution,
+ )
if node_success_response:
yield node_success_response
@@ -334,18 +329,14 @@ class WorkflowAppGenerateTaskPipeline:
| QueueNodeInLoopFailedEvent
| QueueNodeExceptionEvent,
):
- with Session(db.engine, expire_on_commit=False) as session:
- workflow_node_execution = self._workflow_cycle_manager._handle_workflow_node_execution_failed(
- session=session,
- event=event,
- )
- node_failed_response = self._workflow_cycle_manager._workflow_node_finish_to_stream_response(
- session=session,
- event=event,
- task_id=self._application_generate_entity.task_id,
- workflow_node_execution=workflow_node_execution,
- )
- session.commit()
+ workflow_node_execution = self._workflow_cycle_manager._handle_workflow_node_execution_failed(
+ event=event,
+ )
+ node_failed_response = self._workflow_cycle_manager._workflow_node_finish_to_stream_response(
+ event=event,
+ task_id=self._application_generate_entity.task_id,
+ workflow_node_execution=workflow_node_execution,
+ )
if node_failed_response:
yield node_failed_response
@@ -627,6 +618,7 @@ class WorkflowAppGenerateTaskPipeline:
workflow_app_log.created_by = self._user_id
session.add(workflow_app_log)
+ session.commit()
def _text_chunk_to_stream_response(
self, text: str, from_variable_selector: Optional[list[str]] = None
diff --git a/api/core/app/task_pipeline/workflow_cycle_manage.py b/api/core/app/task_pipeline/workflow_cycle_manage.py
index 4d629ca186..5ce9f737d1 100644
--- a/api/core/app/task_pipeline/workflow_cycle_manage.py
+++ b/api/core/app/task_pipeline/workflow_cycle_manage.py
@@ -6,7 +6,7 @@ from typing import Any, Optional, Union, cast
from uuid import uuid4
from sqlalchemy import func, select
-from sqlalchemy.orm import Session
+from sqlalchemy.orm import Session, sessionmaker
from core.app.entities.app_invoke_entities import AdvancedChatAppGenerateEntity, InvokeFrom, WorkflowAppGenerateEntity
from core.app.entities.queue_entities import (
@@ -49,12 +49,14 @@ from core.file import FILE_MODEL_IDENTITY, File
from core.model_runtime.utils.encoders import jsonable_encoder
from core.ops.entities.trace_entity import TraceTaskName
from core.ops.ops_trace_manager import TraceQueueManager, TraceTask
+from core.repository import RepositoryFactory
from core.tools.tool_manager import ToolManager
from core.workflow.entities.node_entities import NodeRunMetadataKey
from core.workflow.enums import SystemVariableKey
from core.workflow.nodes import NodeType
from core.workflow.nodes.tool.entities import ToolNodeData
from core.workflow.workflow_entry import WorkflowEntry
+from extensions.ext_database import db
from models.account import Account
from models.enums import CreatedByRole, WorkflowRunTriggeredFrom
from models.model import EndUser
@@ -80,6 +82,21 @@ class WorkflowCycleManage:
self._application_generate_entity = application_generate_entity
self._workflow_system_variables = workflow_system_variables
+ # Initialize the session factory and repository
+ # We use the global db engine instead of the session passed to methods
+ # Disable expire_on_commit to avoid the need for merging objects
+ self._session_factory = sessionmaker(bind=db.engine, expire_on_commit=False)
+ self._workflow_node_execution_repository = RepositoryFactory.create_workflow_node_execution_repository(
+ params={
+ "tenant_id": self._application_generate_entity.app_config.tenant_id,
+ "app_id": self._application_generate_entity.app_config.app_id,
+ "session_factory": self._session_factory,
+ }
+ )
+
+ # We'll still keep the cache for backward compatibility and performance
+ # but use the repository for database operations
+
def _handle_workflow_run_start(
self,
*,
@@ -254,19 +271,15 @@ class WorkflowCycleManage:
workflow_run.finished_at = datetime.now(UTC).replace(tzinfo=None)
workflow_run.exceptions_count = exceptions_count
- stmt = select(WorkflowNodeExecution.node_execution_id).where(
- WorkflowNodeExecution.tenant_id == workflow_run.tenant_id,
- WorkflowNodeExecution.app_id == workflow_run.app_id,
- WorkflowNodeExecution.workflow_id == workflow_run.workflow_id,
- WorkflowNodeExecution.triggered_from == WorkflowNodeExecutionTriggeredFrom.WORKFLOW_RUN.value,
- WorkflowNodeExecution.workflow_run_id == workflow_run.id,
- WorkflowNodeExecution.status == WorkflowNodeExecutionStatus.RUNNING.value,
+ # Use the instance repository to find running executions for a workflow run
+ running_workflow_node_executions = self._workflow_node_execution_repository.get_running_executions(
+ workflow_run_id=workflow_run.id
)
- ids = session.scalars(stmt).all()
- # Use self._get_workflow_node_execution here to make sure the cache is updated
- running_workflow_node_executions = [
- self._get_workflow_node_execution(session=session, node_execution_id=id) for id in ids if id
- ]
+
+ # Update the cache with the retrieved executions
+ for execution in running_workflow_node_executions:
+ if execution.node_execution_id:
+ self._workflow_node_executions[execution.node_execution_id] = execution
for workflow_node_execution in running_workflow_node_executions:
now = datetime.now(UTC).replace(tzinfo=None)
@@ -288,7 +301,7 @@ class WorkflowCycleManage:
return workflow_run
def _handle_node_execution_start(
- self, *, session: Session, workflow_run: WorkflowRun, event: QueueNodeStartedEvent
+ self, *, workflow_run: WorkflowRun, event: QueueNodeStartedEvent
) -> WorkflowNodeExecution:
workflow_node_execution = WorkflowNodeExecution()
workflow_node_execution.id = str(uuid4())
@@ -315,17 +328,14 @@ class WorkflowCycleManage:
)
workflow_node_execution.created_at = datetime.now(UTC).replace(tzinfo=None)
- session.add(workflow_node_execution)
+ # Use the instance repository to save the workflow node execution
+ self._workflow_node_execution_repository.save(workflow_node_execution)
self._workflow_node_executions[event.node_execution_id] = workflow_node_execution
return workflow_node_execution
- def _handle_workflow_node_execution_success(
- self, *, session: Session, event: QueueNodeSucceededEvent
- ) -> WorkflowNodeExecution:
- workflow_node_execution = self._get_workflow_node_execution(
- session=session, node_execution_id=event.node_execution_id
- )
+ def _handle_workflow_node_execution_success(self, *, event: QueueNodeSucceededEvent) -> WorkflowNodeExecution:
+ workflow_node_execution = self._get_workflow_node_execution(node_execution_id=event.node_execution_id)
inputs = WorkflowEntry.handle_special_values(event.inputs)
process_data = WorkflowEntry.handle_special_values(event.process_data)
outputs = WorkflowEntry.handle_special_values(event.outputs)
@@ -344,13 +354,13 @@ class WorkflowCycleManage:
workflow_node_execution.finished_at = finished_at
workflow_node_execution.elapsed_time = elapsed_time
- workflow_node_execution = session.merge(workflow_node_execution)
+ # Use the instance repository to update the workflow node execution
+ self._workflow_node_execution_repository.update(workflow_node_execution)
return workflow_node_execution
def _handle_workflow_node_execution_failed(
self,
*,
- session: Session,
event: QueueNodeFailedEvent
| QueueNodeInIterationFailedEvent
| QueueNodeInLoopFailedEvent
@@ -361,9 +371,7 @@ class WorkflowCycleManage:
:param event: queue node failed event
:return:
"""
- workflow_node_execution = self._get_workflow_node_execution(
- session=session, node_execution_id=event.node_execution_id
- )
+ workflow_node_execution = self._get_workflow_node_execution(node_execution_id=event.node_execution_id)
inputs = WorkflowEntry.handle_special_values(event.inputs)
process_data = WorkflowEntry.handle_special_values(event.process_data)
@@ -387,14 +395,14 @@ class WorkflowCycleManage:
workflow_node_execution.elapsed_time = elapsed_time
workflow_node_execution.execution_metadata = execution_metadata
- workflow_node_execution = session.merge(workflow_node_execution)
return workflow_node_execution
def _handle_workflow_node_execution_retried(
- self, *, session: Session, workflow_run: WorkflowRun, event: QueueNodeRetryEvent
+ self, *, workflow_run: WorkflowRun, event: QueueNodeRetryEvent
) -> WorkflowNodeExecution:
"""
Workflow node execution failed
+ :param workflow_run: workflow run
:param event: queue node failed event
:return:
"""
@@ -439,15 +447,12 @@ class WorkflowCycleManage:
workflow_node_execution.execution_metadata = execution_metadata
workflow_node_execution.index = event.node_run_index
- session.add(workflow_node_execution)
+ # Use the instance repository to save the workflow node execution
+ self._workflow_node_execution_repository.save(workflow_node_execution)
self._workflow_node_executions[event.node_execution_id] = workflow_node_execution
return workflow_node_execution
- #################################################
- # to stream responses #
- #################################################
-
def _workflow_start_to_stream_response(
self,
*,
@@ -455,7 +460,6 @@ class WorkflowCycleManage:
task_id: str,
workflow_run: WorkflowRun,
) -> WorkflowStartStreamResponse:
- # receive session to make sure the workflow_run won't be expired, need a more elegant way to handle this
_ = session
return WorkflowStartStreamResponse(
task_id=task_id,
@@ -521,14 +525,10 @@ class WorkflowCycleManage:
def _workflow_node_start_to_stream_response(
self,
*,
- session: Session,
event: QueueNodeStartedEvent,
task_id: str,
workflow_node_execution: WorkflowNodeExecution,
) -> Optional[NodeStartStreamResponse]:
- # receive session to make sure the workflow_run won't be expired, need a more elegant way to handle this
- _ = session
-
if workflow_node_execution.node_type in {NodeType.ITERATION.value, NodeType.LOOP.value}:
return None
if not workflow_node_execution.workflow_run_id:
@@ -571,7 +571,6 @@ class WorkflowCycleManage:
def _workflow_node_finish_to_stream_response(
self,
*,
- session: Session,
event: QueueNodeSucceededEvent
| QueueNodeFailedEvent
| QueueNodeInIterationFailedEvent
@@ -580,8 +579,6 @@ class WorkflowCycleManage:
task_id: str,
workflow_node_execution: WorkflowNodeExecution,
) -> Optional[NodeFinishStreamResponse]:
- # receive session to make sure the workflow_run won't be expired, need a more elegant way to handle this
- _ = session
if workflow_node_execution.node_type in {NodeType.ITERATION.value, NodeType.LOOP.value}:
return None
if not workflow_node_execution.workflow_run_id:
@@ -621,13 +618,10 @@ class WorkflowCycleManage:
def _workflow_node_retry_to_stream_response(
self,
*,
- session: Session,
event: QueueNodeRetryEvent,
task_id: str,
workflow_node_execution: WorkflowNodeExecution,
) -> Optional[Union[NodeRetryStreamResponse, NodeFinishStreamResponse]]:
- # receive session to make sure the workflow_run won't be expired, need a more elegant way to handle this
- _ = session
if workflow_node_execution.node_type in {NodeType.ITERATION.value, NodeType.LOOP.value}:
return None
if not workflow_node_execution.workflow_run_id:
@@ -668,7 +662,6 @@ class WorkflowCycleManage:
def _workflow_parallel_branch_start_to_stream_response(
self, *, session: Session, task_id: str, workflow_run: WorkflowRun, event: QueueParallelBranchRunStartedEvent
) -> ParallelBranchStartStreamResponse:
- # receive session to make sure the workflow_run won't be expired, need a more elegant way to handle this
_ = session
return ParallelBranchStartStreamResponse(
task_id=task_id,
@@ -692,7 +685,6 @@ class WorkflowCycleManage:
workflow_run: WorkflowRun,
event: QueueParallelBranchRunSucceededEvent | QueueParallelBranchRunFailedEvent,
) -> ParallelBranchFinishedStreamResponse:
- # receive session to make sure the workflow_run won't be expired, need a more elegant way to handle this
_ = session
return ParallelBranchFinishedStreamResponse(
task_id=task_id,
@@ -713,7 +705,6 @@ class WorkflowCycleManage:
def _workflow_iteration_start_to_stream_response(
self, *, session: Session, task_id: str, workflow_run: WorkflowRun, event: QueueIterationStartEvent
) -> IterationNodeStartStreamResponse:
- # receive session to make sure the workflow_run won't be expired, need a more elegant way to handle this
_ = session
return IterationNodeStartStreamResponse(
task_id=task_id,
@@ -735,7 +726,6 @@ class WorkflowCycleManage:
def _workflow_iteration_next_to_stream_response(
self, *, session: Session, task_id: str, workflow_run: WorkflowRun, event: QueueIterationNextEvent
) -> IterationNodeNextStreamResponse:
- # receive session to make sure the workflow_run won't be expired, need a more elegant way to handle this
_ = session
return IterationNodeNextStreamResponse(
task_id=task_id,
@@ -759,7 +749,6 @@ class WorkflowCycleManage:
def _workflow_iteration_completed_to_stream_response(
self, *, session: Session, task_id: str, workflow_run: WorkflowRun, event: QueueIterationCompletedEvent
) -> IterationNodeCompletedStreamResponse:
- # receive session to make sure the workflow_run won't be expired, need a more elegant way to handle this
_ = session
return IterationNodeCompletedStreamResponse(
task_id=task_id,
@@ -790,7 +779,6 @@ class WorkflowCycleManage:
def _workflow_loop_start_to_stream_response(
self, *, session: Session, task_id: str, workflow_run: WorkflowRun, event: QueueLoopStartEvent
) -> LoopNodeStartStreamResponse:
- # receive session to make sure the workflow_run won't be expired, need a more elegant way to handle this
_ = session
return LoopNodeStartStreamResponse(
task_id=task_id,
@@ -812,7 +800,6 @@ class WorkflowCycleManage:
def _workflow_loop_next_to_stream_response(
self, *, session: Session, task_id: str, workflow_run: WorkflowRun, event: QueueLoopNextEvent
) -> LoopNodeNextStreamResponse:
- # receive session to make sure the workflow_run won't be expired, need a more elegant way to handle this
_ = session
return LoopNodeNextStreamResponse(
task_id=task_id,
@@ -836,7 +823,6 @@ class WorkflowCycleManage:
def _workflow_loop_completed_to_stream_response(
self, *, session: Session, task_id: str, workflow_run: WorkflowRun, event: QueueLoopCompletedEvent
) -> LoopNodeCompletedStreamResponse:
- # receive session to make sure the workflow_run won't be expired, need a more elegant way to handle this
_ = session
return LoopNodeCompletedStreamResponse(
task_id=task_id,
@@ -934,11 +920,22 @@ class WorkflowCycleManage:
return workflow_run
- def _get_workflow_node_execution(self, session: Session, node_execution_id: str) -> WorkflowNodeExecution:
- if node_execution_id not in self._workflow_node_executions:
+ def _get_workflow_node_execution(self, node_execution_id: str) -> WorkflowNodeExecution:
+ # First check the cache for performance
+ if node_execution_id in self._workflow_node_executions:
+ cached_execution = self._workflow_node_executions[node_execution_id]
+ # No need to merge with session since expire_on_commit=False
+ return cached_execution
+
+ # If not in cache, use the instance repository to get by node_execution_id
+ execution = self._workflow_node_execution_repository.get_by_node_execution_id(node_execution_id)
+
+ if not execution:
raise ValueError(f"Workflow node execution not found: {node_execution_id}")
- cached_workflow_node_execution = self._workflow_node_executions[node_execution_id]
- return session.merge(cached_workflow_node_execution)
+
+ # Update cache
+ self._workflow_node_executions[node_execution_id] = execution
+ return execution
def _handle_agent_log(self, task_id: str, event: QueueAgentLogEvent) -> AgentLogStreamResponse:
"""
diff --git a/api/core/callback_handler/index_tool_callback_handler.py b/api/core/callback_handler/index_tool_callback_handler.py
index 64c734f626..56859df7f4 100644
--- a/api/core/callback_handler/index_tool_callback_handler.py
+++ b/api/core/callback_handler/index_tool_callback_handler.py
@@ -6,7 +6,6 @@ from core.rag.models.document import Document
from extensions.ext_database import db
from models.dataset import ChildChunk, DatasetQuery, DocumentSegment
from models.dataset import Document as DatasetDocument
-from models.model import DatasetRetrieverResource
class DatasetIndexToolCallbackHandler:
@@ -71,29 +70,6 @@ class DatasetIndexToolCallbackHandler:
def return_retriever_resource_info(self, resource: list):
"""Handle return_retriever_resource_info."""
- if resource and len(resource) > 0:
- for item in resource:
- dataset_retriever_resource = DatasetRetrieverResource(
- message_id=self._message_id,
- position=item.get("position") or 0,
- dataset_id=item.get("dataset_id"),
- dataset_name=item.get("dataset_name"),
- document_id=item.get("document_id"),
- document_name=item.get("document_name"),
- data_source_type=item.get("data_source_type"),
- segment_id=item.get("segment_id"),
- score=item.get("score") if "score" in item else None,
- hit_count=item.get("hit_count") if "hit_count" in item else None,
- word_count=item.get("word_count") if "word_count" in item else None,
- segment_position=item.get("segment_position") if "segment_position" in item else None,
- index_node_hash=item.get("index_node_hash") if "index_node_hash" in item else None,
- content=item.get("content"),
- retriever_from=item.get("retriever_from"),
- created_by=self._user_id,
- )
- db.session.add(dataset_retriever_resource)
- db.session.commit()
-
self._queue_manager.publish(
QueueRetrieverResourcesEvent(retriever_resources=resource), PublishFrom.APPLICATION_MANAGER
)
diff --git a/api/core/file/file_manager.py b/api/core/file/file_manager.py
index 4ebe997ac5..9a204e9ff6 100644
--- a/api/core/file/file_manager.py
+++ b/api/core/file/file_manager.py
@@ -7,9 +7,9 @@ from core.model_runtime.entities import (
AudioPromptMessageContent,
DocumentPromptMessageContent,
ImagePromptMessageContent,
- MultiModalPromptMessageContent,
VideoPromptMessageContent,
)
+from core.model_runtime.entities.message_entities import PromptMessageContentUnionTypes
from extensions.ext_storage import storage
from . import helpers
@@ -43,7 +43,7 @@ def to_prompt_message_content(
/,
*,
image_detail_config: ImagePromptMessageContent.DETAIL | None = None,
-) -> MultiModalPromptMessageContent:
+) -> PromptMessageContentUnionTypes:
if f.extension is None:
raise ValueError("Missing file extension")
if f.mime_type is None:
@@ -58,7 +58,7 @@ def to_prompt_message_content(
if f.type == FileType.IMAGE:
params["detail"] = image_detail_config or ImagePromptMessageContent.DETAIL.LOW
- prompt_class_map: Mapping[FileType, type[MultiModalPromptMessageContent]] = {
+ prompt_class_map: Mapping[FileType, type[PromptMessageContentUnionTypes]] = {
FileType.IMAGE: ImagePromptMessageContent,
FileType.AUDIO: AudioPromptMessageContent,
FileType.VIDEO: VideoPromptMessageContent,
diff --git a/api/core/helper/ssrf_proxy.py b/api/core/helper/ssrf_proxy.py
index 969cd112ee..11f245812e 100644
--- a/api/core/helper/ssrf_proxy.py
+++ b/api/core/helper/ssrf_proxy.py
@@ -48,25 +48,26 @@ def make_request(method, url, max_retries=SSRF_DEFAULT_MAX_RETRIES, **kwargs):
write=dify_config.SSRF_DEFAULT_WRITE_TIME_OUT,
)
+ if "ssl_verify" not in kwargs:
+ kwargs["ssl_verify"] = HTTP_REQUEST_NODE_SSL_VERIFY
+
+ ssl_verify = kwargs.pop("ssl_verify")
+
retries = 0
while retries <= max_retries:
try:
if dify_config.SSRF_PROXY_ALL_URL:
- with httpx.Client(proxy=dify_config.SSRF_PROXY_ALL_URL, verify=HTTP_REQUEST_NODE_SSL_VERIFY) as client:
+ with httpx.Client(proxy=dify_config.SSRF_PROXY_ALL_URL, verify=ssl_verify) as client:
response = client.request(method=method, url=url, **kwargs)
elif dify_config.SSRF_PROXY_HTTP_URL and dify_config.SSRF_PROXY_HTTPS_URL:
proxy_mounts = {
- "http://": httpx.HTTPTransport(
- proxy=dify_config.SSRF_PROXY_HTTP_URL, verify=HTTP_REQUEST_NODE_SSL_VERIFY
- ),
- "https://": httpx.HTTPTransport(
- proxy=dify_config.SSRF_PROXY_HTTPS_URL, verify=HTTP_REQUEST_NODE_SSL_VERIFY
- ),
+ "http://": httpx.HTTPTransport(proxy=dify_config.SSRF_PROXY_HTTP_URL, verify=ssl_verify),
+ "https://": httpx.HTTPTransport(proxy=dify_config.SSRF_PROXY_HTTPS_URL, verify=ssl_verify),
}
- with httpx.Client(mounts=proxy_mounts, verify=HTTP_REQUEST_NODE_SSL_VERIFY) as client:
+ with httpx.Client(mounts=proxy_mounts, verify=ssl_verify) as client:
response = client.request(method=method, url=url, **kwargs)
else:
- with httpx.Client(verify=HTTP_REQUEST_NODE_SSL_VERIFY) as client:
+ with httpx.Client(verify=ssl_verify) as client:
response = client.request(method=method, url=url, **kwargs)
if response.status_code not in STATUS_FORCELIST:
diff --git a/api/core/llm_generator/llm_generator.py b/api/core/llm_generator/llm_generator.py
index 75687f9ae3..d5d2ca60fa 100644
--- a/api/core/llm_generator/llm_generator.py
+++ b/api/core/llm_generator/llm_generator.py
@@ -10,6 +10,7 @@ from core.llm_generator.prompts import (
GENERATOR_QA_PROMPT,
JAVASCRIPT_CODE_GENERATOR_PROMPT_TEMPLATE,
PYTHON_CODE_GENERATOR_PROMPT_TEMPLATE,
+ SYSTEM_STRUCTURED_OUTPUT_GENERATE,
WORKFLOW_RULE_CONFIG_PROMPT_GENERATE_TEMPLATE,
)
from core.model_manager import ModelManager
@@ -340,3 +341,37 @@ class LLMGenerator:
answer = cast(str, response.message.content)
return answer.strip()
+
+ @classmethod
+ def generate_structured_output(cls, tenant_id: str, instruction: str, model_config: dict):
+ model_manager = ModelManager()
+ model_instance = model_manager.get_model_instance(
+ tenant_id=tenant_id,
+ model_type=ModelType.LLM,
+ provider=model_config.get("provider", ""),
+ model=model_config.get("name", ""),
+ )
+
+ prompt_messages = [
+ SystemPromptMessage(content=SYSTEM_STRUCTURED_OUTPUT_GENERATE),
+ UserPromptMessage(content=instruction),
+ ]
+ model_parameters = model_config.get("model_parameters", {})
+
+ try:
+ response = cast(
+ LLMResult,
+ model_instance.invoke_llm(
+ prompt_messages=list(prompt_messages), model_parameters=model_parameters, stream=False
+ ),
+ )
+
+ generated_json_schema = cast(str, response.message.content)
+ return {"output": generated_json_schema, "error": ""}
+
+ except InvokeError as e:
+ error = str(e)
+ return {"output": "", "error": f"Failed to generate JSON Schema. Error: {error}"}
+ except Exception as e:
+ logging.exception(f"Failed to invoke LLM model, model: {model_config.get('name')}")
+ return {"output": "", "error": f"An unexpected error occurred: {str(e)}"}
diff --git a/api/core/llm_generator/prompts.py b/api/core/llm_generator/prompts.py
index cf20e60c82..fad7cea01c 100644
--- a/api/core/llm_generator/prompts.py
+++ b/api/core/llm_generator/prompts.py
@@ -1,7 +1,7 @@
# Written by YORKI MINAKO🤡, Edited by Xiaoyi
CONVERSATION_TITLE_PROMPT = """You need to decompose the user's input into "subject" and "intention" in order to accurately figure out what the user's input language actually is.
-Notice: the language type user use could be diverse, which can be English, Chinese, Italian, Español, Arabic, Japanese, French, and etc.
-MAKE SURE your output is the SAME language as the user's input!
+Notice: the language type user uses could be diverse, which can be English, Chinese, Italian, Español, Arabic, Japanese, French, and etc.
+ENSURE your output is in the SAME language as the user's input!
Your output is restricted only to: (Input language) Intention + Subject(short as possible)
Your output MUST be a valid JSON.
@@ -19,7 +19,7 @@ User Input: hi, yesterday i had some burgers.
example 2:
User Input: hello
{
- "Language Type": "The user's input is written in pure English",
+ "Language Type": "The user's input is pure English",
"Your Reasoning": "The language of my output must be pure English.",
"Your Output": "Greeting myself☺️"
}
@@ -46,7 +46,7 @@ example 5:
User Input: why小红的年龄is老than小明?
{
"Language Type": "The user's input is English-Chinese mixed",
- "Your Reasoning": "The English parts are subjective particles, the main intention is written in Chinese, besides, Chinese occupies a greater \"actual meaning\" than English, so the language of my output must be using Chinese.",
+ "Your Reasoning": "The English parts are filler words, the main intention is written in Chinese, besides, Chinese occupies a greater \"actual meaning\" than English, so the language of my output must be using Chinese.",
"Your Output": "询问小红和小明的年龄"
}
@@ -114,6 +114,13 @@ JAVASCRIPT_CODE_GENERATOR_PROMPT_TEMPLATE = (
"4. The returned object should contain at least one key-value pair.\n\n"
"5. The returned object should always be in the format: {result: ...}\n\n"
"Example:\n"
+ "/**\n"
+ " * Multiplies two numbers together.\n"
+ " *\n"
+ " * @param {number} arg1 - The first number to multiply.\n"
+ " * @param {number} arg2 - The second number to multiply.\n"
+ " * @returns {{ result: number }} The result of the multiplication.\n"
+ " */\n"
"function main(arg1, arg2) {\n"
" return {\n"
" result: arg1 * arg2\n"
@@ -130,7 +137,7 @@ JAVASCRIPT_CODE_GENERATOR_PROMPT_TEMPLATE = (
SUGGESTED_QUESTIONS_AFTER_ANSWER_INSTRUCTION_PROMPT = (
"Please help me predict the three most likely questions that human would ask, "
- "and keeping each question under 20 characters.\n"
+ "and keep each question under 20 characters.\n"
"MAKE SURE your output is the SAME language as the Assistant's latest response. "
"The output must be an array in JSON format following the specified schema:\n"
'["question1","question2","question3"]\n'
@@ -157,9 +164,9 @@ Here is a task description for which I would like you to create a high-quality p
Based on task description, please create a well-structured prompt template that another AI could use to consistently complete the task. The prompt template should include:
- Do not include or