diff --git a/.devcontainer/post_create_command.sh b/.devcontainer/post_create_command.sh
index d879876d8a..cc8eb552b0 100755
--- a/.devcontainer/post_create_command.sh
+++ b/.devcontainer/post_create_command.sh
@@ -1,13 +1,13 @@
#!/bin/bash
-npm add -g pnpm@9.12.2
+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 up -d"' >> ~/.bashrc
-echo 'alias stop-containers="cd /workspaces/dify/docker && docker-compose -f docker-compose.middleware.yaml -p dify down"' >> ~/.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
source /home/vscode/.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 b9547b6452..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,35 +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: Run mypy
- run: |
- poetry run -C api python -m mypy --install-types --non-interactive .
+ - name: MyPy Cache
+ uses: actions/cache@v4
+ with:
+ path: api/.mypy_cache
+ key: mypy-${{ matrix.python-version }}-${{ runner.os }}-${{ hashFiles('api/uv.lock') }}
+
+ - name: Run MyPy Checks
+ run: dev/mypy-check
- name: Set up dotenvs
run: |
@@ -75,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 d73a782c93..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
@@ -82,7 +83,7 @@ jobs:
uses: actions/setup-node@v4
if: steps.changed-files.outputs.any_changed == 'true'
with:
- node-version: 20
+ node-version: 22
cache: pnpm
cache-dependency-path: ./web/package.json
@@ -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
@@ -153,6 +152,7 @@ jobs:
env:
BASH_SEVERITY: warning
DEFAULT_BRANCH: main
+ FILTER_REGEX_INCLUDE: pnpm-lock.yaml
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
IGNORE_GENERATED_FILES: true
IGNORE_GITIGNORED_FILES: true
diff --git a/.github/workflows/tool-test-sdks.yaml b/.github/workflows/tool-test-sdks.yaml
index 93edb2737a..b1ccd7417a 100644
--- a/.github/workflows/tool-test-sdks.yaml
+++ b/.github/workflows/tool-test-sdks.yaml
@@ -18,7 +18,7 @@ jobs:
strategy:
matrix:
- node-version: [16, 18, 20]
+ node-version: [16, 18, 20, 22]
defaults:
run:
@@ -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/translate-i18n-base-on-english.yml b/.github/workflows/translate-i18n-base-on-english.yml
index 80b78a1311..3f8082eb69 100644
--- a/.github/workflows/translate-i18n-base-on-english.yml
+++ b/.github/workflows/translate-i18n-base-on-english.yml
@@ -33,7 +33,7 @@ jobs:
- name: Set up Node.js
if: env.FILES_CHANGED == 'true'
- uses: actions/setup-node@v2
+ uses: actions/setup-node@v4
with:
node-version: 'lts/*'
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 acee26af2f..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
@@ -31,7 +30,9 @@ jobs:
uses: tj-actions/changed-files@v45
with:
files: web/**
+
- name: Install pnpm
+ if: steps.changed-files.outputs.any_changed == 'true'
uses: pnpm/action-setup@v4
with:
version: 10
@@ -41,7 +42,7 @@ jobs:
uses: actions/setup-node@v4
if: steps.changed-files.outputs.any_changed == 'true'
with:
- node-version: 20
+ node-version: 22
cache: pnpm
cache-dependency-path: ./web/package.json
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 33e34423ff..973629f459 100644
--- a/README_CN.md
+++ b/README_CN.md
@@ -4,7 +4,7 @@
Dify 云服务 ·
自托管 ·
文档 ·
- (需用英文)常见问题解答 / 联系团队
+ Dify 产品形态总览
@@ -254,8 +254,6 @@ docker compose up -d
- [Discord](https://discord.gg/FngNHpbcY7)。👉:分享您的应用程序并与社区交流。
- [X(Twitter)](https://twitter.com/dify_ai)。👉:分享您的应用程序并与社区交流。
- [商业许可](mailto:business@dify.ai?subject=[GitHub]Business%20License%20Inquiry)。👉:有关商业用途许可 Dify.AI 的商业咨询。
- - [微信]() 👉:扫描下方二维码,添加微信好友,备注 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 e7e704e135..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
@@ -189,6 +190,7 @@ TENCENT_VECTOR_DB_USERNAME=dify
TENCENT_VECTOR_DB_DATABASE=dify
TENCENT_VECTOR_DB_SHARD=1
TENCENT_VECTOR_DB_REPLICAS=2
+TENCENT_VECTOR_DB_ENABLE_HYBRID_SEARCH=false
# ElasticSearch configuration
ELASTICSEARCH_HOST=127.0.0.1
@@ -325,6 +327,7 @@ UPLOAD_AUDIO_FILE_SIZE_LIMIT=50
MULTIMODAL_SEND_FORMAT=base64
PROMPT_GENERATION_MAX_TOKENS=512
CODE_GENERATION_MAX_TOKENS=1024
+PLUGIN_BASED_TOKEN_COUNTING_ENABLED=false
# Mail configuration, support: resend, smtp
MAIL_TYPE=
@@ -421,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
@@ -461,3 +470,19 @@ CREATE_TIDB_SERVICE_JOB_ENABLED=false
MAX_SUBMIT_COUNT=100
# Lockout duration in seconds
LOGIN_LOCKOUT_DURATION=86400
+
+# Enable OpenTelemetry
+ENABLE_OTEL=false
+OTLP_BASE_ENDPOINT=http://localhost:4318
+OTLP_API_KEY=
+OTEL_EXPORTER_TYPE=otlp
+OTEL_SAMPLING_RATE=0.1
+OTEL_BATCH_EXPORT_SCHEDULE_DELAY=5000
+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
+
+# 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 52ae05583a..9648d770ab 100644
--- a/api/app_factory.py
+++ b/api/app_factory.py
@@ -51,8 +51,11 @@ def initialize_extensions(app: DifyApp):
ext_login,
ext_mail,
ext_migrate,
+ ext_otel,
+ ext_otel_patch,
ext_proxy_fix,
ext_redis,
+ ext_repositories,
ext_sentry,
ext_set_secretkey,
ext_storage,
@@ -73,6 +76,7 @@ def initialize_extensions(app: DifyApp):
ext_migrate,
ext_redis,
ext_storage,
+ ext_repositories,
ext_celery,
ext_login,
ext_mail,
@@ -81,6 +85,8 @@ 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:
short_name = ext.__name__.split(".")[-1]
diff --git a/api/configs/app_config.py b/api/configs/app_config.py
index ac1ce9db10..3a3ad35ee7 100644
--- a/api/configs/app_config.py
+++ b/api/configs/app_config.py
@@ -9,9 +9,11 @@ from .enterprise import EnterpriseFeatureConfig
from .extra import ExtraServiceConfig
from .feature import FeatureConfig
from .middleware import MiddlewareConfig
+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__)
@@ -33,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 {}
@@ -59,6 +63,8 @@ class DifyConfig(
MiddlewareConfig,
# Extra service configs
ExtraServiceConfig,
+ # Observability configs
+ ObservabilityConfig,
# Remote source configs
RemoteSettingsSourceConfig,
# Enterprise feature configs
diff --git a/api/configs/feature/__init__.py b/api/configs/feature/__init__.py
index 46ded0244f..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):
@@ -442,7 +442,7 @@ class LoggingConfig(BaseSettings):
class ModelLoadBalanceConfig(BaseSettings):
"""
- Configuration for model load balancing
+ Configuration for model load balancing and token counting
"""
MODEL_LB_ENABLED: bool = Field(
@@ -450,6 +450,11 @@ class ModelLoadBalanceConfig(BaseSettings):
default=False,
)
+ PLUGIN_BASED_TOKEN_COUNTING_ENABLED: bool = Field(
+ description="Enable or disable plugin based token counting. If disabled, token counting will return 0.",
+ default=False,
+ )
+
class BillingConfig(BaseSettings):
"""
@@ -514,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):
"""
@@ -848,6 +858,11 @@ class AccountConfig(BaseSettings):
default=5,
)
+ EDUCATION_ENABLED: bool = Field(
+ description="whether to enable education identity",
+ default=False,
+ )
+
class FeatureConfig(
# place the configs in alphabet order
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/middleware/vdb/tencent_vector_config.py b/api/configs/middleware/vdb/tencent_vector_config.py
index 9cf4d07f6f..a51823c3f3 100644
--- a/api/configs/middleware/vdb/tencent_vector_config.py
+++ b/api/configs/middleware/vdb/tencent_vector_config.py
@@ -48,3 +48,8 @@ class TencentVectorDBConfig(BaseSettings):
description="Name of the specific Tencent Vector Database to connect to",
default=None,
)
+
+ TENCENT_VECTOR_DB_ENABLE_HYBRID_SEARCH: bool = Field(
+ description="Enable hybrid search features",
+ default=False,
+ )
diff --git a/api/configs/observability/__init__.py b/api/configs/observability/__init__.py
new file mode 100644
index 0000000000..8c6f21e28b
--- /dev/null
+++ b/api/configs/observability/__init__.py
@@ -0,0 +1,9 @@
+from configs.observability.otel.otel_config import OTelConfig
+
+
+class ObservabilityConfig(OTelConfig):
+ """
+ Observability configuration settings
+ """
+
+ pass
diff --git a/api/configs/observability/otel/otel_config.py b/api/configs/observability/otel/otel_config.py
new file mode 100644
index 0000000000..568a800d10
--- /dev/null
+++ b/api/configs/observability/otel/otel_config.py
@@ -0,0 +1,44 @@
+from pydantic import Field
+from pydantic_settings import BaseSettings
+
+
+class OTelConfig(BaseSettings):
+ """
+ OpenTelemetry configuration settings
+ """
+
+ ENABLE_OTEL: bool = Field(
+ description="Whether to enable OpenTelemetry",
+ default=False,
+ )
+
+ OTLP_BASE_ENDPOINT: str = Field(
+ description="OTLP base endpoint",
+ default="http://localhost:4318",
+ )
+
+ OTLP_API_KEY: str = Field(
+ description="OTLP API key",
+ default="",
+ )
+
+ OTEL_EXPORTER_TYPE: str = Field(
+ description="OTEL exporter type",
+ default="otlp",
+ )
+
+ OTEL_SAMPLING_RATE: float = Field(default=0.1, description="Sampling rate for traces (0.0 to 1.0)")
+
+ OTEL_BATCH_EXPORT_SCHEDULE_DELAY: int = Field(
+ default=5000, description="Batch export schedule delay in milliseconds"
+ )
+
+ OTEL_MAX_QUEUE_SIZE: int = Field(default=2048, description="Maximum queue size for the batch span processor")
+
+ OTEL_MAX_EXPORT_BATCH_SIZE: int = Field(default=512, description="Maximum export batch size")
+
+ OTEL_METRIC_EXPORT_INTERVAL: int = Field(default=60000, description="Metric export interval in milliseconds")
+
+ OTEL_BATCH_EXPORT_TIMEOUT: int = Field(default=10000, description="Batch export timeout in milliseconds")
+
+ OTEL_METRIC_EXPORT_TIMEOUT: int = Field(default=30000, description="Metric export timeout in milliseconds")
diff --git a/api/configs/packaging/__init__.py b/api/configs/packaging/__init__.py
index 0ef5a724b3..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.1.3",
+ default="1.3.0",
)
COMMIT_SHA: str = Field(
diff --git a/api/configs/remote_settings_sources/apollo/client.py b/api/configs/remote_settings_sources/apollo/client.py
index 03c64ea00f..88b30d3987 100644
--- a/api/configs/remote_settings_sources/apollo/client.py
+++ b/api/configs/remote_settings_sources/apollo/client.py
@@ -270,7 +270,7 @@ class ApolloClient:
while not self._stopping:
for namespace in self._notification_map:
self._do_heart_beat(namespace)
- time.sleep(60 * 10) # 10分钟
+ time.sleep(60 * 10) # 10 minutes
def _do_heart_beat(self, namespace):
url = "{}/configs/{}/{}/{}?ip={}".format(self.config_url, self.app_id, self.cluster, namespace, self.ip)
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/constants/__init__.py b/api/constants/__init__.py
index b5dfd9cb18..9162357466 100644
--- a/api/constants/__init__.py
+++ b/api/constants/__init__.py
@@ -3,6 +3,8 @@ from configs import dify_config
HIDDEN_VALUE = "[__HIDDEN__]"
UUID_NIL = "00000000-0000-0000-0000-000000000000"
+DEFAULT_FILE_NUMBER_LIMITS = 3
+
IMAGE_EXTENSIONS = ["jpg", "jpeg", "png", "webp", "gif", "svg"]
IMAGE_EXTENSIONS.extend([ext.upper() for ext in IMAGE_EXTENSIONS])
diff --git a/api/controllers/common/helpers.py b/api/controllers/common/helpers.py
index 2979375169..008f1f0f7a 100644
--- a/api/controllers/common/helpers.py
+++ b/api/controllers/common/helpers.py
@@ -4,8 +4,6 @@ import platform
import re
import urllib.parse
import warnings
-from collections.abc import Mapping
-from typing import Any
from uuid import uuid4
import httpx
@@ -29,8 +27,6 @@ except ImportError:
from pydantic import BaseModel
-from configs import dify_config
-
class FileInfo(BaseModel):
filename: str
@@ -87,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": 3,
- "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/app_import.py b/api/controllers/console/app/app_import.py
index 47acb47a2c..a159d4c5c4 100644
--- a/api/controllers/console/app/app_import.py
+++ b/api/controllers/console/app/app_import.py
@@ -8,6 +8,7 @@ from werkzeug.exceptions import Forbidden
from controllers.console.app.wraps import get_app_model
from controllers.console.wraps import (
account_initialization_required,
+ cloud_edition_billing_resource_check,
setup_required,
)
from extensions.ext_database import db
@@ -23,6 +24,7 @@ class AppImportApi(Resource):
@login_required
@account_initialization_required
@marshal_with(app_import_fields)
+ @cloud_edition_billing_resource_check("apps")
def post(self):
# Check user role first
if not current_user.is_editor:
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 773ee65727..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")
@@ -99,53 +110,67 @@ class ForgotPasswordResetApi(Resource):
parser.add_argument("password_confirm", type=valid_password, required=True, nullable=False, location="json")
args = parser.parse_args()
- new_password = args["new_password"]
- password_confirm = args["password_confirm"]
-
- if str(new_password).strip() != str(password_confirm).strip():
+ # Validate passwords match
+ if args["new_password"] != args["password_confirm"]:
raise PasswordMismatchError()
- token = args["token"]
- reset_data = AccountService.get_reset_password_data(token)
-
- if reset_data is None:
+ # Validate token and get reset data
+ 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()
- AccountService.revoke_reset_password_token(token)
+ # Revoke token to prevent reuse
+ AccountService.revoke_reset_password_token(args["token"])
+ # Generate secure salt and hash password
salt = secrets.token_bytes(16)
- base64_salt = base64.b64encode(salt).decode()
+ password_hashed = hash_password(args["new_password"], salt)
- password_hashed = hash_password(new_password, salt)
- base64_password_hashed = base64.b64encode(password_hashed).decode()
+ email = reset_data.get("email", "")
with Session(db.engine) as session:
- account = session.execute(select(Account).filter_by(email=reset_data.get("email"))).scalar_one_or_none()
- if account:
- account.password = base64_password_hashed
- account.password_salt = base64_salt
- db.session.commit()
- tenant = TenantService.get_join_tenants(account)
- if not tenant and not FeatureService.get_system_features().is_allow_create_workspace:
- tenant = TenantService.create_tenant(f"{account.name}'s Workspace")
- TenantService.create_tenant_member(tenant, account, role="owner")
- account.current_tenant = tenant
- tenant_was_created.send(tenant)
- else:
- try:
- account = AccountService.create_account_and_tenant(
- email=reset_data.get("email", ""),
- name=reset_data.get("email", ""),
- password=password_confirm,
- interface_language=languages[0],
- )
- except WorkSpaceNotAllowedCreateError:
- pass
- except AccountRegisterError:
- raise AccountInFreezeError()
+ account = session.execute(select(Account).filter_by(email=email)).scalar_one_or_none()
+
+ if account:
+ self._update_existing_account(account, password_hashed, salt, session)
+ else:
+ self._create_new_account(email, args["password_confirm"])
return {"result": "success"}
+ def _update_existing_account(self, account, password_hashed, salt, session):
+ # Update existing account credentials
+ account.password = base64.b64encode(password_hashed).decode()
+ account.password_salt = base64.b64encode(salt).decode()
+ session.commit()
+
+ # Create workspace if needed
+ if (
+ not TenantService.get_join_tenants(account)
+ and FeatureService.get_system_features().is_allow_create_workspace
+ ):
+ tenant = TenantService.create_tenant(f"{account.name}'s Workspace")
+ TenantService.create_tenant_member(tenant, account, role="owner")
+ account.current_tenant = tenant
+ tenant_was_created.send(tenant)
+
+ def _create_new_account(self, email, password):
+ # Create new account if allowed
+ try:
+ AccountService.create_account_and_tenant(
+ email=email,
+ name=email,
+ password=password,
+ interface_language=languages[0],
+ )
+ except WorkSpaceNotAllowedCreateError:
+ pass
+ except AccountRegisterError:
+ raise AccountInFreezeError()
+
api.add_resource(ForgotPasswordSendEmailApi, "/forgot-password")
api.add_resource(ForgotPasswordCheckApi, "/forgot-password/validity")
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 396dae7a57..752d124735 100644
--- a/api/controllers/console/datasets/datasets.py
+++ b/api/controllers/console/datasets/datasets.py
@@ -641,7 +641,6 @@ class DatasetRetrievalSettingApi(Resource):
VectorType.RELYT
| VectorType.TIDB_VECTOR
| VectorType.CHROMA
- | VectorType.TENCENT
| VectorType.PGVECTO_RS
| VectorType.BAIDU
| VectorType.VIKINGDB
@@ -665,6 +664,8 @@ class DatasetRetrievalSettingApi(Resource):
| VectorType.OPENGAUSS
| VectorType.OCEANBASE
| VectorType.TABLESTORE
+ | VectorType.HUAWEI_CLOUD
+ | VectorType.TENCENT
):
return {
"retrieval_method": [
@@ -688,7 +689,6 @@ class DatasetRetrievalSettingMockApi(Resource):
| VectorType.RELYT
| VectorType.TIDB_VECTOR
| VectorType.CHROMA
- | VectorType.TENCENT
| VectorType.PGVECTO_RS
| VectorType.BAIDU
| VectorType.VIKINGDB
@@ -710,6 +710,8 @@ class DatasetRetrievalSettingMockApi(Resource):
| VectorType.OPENGAUSS
| 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/datasets/external.py b/api/controllers/console/datasets/external.py
index 48f360dcd1..2c031172bf 100644
--- a/api/controllers/console/datasets/external.py
+++ b/api/controllers/console/datasets/external.py
@@ -21,12 +21,6 @@ def _validate_name(name):
return name
-def _validate_description_length(description):
- if description and len(description) > 400:
- raise ValueError("Description cannot exceed 400 characters.")
- return description
-
-
class ExternalApiTemplateListApi(Resource):
@setup_required
@login_required
diff --git a/api/controllers/console/datasets/metadata.py b/api/controllers/console/datasets/metadata.py
index 14183a1e67..fc9711169f 100644
--- a/api/controllers/console/datasets/metadata.py
+++ b/api/controllers/console/datasets/metadata.py
@@ -14,18 +14,6 @@ from services.entities.knowledge_entities.knowledge_entities import (
from services.metadata_service import MetadataService
-def _validate_name(name):
- if not name or len(name) < 1 or len(name) > 40:
- raise ValueError("Name must be between 1 to 40 characters.")
- return name
-
-
-def _validate_description_length(description):
- if len(description) > 400:
- raise ValueError("Description cannot exceed 400 characters.")
- return description
-
-
class DatasetMetadataCreateApi(Resource):
@setup_required
@login_required
diff --git a/api/controllers/console/datasets/website.py b/api/controllers/console/datasets/website.py
index da995537e7..33c926b4c9 100644
--- a/api/controllers/console/datasets/website.py
+++ b/api/controllers/console/datasets/website.py
@@ -14,7 +14,12 @@ class WebsiteCrawlApi(Resource):
def post(self):
parser = reqparse.RequestParser()
parser.add_argument(
- "provider", type=str, choices=["firecrawl", "jinareader"], required=True, nullable=True, location="json"
+ "provider",
+ type=str,
+ choices=["firecrawl", "watercrawl", "jinareader"],
+ required=True,
+ nullable=True,
+ location="json",
)
parser.add_argument("url", type=str, required=True, nullable=True, location="json")
parser.add_argument("options", type=dict, required=True, nullable=True, location="json")
@@ -34,7 +39,9 @@ class WebsiteCrawlStatusApi(Resource):
@account_initialization_required
def get(self, job_id: str):
parser = reqparse.RequestParser()
- parser.add_argument("provider", type=str, choices=["firecrawl", "jinareader"], required=True, location="args")
+ parser.add_argument(
+ "provider", type=str, choices=["firecrawl", "watercrawl", "jinareader"], required=True, location="args"
+ )
args = parser.parse_args()
# get crawl status
try:
diff --git a/api/controllers/console/error.py b/api/controllers/console/error.py
index bd4ae9dc7f..b8fd1f0358 100644
--- a/api/controllers/console/error.py
+++ b/api/controllers/console/error.py
@@ -103,6 +103,18 @@ class AccountInFreezeError(BaseHTTPException):
)
+class EducationVerifyLimitError(BaseHTTPException):
+ error_code = "education_verify_limit"
+ description = "Rate limit exceeded"
+ code = 429
+
+
+class EducationActivateLimitError(BaseHTTPException):
+ error_code = "education_activate_limit"
+ description = "Rate limit exceeded"
+ code = 429
+
+
class CompilanceRateLimitError(BaseHTTPException):
error_code = "compilance_rate_limit"
description = "Rate limit exceeded for downloading compliance report."
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/account.py b/api/controllers/console/workspace/account.py
index f1ec0f3d29..e9c25e6c5b 100644
--- a/api/controllers/console/workspace/account.py
+++ b/api/controllers/console/workspace/account.py
@@ -15,7 +15,13 @@ from controllers.console.workspace.error import (
InvalidInvitationCodeError,
RepeatPasswordNotMatchError,
)
-from controllers.console.wraps import account_initialization_required, enterprise_license_required, setup_required
+from controllers.console.wraps import (
+ account_initialization_required,
+ cloud_edition_billing_enabled,
+ enterprise_license_required,
+ only_edition_cloud,
+ setup_required,
+)
from extensions.ext_database import db
from fields.member_fields import account_fields
from libs.helper import TimestampField, timezone
@@ -280,8 +286,6 @@ class AccountDeleteApi(Resource):
class AccountDeleteUpdateFeedbackApi(Resource):
@setup_required
def post(self):
- account = current_user
-
parser = reqparse.RequestParser()
parser.add_argument("email", type=str, required=True, location="json")
parser.add_argument("feedback", type=str, required=True, location="json")
@@ -292,6 +296,79 @@ class AccountDeleteUpdateFeedbackApi(Resource):
return {"result": "success"}
+class EducationVerifyApi(Resource):
+ verify_fields = {
+ "token": fields.String,
+ }
+
+ @setup_required
+ @login_required
+ @account_initialization_required
+ @only_edition_cloud
+ @cloud_edition_billing_enabled
+ @marshal_with(verify_fields)
+ def get(self):
+ account = current_user
+
+ return BillingService.EducationIdentity.verify(account.id, account.email)
+
+
+class EducationApi(Resource):
+ status_fields = {
+ "result": fields.Boolean,
+ }
+
+ @setup_required
+ @login_required
+ @account_initialization_required
+ @only_edition_cloud
+ @cloud_edition_billing_enabled
+ def post(self):
+ account = current_user
+
+ parser = reqparse.RequestParser()
+ parser.add_argument("token", type=str, required=True, location="json")
+ parser.add_argument("institution", type=str, required=True, location="json")
+ parser.add_argument("role", type=str, required=True, location="json")
+ args = parser.parse_args()
+
+ return BillingService.EducationIdentity.activate(account, args["token"], args["institution"], args["role"])
+
+ @setup_required
+ @login_required
+ @account_initialization_required
+ @only_edition_cloud
+ @cloud_edition_billing_enabled
+ @marshal_with(status_fields)
+ def get(self):
+ account = current_user
+
+ return BillingService.EducationIdentity.is_active(account.id)
+
+
+class EducationAutoCompleteApi(Resource):
+ data_fields = {
+ "data": fields.List(fields.String),
+ "curr_page": fields.Integer,
+ "has_next": fields.Boolean,
+ }
+
+ @setup_required
+ @login_required
+ @account_initialization_required
+ @only_edition_cloud
+ @cloud_edition_billing_enabled
+ @marshal_with(data_fields)
+ def get(self):
+ parser = reqparse.RequestParser()
+ parser.add_argument("keywords", type=str, required=True, location="args")
+ parser.add_argument("page", type=int, required=False, location="args", default=0)
+ parser.add_argument("limit", type=int, required=False, location="args", default=20)
+ args = parser.parse_args()
+
+ return BillingService.EducationIdentity.autocomplete(args["keywords"], args["page"], args["limit"])
+
+
# Register API resources
api.add_resource(AccountInitApi, "/account/init")
api.add_resource(AccountProfileApi, "/account/profile")
@@ -305,5 +382,8 @@ api.add_resource(AccountIntegrateApi, "/account/integrates")
api.add_resource(AccountDeleteVerifyApi, "/account/delete/verify")
api.add_resource(AccountDeleteApi, "/account/delete")
api.add_resource(AccountDeleteUpdateFeedbackApi, "/account/delete/feedback")
+api.add_resource(EducationVerifyApi, "/account/education/verify")
+api.add_resource(EducationApi, "/account/education")
+api.add_resource(EducationAutoCompleteApi, "/account/education/autocomplete")
# api.add_resource(AccountEmailApi, '/account/email')
# api.add_resource(AccountEmailVerifyApi, '/account/email-verify')
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 f4c32ede2b..e9c1884c60 100644
--- a/api/controllers/console/workspace/plugin.py
+++ b/api/controllers/console/workspace/plugin.py
@@ -49,6 +49,23 @@ class PluginListApi(Resource):
return jsonable_encoder({"plugins": plugins})
+class PluginListLatestVersionsApi(Resource):
+ @setup_required
+ @login_required
+ @account_initialization_required
+ def post(self):
+ req = reqparse.RequestParser()
+ req.add_argument("plugin_ids", type=list, required=True, location="json")
+ args = req.parse_args()
+
+ try:
+ versions = PluginService.list_latest_versions(args["plugin_ids"])
+ except PluginDaemonClientSideError as e:
+ raise ValueError(e)
+
+ return jsonable_encoder({"versions": versions})
+
+
class PluginListInstallationsFromIdsApi(Resource):
@setup_required
@login_required
@@ -232,11 +249,36 @@ 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
@account_initialization_required
- @plugin_permission_required(debug_required=True)
+ @plugin_permission_required(install_required=True)
def get(self):
tenant_id = current_user.current_tenant_id
@@ -260,7 +302,7 @@ class PluginFetchInstallTasksApi(Resource):
@setup_required
@login_required
@account_initialization_required
- @plugin_permission_required(debug_required=True)
+ @plugin_permission_required(install_required=True)
def get(self):
tenant_id = current_user.current_tenant_id
@@ -281,7 +323,7 @@ class PluginFetchInstallTaskApi(Resource):
@setup_required
@login_required
@account_initialization_required
- @plugin_permission_required(debug_required=True)
+ @plugin_permission_required(install_required=True)
def get(self, task_id: str):
tenant_id = current_user.current_tenant_id
@@ -295,7 +337,7 @@ class PluginDeleteInstallTaskApi(Resource):
@setup_required
@login_required
@account_initialization_required
- @plugin_permission_required(debug_required=True)
+ @plugin_permission_required(install_required=True)
def post(self, task_id: str):
tenant_id = current_user.current_tenant_id
@@ -309,7 +351,7 @@ class PluginDeleteAllInstallTaskItemsApi(Resource):
@setup_required
@login_required
@account_initialization_required
- @plugin_permission_required(debug_required=True)
+ @plugin_permission_required(install_required=True)
def post(self):
tenant_id = current_user.current_tenant_id
@@ -323,7 +365,7 @@ class PluginDeleteInstallTaskItemApi(Resource):
@setup_required
@login_required
@account_initialization_required
- @plugin_permission_required(debug_required=True)
+ @plugin_permission_required(install_required=True)
def post(self, task_id: str, identifier: str):
tenant_id = current_user.current_tenant_id
@@ -337,7 +379,7 @@ class PluginUpgradeFromMarketplaceApi(Resource):
@setup_required
@login_required
@account_initialization_required
- @plugin_permission_required(debug_required=True)
+ @plugin_permission_required(install_required=True)
def post(self):
tenant_id = current_user.current_tenant_id
@@ -360,7 +402,7 @@ class PluginUpgradeFromGithubApi(Resource):
@setup_required
@login_required
@account_initialization_required
- @plugin_permission_required(debug_required=True)
+ @plugin_permission_required(install_required=True)
def post(self):
tenant_id = current_user.current_tenant_id
@@ -391,7 +433,7 @@ class PluginUninstallApi(Resource):
@setup_required
@login_required
@account_initialization_required
- @plugin_permission_required(debug_required=True)
+ @plugin_permission_required(install_required=True)
def post(self):
req = reqparse.RequestParser()
req.add_argument("plugin_installation_id", type=str, required=True, location="json")
@@ -453,6 +495,7 @@ class PluginFetchPermissionApi(Resource):
api.add_resource(PluginDebuggingKeyApi, "/workspaces/current/plugin/debugging-key")
api.add_resource(PluginListApi, "/workspaces/current/plugin/list")
+api.add_resource(PluginListLatestVersionsApi, "/workspaces/current/plugin/list/latest-versions")
api.add_resource(PluginListInstallationsFromIdsApi, "/workspaces/current/plugin/list/installations/ids")
api.add_resource(PluginIconApi, "/workspaces/current/plugin/icon")
api.add_resource(PluginUploadFromPkgApi, "/workspaces/current/plugin/upload/pkg")
@@ -470,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/workspace/workspace.py b/api/controllers/console/workspace/workspace.py
index 27fb5134ee..332ed00222 100644
--- a/api/controllers/console/workspace/workspace.py
+++ b/api/controllers/console/workspace/workspace.py
@@ -216,6 +216,23 @@ class WebappLogoWorkspaceApi(Resource):
return {"id": upload_file.id}, 201
+class WorkspaceInfoApi(Resource):
+ @setup_required
+ @login_required
+ @account_initialization_required
+ # Change workspace name
+ def post(self):
+ parser = reqparse.RequestParser()
+ parser.add_argument("name", type=str, required=True, location="json")
+ args = parser.parse_args()
+
+ tenant = Tenant.query.filter(Tenant.id == current_user.current_tenant_id).one_or_404()
+ tenant.name = args["name"]
+ db.session.commit()
+
+ return {"result": "success", "tenant": marshal(WorkspaceService.get_tenant_info(tenant), tenant_fields)}
+
+
api.add_resource(TenantListApi, "/workspaces") # GET for getting all tenants
api.add_resource(WorkspaceListApi, "/all-workspaces") # GET for getting all tenants
api.add_resource(TenantApi, "/workspaces/current", endpoint="workspaces_current") # GET for getting current tenant info
@@ -223,3 +240,4 @@ api.add_resource(TenantApi, "/info", endpoint="info") # Deprecated
api.add_resource(SwitchWorkspaceApi, "/workspaces/switch") # POST for switching tenant
api.add_resource(CustomConfigWorkspaceApi, "/workspaces/custom-config")
api.add_resource(WebappLogoWorkspaceApi, "/workspaces/custom-config/webapp-logo/upload")
+api.add_resource(WorkspaceInfoApi, "/workspaces/info") # POST for changing workspace info
diff --git a/api/controllers/console/wraps.py b/api/controllers/console/wraps.py
index ed6e16b035..e5e8038ad7 100644
--- a/api/controllers/console/wraps.py
+++ b/api/controllers/console/wraps.py
@@ -54,6 +54,17 @@ def only_edition_self_hosted(view):
return decorated
+def cloud_edition_billing_enabled(view):
+ @wraps(view)
+ def decorated(*args, **kwargs):
+ features = FeatureService.get_features(current_user.current_tenant_id)
+ if not features.billing.enabled:
+ abort(403, "Billing feature is not enabled.")
+ return view(*args, **kwargs)
+
+ return decorated
+
+
def cloud_edition_billing_resource_check(resource: str):
def interceptor(view):
@wraps(view)
@@ -199,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/__init__.py b/api/controllers/service_api/__init__.py
index a754851c50..d97074e8b9 100644
--- a/api/controllers/service_api/__init__.py
+++ b/api/controllers/service_api/__init__.py
@@ -6,5 +6,6 @@ bp = Blueprint("service_api", __name__, url_prefix="/v1")
api = ExternalApi(bp)
from . import index
-from .app import app, audio, completion, conversation, file, message, workflow
+from .app import annotation, app, audio, completion, conversation, file, message, workflow
from .dataset import dataset, document, hit_testing, metadata, segment, upload_file
+from .workspace import models
diff --git a/api/controllers/service_api/app/annotation.py b/api/controllers/service_api/app/annotation.py
new file mode 100644
index 0000000000..cffa3665b1
--- /dev/null
+++ b/api/controllers/service_api/app/annotation.py
@@ -0,0 +1,107 @@
+from flask import request
+from flask_restful import Resource, marshal, marshal_with, reqparse # type: ignore
+from werkzeug.exceptions import Forbidden
+
+from controllers.service_api import api
+from controllers.service_api.wraps import FetchUserArg, WhereisUserArg, validate_app_token
+from extensions.ext_redis import redis_client
+from fields.annotation_fields import (
+ annotation_fields,
+)
+from libs.login import current_user
+from models.model import App, EndUser
+from services.annotation_service import AppAnnotationService
+
+
+class AnnotationReplyActionApi(Resource):
+ @validate_app_token(fetch_user_arg=FetchUserArg(fetch_from=WhereisUserArg.JSON))
+ def post(self, app_model: App, end_user: EndUser, action):
+ parser = reqparse.RequestParser()
+ parser.add_argument("score_threshold", required=True, type=float, location="json")
+ parser.add_argument("embedding_provider_name", required=True, type=str, location="json")
+ parser.add_argument("embedding_model_name", required=True, type=str, location="json")
+ args = parser.parse_args()
+ if action == "enable":
+ result = AppAnnotationService.enable_app_annotation(args, app_model.id)
+ elif action == "disable":
+ result = AppAnnotationService.disable_app_annotation(app_model.id)
+ else:
+ raise ValueError("Unsupported annotation reply action")
+ return result, 200
+
+
+class AnnotationReplyActionStatusApi(Resource):
+ @validate_app_token(fetch_user_arg=FetchUserArg(fetch_from=WhereisUserArg.QUERY))
+ def get(self, app_model: App, end_user: EndUser, job_id, action):
+ job_id = str(job_id)
+ app_annotation_job_key = "{}_app_annotation_job_{}".format(action, str(job_id))
+ cache_result = redis_client.get(app_annotation_job_key)
+ if cache_result is None:
+ raise ValueError("The job does not exist.")
+
+ job_status = cache_result.decode()
+ error_msg = ""
+ if job_status == "error":
+ app_annotation_error_key = "{}_app_annotation_error_{}".format(action, str(job_id))
+ error_msg = redis_client.get(app_annotation_error_key).decode()
+
+ return {"job_id": job_id, "job_status": job_status, "error_msg": error_msg}, 200
+
+
+class AnnotationListApi(Resource):
+ @validate_app_token(fetch_user_arg=FetchUserArg(fetch_from=WhereisUserArg.QUERY))
+ def get(self, app_model: App, end_user: EndUser):
+ page = request.args.get("page", default=1, type=int)
+ limit = request.args.get("limit", default=20, type=int)
+ keyword = request.args.get("keyword", default="", type=str)
+
+ annotation_list, total = AppAnnotationService.get_annotation_list_by_app_id(app_model.id, page, limit, keyword)
+ response = {
+ "data": marshal(annotation_list, annotation_fields),
+ "has_more": len(annotation_list) == limit,
+ "limit": limit,
+ "total": total,
+ "page": page,
+ }
+ return response, 200
+
+ @validate_app_token(fetch_user_arg=FetchUserArg(fetch_from=WhereisUserArg.JSON))
+ @marshal_with(annotation_fields)
+ def post(self, app_model: App, end_user: EndUser):
+ parser = reqparse.RequestParser()
+ parser.add_argument("question", required=True, type=str, location="json")
+ parser.add_argument("answer", required=True, type=str, location="json")
+ args = parser.parse_args()
+ annotation = AppAnnotationService.insert_app_annotation_directly(args, app_model.id)
+ return annotation
+
+
+class AnnotationUpdateDeleteApi(Resource):
+ @validate_app_token(fetch_user_arg=FetchUserArg(fetch_from=WhereisUserArg.JSON))
+ @marshal_with(annotation_fields)
+ def post(self, app_model: App, end_user: EndUser, annotation_id):
+ if not current_user.is_editor:
+ raise Forbidden()
+
+ annotation_id = str(annotation_id)
+ parser = reqparse.RequestParser()
+ parser.add_argument("question", required=True, type=str, location="json")
+ parser.add_argument("answer", required=True, type=str, location="json")
+ args = parser.parse_args()
+ annotation = AppAnnotationService.update_app_annotation_directly(args, app_model.id, annotation_id)
+ return annotation
+
+ @validate_app_token(fetch_user_arg=FetchUserArg(fetch_from=WhereisUserArg.QUERY))
+ def delete(self, app_model: App, end_user: EndUser, annotation_id):
+ if not current_user.is_editor:
+ raise Forbidden()
+
+ annotation_id = str(annotation_id)
+ AppAnnotationService.delete_app_annotation(app_model.id, annotation_id)
+ return {"result": "success"}, 200
+
+
+api.add_resource(AnnotationReplyActionApi, "/apps/annotation-reply/")
+api.add_resource(AnnotationReplyActionStatusApi, "/apps/annotation-reply//status/")
+api.add_resource(AnnotationListApi, "/apps/annotations")
+api.add_resource(AnnotationUpdateDeleteApi, "/apps/annotations/")
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/message.py b/api/controllers/service_api/app/message.py
index 749099053d..95e538f4c7 100644
--- a/api/controllers/service_api/app/message.py
+++ b/api/controllers/service_api/app/message.py
@@ -1,3 +1,4 @@
+import json
import logging
from flask_restful import Resource, fields, marshal_with, reqparse # type: ignore
@@ -10,7 +11,7 @@ from controllers.service_api.app.error import NotChatAppError
from controllers.service_api.wraps import FetchUserArg, WhereisUserArg, validate_app_token
from core.app.entities.app_invoke_entities import InvokeFrom
from fields.conversation_fields import message_file_fields
-from fields.message_fields import agent_thought_fields, feedback_fields, retriever_resource_fields
+from fields.message_fields import agent_thought_fields, feedback_fields
from fields.raws import FilesContainedField
from libs.helper import TimestampField, uuid_value
from models.model import App, AppMode, EndUser
@@ -28,7 +29,11 @@ class MessageListApi(Resource):
"answer": fields.String(attribute="re_sign_file_url_answer"),
"message_files": fields.List(fields.Nested(message_file_fields)),
"feedback": fields.Nested(feedback_fields, attribute="user_feedback", allow_null=True),
- "retriever_resources": fields.List(fields.Nested(retriever_resource_fields)),
+ "retriever_resources": fields.Raw(
+ attribute=lambda obj: json.loads(obj.message_metadata).get("retriever_resources", [])
+ if obj.message_metadata
+ else []
+ ),
"created_at": TimestampField,
"agent_thoughts": fields.List(fields.Nested(agent_thought_fields)),
"status": fields.String,
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 d813ae7ebd..e1e6f3168f 100644
--- a/api/controllers/service_api/dataset/dataset.py
+++ b/api/controllers/service_api/dataset/dataset.py
@@ -1,6 +1,6 @@
from flask import request
from flask_restful import marshal, reqparse # type: ignore
-from werkzeug.exceptions import NotFound
+from werkzeug.exceptions import Forbidden, NotFound
import services.dataset_service
from controllers.service_api import api
@@ -12,7 +12,8 @@ from core.provider_manager import ProviderManager
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 DatasetService
+from services.dataset_service import DatasetPermissionService, DatasetService
+from services.entities.knowledge_entities.knowledge_entities import RetrievalModel
def _validate_name(name):
@@ -21,6 +22,12 @@ def _validate_name(name):
return name
+def _validate_description_length(description):
+ if len(description) > 400:
+ raise ValueError("Description cannot exceed 400 characters.")
+ return description
+
+
class DatasetListApi(DatasetApiResource):
"""Resource for datasets."""
@@ -114,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,
@@ -127,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()
@@ -137,6 +152,145 @@ class DatasetListApi(DatasetApiResource):
class DatasetApi(DatasetApiResource):
"""Resource for dataset."""
+ def get(self, _, dataset_id):
+ dataset_id_str = str(dataset_id)
+ dataset = DatasetService.get_dataset(dataset_id_str)
+ if dataset is None:
+ raise NotFound("Dataset not found.")
+ try:
+ DatasetService.check_dataset_permission(dataset, current_user)
+ except services.errors.account.NoPermissionError as e:
+ raise Forbidden(str(e))
+ data = marshal(dataset, dataset_detail_fields)
+ if data.get("permission") == "partial_members":
+ part_users_list = DatasetPermissionService.get_dataset_partial_member_list(dataset_id_str)
+ data.update({"partial_member_list": part_users_list})
+
+ # check embedding setting
+ provider_manager = ProviderManager()
+ configurations = provider_manager.get_configurations(tenant_id=current_user.current_tenant_id)
+
+ embedding_models = configurations.get_models(model_type=ModelType.TEXT_EMBEDDING, only_active=True)
+
+ model_names = []
+ for embedding_model in embedding_models:
+ model_names.append(f"{embedding_model.model}:{embedding_model.provider.provider}")
+
+ if data["indexing_technique"] == "high_quality":
+ item_model = f"{data['embedding_model']}:{data['embedding_model_provider']}"
+ if item_model in model_names:
+ data["embedding_available"] = True
+ else:
+ data["embedding_available"] = False
+ else:
+ data["embedding_available"] = True
+
+ if data.get("permission") == "partial_members":
+ part_users_list = DatasetPermissionService.get_dataset_partial_member_list(dataset_id_str)
+ data.update({"partial_member_list": part_users_list})
+
+ return data, 200
+
+ def patch(self, _, dataset_id):
+ dataset_id_str = str(dataset_id)
+ dataset = DatasetService.get_dataset(dataset_id_str)
+ if dataset is None:
+ raise NotFound("Dataset not found.")
+
+ parser = reqparse.RequestParser()
+ parser.add_argument(
+ "name",
+ nullable=False,
+ help="type is required. Name must be between 1 to 40 characters.",
+ type=_validate_name,
+ )
+ parser.add_argument("description", location="json", store_missing=False, type=_validate_description_length)
+ parser.add_argument(
+ "indexing_technique",
+ type=str,
+ location="json",
+ choices=Dataset.INDEXING_TECHNIQUE_LIST,
+ nullable=True,
+ help="Invalid indexing technique.",
+ )
+ parser.add_argument(
+ "permission",
+ type=str,
+ location="json",
+ choices=(DatasetPermissionEnum.ONLY_ME, DatasetPermissionEnum.ALL_TEAM, DatasetPermissionEnum.PARTIAL_TEAM),
+ help="Invalid permission.",
+ )
+ parser.add_argument("embedding_model", type=str, location="json", help="Invalid embedding model.")
+ parser.add_argument(
+ "embedding_model_provider", type=str, location="json", help="Invalid embedding model provider."
+ )
+ parser.add_argument("retrieval_model", type=dict, location="json", help="Invalid retrieval model.")
+ parser.add_argument("partial_member_list", type=list, location="json", help="Invalid parent user list.")
+
+ parser.add_argument(
+ "external_retrieval_model",
+ type=dict,
+ required=False,
+ nullable=True,
+ location="json",
+ help="Invalid external retrieval model.",
+ )
+
+ parser.add_argument(
+ "external_knowledge_id",
+ type=str,
+ required=False,
+ nullable=True,
+ location="json",
+ help="Invalid external knowledge id.",
+ )
+
+ parser.add_argument(
+ "external_knowledge_api_id",
+ type=str,
+ required=False,
+ nullable=True,
+ location="json",
+ help="Invalid external knowledge api id.",
+ )
+ args = parser.parse_args()
+ data = request.get_json()
+
+ # check embedding model setting
+ if data.get("indexing_technique") == "high_quality":
+ DatasetService.check_embedding_model_setting(
+ dataset.tenant_id, data.get("embedding_model_provider"), data.get("embedding_model")
+ )
+
+ # The role of the current user in the ta table must be admin, owner, editor, or dataset_operator
+ DatasetPermissionService.check_permission(
+ current_user, dataset, data.get("permission"), data.get("partial_member_list")
+ )
+
+ dataset = DatasetService.update_dataset(dataset_id_str, args, current_user)
+
+ if dataset is None:
+ raise NotFound("Dataset not found.")
+
+ result_data = marshal(dataset, dataset_detail_fields)
+ tenant_id = current_user.current_tenant_id
+
+ if data.get("partial_member_list") and data.get("permission") == "partial_members":
+ DatasetPermissionService.update_partial_member_list(
+ tenant_id, dataset_id_str, data.get("partial_member_list")
+ )
+ # clear partial member list when permission is only_me or all_team_members
+ elif (
+ data.get("permission") == DatasetPermissionEnum.ONLY_ME
+ or data.get("permission") == DatasetPermissionEnum.ALL_TEAM
+ ):
+ DatasetPermissionService.clear_partial_member_list(dataset_id_str)
+
+ partial_member_list = DatasetPermissionService.get_dataset_partial_member_list(dataset_id_str)
+ result_data.update({"partial_member_list": partial_member_list})
+
+ return result_data, 200
+
def delete(self, _, dataset_id):
"""
Deletes a dataset given its ID.
@@ -158,6 +312,7 @@ class DatasetApi(DatasetApiResource):
try:
if DatasetService.delete_dataset(dataset_id_str, current_user):
+ DatasetPermissionService.clear_partial_member_list(dataset_id_str)
return {"result": "success"}, 204
else:
raise NotFound("Dataset not found.")
diff --git a/api/controllers/service_api/dataset/document.py b/api/controllers/service_api/dataset/document.py
index 995444ee48..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)
@@ -341,7 +343,7 @@ class DocumentListApi(DatasetApiResource):
search = f"%{search}%"
query = query.filter(Document.name.like(search))
- query = query.order_by(desc(Document.created_at))
+ query = query.order_by(desc(Document.created_at), desc(Document.position))
paginated_documents = query.paginate(page=page, per_page=limit, max_per_page=100, error_out=False)
documents = paginated_documents.items
diff --git a/api/controllers/service_api/dataset/metadata.py b/api/controllers/service_api/dataset/metadata.py
index 9ba94bdbbf..298c8a8df8 100644
--- a/api/controllers/service_api/dataset/metadata.py
+++ b/api/controllers/service_api/dataset/metadata.py
@@ -13,18 +13,6 @@ from services.entities.knowledge_entities.knowledge_entities import (
from services.metadata_service import MetadataService
-def _validate_name(name):
- if not name or len(name) < 1 or len(name) > 40:
- raise ValueError("Name must be between 1 to 40 characters.")
- return name
-
-
-def _validate_description_length(description):
- if len(description) > 400:
- raise ValueError("Description cannot exceed 400 characters.")
- return description
-
-
class DatasetMetadataCreateServiceApi(DatasetApiResource):
def post(self, tenant_id, dataset_id):
parser = reqparse.RequestParser()
diff --git a/api/controllers/service_api/dataset/segment.py b/api/controllers/service_api/dataset/segment.py
index 81bae2940d..2a79e15cc5 100644
--- a/api/controllers/service_api/dataset/segment.py
+++ b/api/controllers/service_api/dataset/segment.py
@@ -117,14 +117,13 @@ class SegmentApi(DatasetApiResource):
parser.add_argument("keyword", type=str, default=None, location="args")
args = parser.parse_args()
- status_list = args["status"]
- keyword = args["keyword"]
-
segments, total = SegmentService.get_segments(
document_id=document_id,
tenant_id=current_user.current_tenant_id,
status_list=args["status"],
keyword=args["keyword"],
+ page=page,
+ limit=limit,
)
response = {
diff --git a/api/controllers/service_api/workspace/models.py b/api/controllers/service_api/workspace/models.py
new file mode 100644
index 0000000000..373f8019f9
--- /dev/null
+++ b/api/controllers/service_api/workspace/models.py
@@ -0,0 +1,21 @@
+from flask_login import current_user # type: ignore
+from flask_restful import Resource # type: ignore
+
+from controllers.service_api import api
+from controllers.service_api.wraps import validate_dataset_token
+from core.model_runtime.utils.encoders import jsonable_encoder
+from services.model_provider_service import ModelProviderService
+
+
+class ModelProviderAvailableModelApi(Resource):
+ @validate_dataset_token
+ def get(self, _, model_type):
+ tenant_id = current_user.current_tenant_id
+
+ model_provider_service = ModelProviderService()
+ models = model_provider_service.get_models_by_model_type(tenant_id=tenant_id, model_type=model_type)
+
+ return jsonable_encoder({"data": models})
+
+
+api.add_resource(ModelProviderAvailableModelApi, "/workspaces/current/models/model-types/")
diff --git a/api/controllers/service_api/wraps.py b/api/controllers/service_api/wraps.py
index 7f87bf438b..7facb03358 100644
--- a/api/controllers/service_api/wraps.py
+++ b/api/controllers/service_api/wraps.py
@@ -59,6 +59,27 @@ def validate_app_token(view: Optional[Callable] = None, *, fetch_user_arg: Optio
if tenant.status == TenantStatus.ARCHIVE:
raise Forbidden("The workspace's status is archived.")
+ tenant_account_join = (
+ db.session.query(Tenant, TenantAccountJoin)
+ .filter(Tenant.id == api_token.tenant_id)
+ .filter(TenantAccountJoin.tenant_id == Tenant.id)
+ .filter(TenantAccountJoin.role.in_(["owner"]))
+ .filter(Tenant.status == TenantStatus.NORMAL)
+ .one_or_none()
+ ) # TODO: only owner information is required, so only one is returned.
+ if tenant_account_join:
+ tenant, ta = tenant_account_join
+ account = db.session.query(Account).filter(Account.id == ta.account_id).first()
+ # Login admin
+ if account:
+ account.current_tenant = tenant
+ current_app.login_manager._update_request_context_with_user(account) # type: ignore
+ user_logged_in.send(current_app._get_current_object(), user=_get_user()) # type: ignore
+ else:
+ raise Unauthorized("Tenant owner account does not exist.")
+ else:
+ raise Unauthorized("Tenant does not exist.")
+
kwargs["app_model"] = app_model
if fetch_user_arg:
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 92bd5500ef..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)
@@ -70,11 +71,20 @@ class AgentStrategyIdentity(ToolIdentity):
pass
+class AgentFeature(enum.StrEnum):
+ """
+ Agent Feature, used to describe the features of the agent strategy.
+ """
+
+ HISTORY_MESSAGES = "history-messages"
+
+
class AgentStrategyEntity(BaseModel):
identity: AgentStrategyIdentity
parameters: list[AgentStrategyParameter] = Field(default_factory=list)
description: I18nObject = Field(..., description="The description of the agent strategy")
output_schema: Optional[dict] = None
+ features: Optional[list[AgentFeature]] = None
# pydantic configs
model_config = ConfigDict(protected_namespaces=())
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/app_config/features/file_upload/manager.py b/api/core/app/app_config/features/file_upload/manager.py
index bcc69e8ec6..40b6c19214 100644
--- a/api/core/app/app_config/features/file_upload/manager.py
+++ b/api/core/app/app_config/features/file_upload/manager.py
@@ -1,6 +1,7 @@
from collections.abc import Mapping
from typing import Any
+from constants import DEFAULT_FILE_NUMBER_LIMITS
from core.file import FileUploadConfig
@@ -18,7 +19,7 @@ class FileUploadConfigManager:
if file_upload_dict.get("enabled"):
transform_methods = file_upload_dict.get("allowed_file_upload_methods", [])
file_upload_dict["image_config"] = {
- "number_limits": file_upload_dict.get("number_limits", 1),
+ "number_limits": file_upload_dict.get("number_limits", DEFAULT_FILE_NUMBER_LIMITS),
"transfer_methods": transform_methods,
}
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/agent_chat/app_runner.py b/api/core/app/apps/agent_chat/app_runner.py
index 72a1717112..71328f6d1b 100644
--- a/api/core/app/apps/agent_chat/app_runner.py
+++ b/api/core/app/apps/agent_chat/app_runner.py
@@ -53,20 +53,6 @@ class AgentChatAppRunner(AppRunner):
query = application_generate_entity.query
files = application_generate_entity.files
- # Pre-calculate the number of tokens of the prompt messages,
- # and return the rest number of tokens by model context token size limit and max token size limit.
- # If the rest number of tokens is not enough, raise exception.
- # Include: prompt template, inputs, query(optional), files(optional)
- # Not Include: memory, external data, dataset context
- self.get_pre_calculate_rest_tokens(
- app_record=app_record,
- model_config=application_generate_entity.model_conf,
- prompt_template_entity=app_config.prompt_template,
- inputs=dict(inputs),
- files=list(files),
- query=query,
- )
-
memory = None
if application_generate_entity.conversation_id:
# get memory of conversation (read-only)
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/chat/app_runner.py b/api/core/app/apps/chat/app_runner.py
index 8641f188f7..39597fc036 100644
--- a/api/core/app/apps/chat/app_runner.py
+++ b/api/core/app/apps/chat/app_runner.py
@@ -61,20 +61,6 @@ class ChatAppRunner(AppRunner):
)
image_detail_config = image_detail_config or ImagePromptMessageContent.DETAIL.LOW
- # Pre-calculate the number of tokens of the prompt messages,
- # and return the rest number of tokens by model context token size limit and max token size limit.
- # If the rest number of tokens is not enough, raise exception.
- # Include: prompt template, inputs, query(optional), files(optional)
- # Not Include: memory, external data, dataset context
- self.get_pre_calculate_rest_tokens(
- app_record=app_record,
- model_config=application_generate_entity.model_conf,
- prompt_template_entity=app_config.prompt_template,
- inputs=inputs,
- files=files,
- query=query,
- )
-
memory = None
if application_generate_entity.conversation_id:
# get memory of conversation (read-only)
diff --git a/api/core/app/apps/completion/app_runner.py b/api/core/app/apps/completion/app_runner.py
index 4f16247318..80fdd0b80e 100644
--- a/api/core/app/apps/completion/app_runner.py
+++ b/api/core/app/apps/completion/app_runner.py
@@ -54,20 +54,6 @@ class CompletionAppRunner(AppRunner):
)
image_detail_config = image_detail_config or ImagePromptMessageContent.DETAIL.LOW
- # Pre-calculate the number of tokens of the prompt messages,
- # and return the rest number of tokens by model context token size limit and max token size limit.
- # If the rest number of tokens is not enough, raise exception.
- # Include: prompt template, inputs, query(optional), files(optional)
- # Not Include: memory, external data, dataset context
- self.get_pre_calculate_rest_tokens(
- app_record=app_record,
- model_config=application_generate_entity.model_conf,
- prompt_template_entity=app_config.prompt_template,
- inputs=inputs,
- files=files,
- query=query,
- )
-
# organize all inputs and template to prompt messages
# Include: prompt template, inputs, query(optional), files(optional)
prompt_messages, stop = self.organize_prompt_messages(
diff --git a/api/core/app/apps/message_based_app_generator.py b/api/core/app/apps/message_based_app_generator.py
index 64ec6ac0c4..995082b79d 100644
--- a/api/core/app/apps/message_based_app_generator.py
+++ b/api/core/app/apps/message_based_app_generator.py
@@ -153,6 +153,9 @@ class MessageBasedAppGenerator(BaseAppGenerator):
query = application_generate_entity.query or "New conversation"
else:
query = next(iter(application_generate_entity.inputs.values()), "New conversation")
+ if isinstance(query, int):
+ query = str(query)
+ 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/entities/provider_entities.py b/api/core/entities/provider_entities.py
index e04e2a42fd..2a0751a5ee 100644
--- a/api/core/entities/provider_entities.py
+++ b/api/core/entities/provider_entities.py
@@ -146,6 +146,7 @@ class BasicProviderConfig(BaseModel):
BOOLEAN = CommonParameterType.BOOLEAN.value
APP_SELECTOR = CommonParameterType.APP_SELECTOR.value
MODEL_SELECTOR = CommonParameterType.MODEL_SELECTOR.value
+ TOOLS_SELECTOR = CommonParameterType.TOOLS_SELECTOR.value
@classmethod
def value_of(cls, value: str) -> "ProviderConfig.Type":
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 6367e45638..11f245812e 100644
--- a/api/core/helper/ssrf_proxy.py
+++ b/api/core/helper/ssrf_proxy.py
@@ -48,21 +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),
- "https://": httpx.HTTPTransport(proxy=dify_config.SSRF_PROXY_HTTPS_URL),
+ "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