Compare commits
2 Commits
feat/shell
...
codex/refa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
68f4dff852 | ||
|
|
a85e583720 |
@@ -55,8 +55,7 @@ For non-string `Select` and `RadioGroup` values, prefer explicit domain generics
|
||||
Flag:
|
||||
|
||||
- Form-like UI using unrelated `Input` and `Button` pieces without a submit boundary.
|
||||
- Text-like fields not composed through `Field`, `FieldLabel`, and `Input` or `InputGroupInput` when using Dify UI form semantics.
|
||||
- Prefixes, suffixes, or actions manually layered over `Input` instead of using the canonical `InputGroup` composition.
|
||||
- Text-like fields not composed through `Field`, `FieldLabel`, and `FieldControl` when using Dify UI form semantics.
|
||||
- Select fields using `FieldLabel` instead of `SelectLabel`.
|
||||
- Slider fields using a generic label instead of `SliderLabel`.
|
||||
- Checkbox/radio groups missing `Fieldset` and `FieldsetLegend`.
|
||||
|
||||
@@ -4,7 +4,6 @@ Read this document when a change introduces Effects, navigation side effects, me
|
||||
|
||||
## Effects
|
||||
|
||||
- Keep render pure: do not read or write `ref.current` during render except for predictable null-guarded lazy initialization. Update interaction-owned refs in event handlers, synchronize external-system refs after commit, and use state or derivation for rendered values.
|
||||
- Use Effects only to synchronize with a named external system such as a browser API, subscription, timer, analytics integration, non-React widget, or imperative DOM API.
|
||||
- Do not use Effects to transform render state, handle user actions, copy query data, reset state from props, or fetch data owned by framework APIs or TanStack Query.
|
||||
- Initialize query-backed forms with keyed remounts or surface-entry hydration instead of copying data through Effects.
|
||||
|
||||
@@ -37,7 +37,3 @@ indent_size = 2
|
||||
[package.json]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
|
||||
# Oxlint writes its generated suppression baseline without a final newline.
|
||||
[oxlint-suppressions.json]
|
||||
insert_final_newline = false
|
||||
|
||||
2
.github/CODEOWNERS
vendored
2
.github/CODEOWNERS
vendored
@@ -245,7 +245,7 @@
|
||||
|
||||
# Frontend - Billing and Education
|
||||
/web/app/components/billing/ @iamjoel @zxhlyh
|
||||
/web/app/education/ @iamjoel @zxhlyh
|
||||
/web/app/education-apply/ @iamjoel @zxhlyh
|
||||
|
||||
# Frontend - Workspace
|
||||
/web/app/components/header/account-dropdown/workplace-selector/ @iamjoel @zxhlyh
|
||||
|
||||
2
.github/actions/setup-web/action.yml
vendored
2
.github/actions/setup-web/action.yml
vendored
@@ -9,7 +9,7 @@ runs:
|
||||
with:
|
||||
run_install: false
|
||||
- name: Setup Vite+
|
||||
uses: voidzero-dev/setup-vp@313600b80b104eadebb9111787d37a2e83e014ca # v1.17.0
|
||||
uses: voidzero-dev/setup-vp@143f5f385f39b1b753ffed1a01ad443811855c8b # v1.16.1
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
cache: true
|
||||
|
||||
2
.github/linters/.yaml-lint.yml
vendored
2
.github/linters/.yaml-lint.yml
vendored
@@ -4,8 +4,6 @@ extends: default
|
||||
rules:
|
||||
brackets:
|
||||
max-spaces-inside: 1
|
||||
comments:
|
||||
min-spaces-from-content: 1
|
||||
comments-indentation: disable
|
||||
document-start: disable
|
||||
line-length: disable
|
||||
|
||||
2
.github/pull_request_template.md
vendored
2
.github/pull_request_template.md
vendored
@@ -21,4 +21,4 @@
|
||||
- [ ] I understand that this PR may be closed in case there was no previous discussion or issues. (This doesn't apply to typos!)
|
||||
- [ ] I've added a test for each change that was introduced, and I tried as much as possible to make a single atomic change.
|
||||
- [ ] I've updated the documentation accordingly.
|
||||
- [ ] I ran `make lint && make type-check` (backend) and `vp staged` (frontend) to appease the lint gods
|
||||
- [ ] I ran `make lint && make type-check` (backend) and `cd web && pnpm exec vp staged` (frontend) to appease the lint gods
|
||||
|
||||
2
.github/workflows/accessibility-e2e.yml
vendored
2
.github/workflows/accessibility-e2e.yml
vendored
@@ -52,7 +52,7 @@ jobs:
|
||||
uses: ./.github/actions/setup-web
|
||||
|
||||
- name: Setup UV and Python
|
||||
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
with:
|
||||
enable-cache: true
|
||||
python-version: '3.12'
|
||||
|
||||
6
.github/workflows/api-tests.yml
vendored
6
.github/workflows/api-tests.yml
vendored
@@ -35,7 +35,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup UV and Python
|
||||
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
with:
|
||||
enable-cache: true
|
||||
python-version: ${{ matrix.python-version }}
|
||||
@@ -94,7 +94,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup UV and Python
|
||||
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
with:
|
||||
enable-cache: true
|
||||
python-version: ${{ matrix.python-version }}
|
||||
@@ -145,7 +145,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup UV and Python
|
||||
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
with:
|
||||
enable-cache: true
|
||||
python-version: '3.12'
|
||||
|
||||
4
.github/workflows/autofix.yml
vendored
4
.github/workflows/autofix.yml
vendored
@@ -50,8 +50,6 @@ jobs:
|
||||
vite.config.ts
|
||||
lint.config.ts
|
||||
eslint.config.mjs
|
||||
knip.config.ts
|
||||
scripts/check-web-production-unused-after-knip-fix.mjs
|
||||
oxlint-suppressions.json
|
||||
eslint-suppressions.json
|
||||
.vscode/**
|
||||
@@ -90,7 +88,7 @@ jobs:
|
||||
python-version: '3.11'
|
||||
|
||||
- if: github.event_name != 'merge_group'
|
||||
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
|
||||
- name: Generate Docker Compose
|
||||
if: github.event_name != 'merge_group' && steps.docker-compose-changes.outputs.any_changed == 'true'
|
||||
|
||||
29
.github/workflows/build-push.yml
vendored
29
.github/workflows/build-push.yml
vendored
@@ -229,32 +229,3 @@ jobs:
|
||||
IMAGE_VERSION: ${{ steps.meta.outputs.version }}
|
||||
run: |
|
||||
docker buildx imagetools inspect "$IMAGE_NAME:$IMAGE_VERSION"
|
||||
|
||||
sync-e2b-template:
|
||||
needs: create-manifest
|
||||
runs-on: ubuntu-24.04
|
||||
if: github.repository == 'langgenius/dify' && startsWith(github.ref, 'refs/tags/')
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- project: dev
|
||||
api_key_secret: E2B_API_KEY_DEV
|
||||
- project: prod
|
||||
api_key_secret: E2B_API_KEY_PROD
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
with:
|
||||
username: ${{ env.DOCKERHUB_USER }}
|
||||
password: ${{ env.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Sync E2B Template (${{ matrix.project }})
|
||||
env:
|
||||
E2B_API_KEY: ${{ secrets[matrix.api_key_secret] }}
|
||||
run: ./dify-agent-runtime/docker/sync-e2b-template.sh
|
||||
|
||||
8
.github/workflows/db-migration-test.yml
vendored
8
.github/workflows/db-migration-test.yml
vendored
@@ -19,7 +19,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup UV and Python
|
||||
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
with:
|
||||
enable-cache: true
|
||||
python-version: '3.12'
|
||||
@@ -40,7 +40,7 @@ jobs:
|
||||
cp envs/middleware.env.example middleware.env
|
||||
|
||||
- name: Set up Middlewares
|
||||
uses: hoverkraft-tech/compose-action@ee6af68587292d72db67743171809c19787df4c9 # v3.1.0
|
||||
uses: hoverkraft-tech/compose-action@11beaa1c2dae4e8ed7b1665aa074723b6cecb0e4 # v3.0.0
|
||||
with:
|
||||
compose-file: |
|
||||
docker/docker-compose.middleware.yaml
|
||||
@@ -69,7 +69,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup UV and Python
|
||||
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
with:
|
||||
enable-cache: true
|
||||
python-version: '3.12'
|
||||
@@ -94,7 +94,7 @@ jobs:
|
||||
sed -i 's/DB_USERNAME=postgres/DB_USERNAME=mysql/' middleware.env
|
||||
|
||||
- name: Set up Middlewares
|
||||
uses: hoverkraft-tech/compose-action@ee6af68587292d72db67743171809c19787df4c9 # v3.1.0
|
||||
uses: hoverkraft-tech/compose-action@11beaa1c2dae4e8ed7b1665aa074723b6cecb0e4 # v3.0.0
|
||||
with:
|
||||
compose-file: |
|
||||
docker/docker-compose.middleware.yaml
|
||||
|
||||
8
.github/workflows/main-ci.yml
vendored
8
.github/workflows/main-ci.yml
vendored
@@ -15,10 +15,8 @@ permissions:
|
||||
statuses: write
|
||||
|
||||
concurrency:
|
||||
# Pull request deduplication is owned by skip-duplicate-actions so pre_job can
|
||||
# publish should_skip before required status finalizers run.
|
||||
group: main-ci-${{ github.event.merge_group.head_sha || github.run_id }}
|
||||
cancel-in-progress: ${{ github.event_name == 'merge_group' }}
|
||||
group: main-ci-${{ github.event.pull_request.number || github.event.merge_group.head_sha || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
pre_job:
|
||||
@@ -396,10 +394,10 @@ jobs:
|
||||
style-check:
|
||||
name: Style Check
|
||||
needs: pre_job
|
||||
if: needs.pre_job.outputs.should_skip != 'true'
|
||||
uses: ./.github/workflows/style.yml
|
||||
with:
|
||||
base-rev: ${{ github.event.pull_request.base.sha || github.event.merge_group.base_sha }}
|
||||
skip-checks: ${{ needs.pre_job.outputs.should_skip == 'true' }}
|
||||
|
||||
vdb-tests-run:
|
||||
name: Run VDB Tests
|
||||
|
||||
2
.github/workflows/pyrefly-diff.yml
vendored
2
.github/workflows/pyrefly-diff.yml
vendored
@@ -22,7 +22,7 @@ jobs:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Python & UV
|
||||
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
with:
|
||||
enable-cache: true
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ jobs:
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Setup Python & UV
|
||||
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
with:
|
||||
enable-cache: true
|
||||
|
||||
|
||||
2
.github/workflows/pyrefly-type-coverage.yml
vendored
2
.github/workflows/pyrefly-type-coverage.yml
vendored
@@ -22,7 +22,7 @@ jobs:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Python & UV
|
||||
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
with:
|
||||
enable-cache: true
|
||||
|
||||
|
||||
17
.github/workflows/style.yml
vendored
17
.github/workflows/style.yml
vendored
@@ -6,11 +6,6 @@ on:
|
||||
base-rev:
|
||||
required: true
|
||||
type: string
|
||||
skip-checks:
|
||||
description: Create the required check runs without repeating previously successful work.
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
permissions:
|
||||
checks: write
|
||||
@@ -20,7 +15,6 @@ permissions:
|
||||
jobs:
|
||||
python-style:
|
||||
name: Python Style
|
||||
if: ${{ !inputs.skip-checks }}
|
||||
runs-on: depot-ubuntu-24.04
|
||||
|
||||
steps:
|
||||
@@ -47,7 +41,7 @@ jobs:
|
||||
|
||||
- name: Setup UV and Python
|
||||
if: steps.changed-files.outputs.any_changed == 'true'
|
||||
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
with:
|
||||
enable-cache: false
|
||||
python-version: '3.12'
|
||||
@@ -85,7 +79,6 @@ jobs:
|
||||
|
||||
web-style:
|
||||
name: Web Style
|
||||
if: ${{ !inputs.skip-checks }}
|
||||
runs-on: depot-ubuntu-24.04
|
||||
defaults:
|
||||
run:
|
||||
@@ -145,7 +138,6 @@ jobs:
|
||||
|
||||
ts-common-style:
|
||||
name: TS Common
|
||||
if: ${{ !inputs.skip-checks }}
|
||||
runs-on: depot-ubuntu-24.04-4
|
||||
permissions:
|
||||
checks: write
|
||||
@@ -174,8 +166,6 @@ jobs:
|
||||
vite.config.ts
|
||||
lint.config.ts
|
||||
eslint.config.mjs
|
||||
knip.config.ts
|
||||
scripts/check-web-production-unused-after-knip-fix.mjs
|
||||
oxlint-suppressions.json
|
||||
eslint-suppressions.json
|
||||
.vscode/**
|
||||
@@ -191,7 +181,6 @@ jobs:
|
||||
|
||||
superlinter:
|
||||
name: SuperLinter
|
||||
if: ${{ !inputs.skip-checks }}
|
||||
runs-on: depot-ubuntu-24.04
|
||||
|
||||
steps:
|
||||
@@ -212,7 +201,6 @@ jobs:
|
||||
**Dockerfile
|
||||
dev/**
|
||||
.editorconfig
|
||||
.vite-hooks/**
|
||||
|
||||
- name: Super-linter
|
||||
uses: super-linter/super-linter/slim@4ce20838b8ab83717e78138c5b3a1407148e0918 # v8.7.0
|
||||
@@ -221,11 +209,10 @@ jobs:
|
||||
BASH_SEVERITY: warning
|
||||
DEFAULT_BRANCH: origin/main
|
||||
EDITORCONFIG_FILE_NAME: editorconfig-checker.json
|
||||
ENABLE_GITHUB_PULL_REQUEST_SUMMARY_COMMENT: false
|
||||
FILTER_REGEX_INCLUDE: pnpm-lock.yaml
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
IGNORE_GENERATED_FILES: true
|
||||
IGNORE_GITIGNORED_FILES: true
|
||||
VALIDATE_ALL_CODEBASE: false
|
||||
VALIDATE_BASH: true
|
||||
VALIDATE_BASH_EXEC: true
|
||||
# FIXME: temporarily disabled until api-docker.yaml's run script is fixed for shellcheck
|
||||
|
||||
12
.github/workflows/translate-i18n-claude.yml
vendored
12
.github/workflows/translate-i18n-claude.yml
vendored
@@ -8,19 +8,15 @@ on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
files:
|
||||
description: 'Specific files to translate (space-separated, e.g., "app common").
|
||||
Required for full mode; leave empty in incremental mode to use en-US
|
||||
files changed since HEAD~1.'
|
||||
description: 'Specific files to translate (space-separated, e.g., "app common"). Required for full mode; leave empty in incremental mode to use en-US files changed since HEAD~1.'
|
||||
required: false
|
||||
type: string
|
||||
languages:
|
||||
description: 'Specific languages to translate (space-separated, e.g., "zh-Hans
|
||||
ja-JP"). Leave empty for all supported target languages except en-US.'
|
||||
description: 'Specific languages to translate (space-separated, e.g., "zh-Hans ja-JP"). Leave empty for all supported target languages except en-US.'
|
||||
required: false
|
||||
type: string
|
||||
mode:
|
||||
description: 'Sync mode: incremental (compare with previous en-US revision) or
|
||||
full (sync all keys in scope)'
|
||||
description: 'Sync mode: incremental (compare with previous en-US revision) or full (sync all keys in scope)'
|
||||
required: false
|
||||
default: incremental
|
||||
type: choice
|
||||
@@ -162,7 +158,7 @@ jobs:
|
||||
|
||||
- name: Run Claude Code for Translation Sync
|
||||
if: steps.context.outputs.CHANGED_FILES != ''
|
||||
uses: anthropics/claude-code-action@9d7150bc8a3dae8149739a88019d192b579ad90c # v1.0.193
|
||||
uses: anthropics/claude-code-action@c038e4dcdedfbbca18dfb17df35a17e40ded4ddc # v1.0.186
|
||||
with:
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
2
.github/workflows/vdb-tests-full.yml
vendored
2
.github/workflows/vdb-tests-full.yml
vendored
@@ -36,7 +36,7 @@ jobs:
|
||||
remove_tool_cache: true
|
||||
|
||||
- name: Setup UV and Python
|
||||
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
with:
|
||||
enable-cache: true
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
2
.github/workflows/vdb-tests.yml
vendored
2
.github/workflows/vdb-tests.yml
vendored
@@ -33,7 +33,7 @@ jobs:
|
||||
remove_tool_cache: true
|
||||
|
||||
- name: Setup UV and Python
|
||||
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
with:
|
||||
enable-cache: true
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
7
.github/workflows/web-e2e.yml
vendored
7
.github/workflows/web-e2e.yml
vendored
@@ -26,11 +26,16 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Switch Ubuntu mirror
|
||||
run: |
|
||||
sudo sed -i 's|http://us-east-1.ec2.archive.ubuntu.com/ubuntu|http://azure.archive.ubuntu.com/ubuntu|g' /etc/apt/sources.list.d/ubuntu.sources
|
||||
sudo apt-get update
|
||||
|
||||
- name: Setup web dependencies
|
||||
uses: ./.github/actions/setup-web
|
||||
|
||||
- name: Setup UV and Python
|
||||
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
with:
|
||||
enable-cache: true
|
||||
python-version: '3.12'
|
||||
|
||||
35
.github/workflows/web-tests.yml
vendored
35
.github/workflows/web-tests.yml
vendored
@@ -44,31 +44,6 @@ jobs:
|
||||
include-hidden-files: true
|
||||
retention-days: 1
|
||||
|
||||
browser-test:
|
||||
name: Web Browser Tests
|
||||
runs-on: depot-ubuntu-24.04-4
|
||||
timeout-minutes: 20
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
working-directory: ./web
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup web environment
|
||||
uses: ./.github/actions/setup-web
|
||||
|
||||
- name: Install Chromium for Browser Mode
|
||||
timeout-minutes: 15
|
||||
run: vp exec playwright install --with-deps --only-shell chromium
|
||||
|
||||
- name: Run browser tests
|
||||
run: vp test run --config vitest.browser.config.ts --silent=passed-only
|
||||
|
||||
merge-reports:
|
||||
name: Merge Test Reports
|
||||
if: ${{ !cancelled() }}
|
||||
@@ -126,6 +101,11 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Switch Ubuntu mirror
|
||||
run: |
|
||||
sudo sed -i 's|http://us-east-1.ec2.archive.ubuntu.com/ubuntu|http://azure.archive.ubuntu.com/ubuntu|g' /etc/apt/sources.list.d/ubuntu.sources
|
||||
sudo apt-get update
|
||||
|
||||
- name: Setup web environment
|
||||
uses: ./.github/actions/setup-web
|
||||
|
||||
@@ -160,6 +140,11 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Switch Ubuntu mirror
|
||||
run: |
|
||||
sudo sed -i 's|http://us-east-1.ec2.archive.ubuntu.com/ubuntu|http://azure.archive.ubuntu.com/ubuntu|g' /etc/apt/sources.list.d/ubuntu.sources
|
||||
sudo apt-get update
|
||||
|
||||
- name: Setup web environment
|
||||
uses: ./.github/actions/setup-web
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ files=$(git diff --cached --name-only)
|
||||
# check if api or web directory is modified
|
||||
|
||||
api_modified=false
|
||||
web_modified=false
|
||||
skip_web_checks=false
|
||||
|
||||
git_path() {
|
||||
@@ -28,6 +29,10 @@ do
|
||||
# set api_modified flag to true
|
||||
api_modified=true
|
||||
;;
|
||||
web/*)
|
||||
# set web_modified flag to true
|
||||
web_modified=true
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
|
||||
5
.vscode/settings.example.json
vendored
5
.vscode/settings.example.json
vendored
@@ -4,6 +4,7 @@
|
||||
|
||||
"tailwindCSS.experimental.configFile": "web/app/styles/globals.css",
|
||||
|
||||
// Format
|
||||
"[javascript]": { "editor.defaultFormatter": "oxc.oxc-vscode" },
|
||||
"[javascriptreact]": { "editor.defaultFormatter": "oxc.oxc-vscode" },
|
||||
"[typescript]": { "editor.defaultFormatter": "oxc.oxc-vscode" },
|
||||
@@ -24,9 +25,9 @@
|
||||
"[handlebars]": { "editor.defaultFormatter": "oxc.oxc-vscode" },
|
||||
"editor.formatOnSave": true,
|
||||
"editor.formatOnSaveMode": "file",
|
||||
"oxc.disableNestedConfig": true,
|
||||
"oxc.fmt.disableNestedConfig": true,
|
||||
"oxc.fmt.configPath": "./vite.config.ts",
|
||||
|
||||
// Lint fix
|
||||
"eslint.useFlatConfig": true,
|
||||
"eslint.validate": ["json", "jsonc", "markdown", "yaml", "toml"],
|
||||
"editor.codeActionsOnSave": {
|
||||
|
||||
@@ -488,13 +488,6 @@ SENDGRID_API_KEY=
|
||||
# Sentry configuration
|
||||
SENTRY_DSN=
|
||||
|
||||
# Cloudflare Turnstile server-side verification for Dify Cloud sign-in
|
||||
TURNSTILE_SECRET_KEY=
|
||||
# Comma-separated parent or exact hostnames, for example: dify.ai,staging.dify.dev
|
||||
TURNSTILE_ALLOWED_HOSTNAMES=
|
||||
# Enable only after the compatible web client has been fully deployed.
|
||||
TURNSTILE_EMAIL_CODE_VERIFY_REQUIRED=false
|
||||
|
||||
# DEBUG
|
||||
DEBUG=false
|
||||
ENABLE_REQUEST_LOGGING=False
|
||||
@@ -705,10 +698,11 @@ INNER_API_KEY_FOR_PLUGIN=QaHbTe77CtuXmsfyhR7+vRjI/+XbV1AaFy691iy+kGDv2Jvy0/eAh8Y
|
||||
|
||||
# Dify Agent backend
|
||||
AGENT_BACKEND_BASE_URL=http://localhost:5050
|
||||
# Bearer token sent to the Agent backend control-plane API. Must match DIFY_AGENT_API_TOKEN on the server side.
|
||||
# Bearer token sent to the Agent backend /runs API. Must match DIFY_AGENT_API_TOKEN on the server side.
|
||||
AGENT_BACKEND_API_TOKEN=dify-agent-run-token-for-dev-only
|
||||
AGENT_BACKEND_STREAM_READ_TIMEOUT_SECONDS=30
|
||||
AGENT_BACKEND_STREAM_MAX_RECONNECTS=3
|
||||
AGENT_BACKEND_RUN_TIMEOUT_SECONDS=1200
|
||||
|
||||
# KnowledgeFS (Dataset 2.0)
|
||||
KNOWLEDGE_FS_ENABLED=false
|
||||
@@ -735,8 +729,6 @@ RESET_PASSWORD_TOKEN_EXPIRY_MINUTES=5
|
||||
EMAIL_REGISTER_TOKEN_EXPIRY_MINUTES=5
|
||||
CHANGE_EMAIL_TOKEN_EXPIRY_MINUTES=5
|
||||
OWNER_TRANSFER_TOKEN_EXPIRY_MINUTES=5
|
||||
EMAIL_CODE_LOGIN_TOKEN_EXPIRY_MINUTES=5
|
||||
EMAIL_CODE_LOGIN_MAX_ATTEMPTS=5
|
||||
|
||||
CREATE_TIDB_SERVICE_JOB_ENABLED=false
|
||||
|
||||
@@ -833,12 +825,6 @@ ENABLE_HUMAN_INPUT_TIMEOUT_TASK=true
|
||||
# Human input timeout check interval in minutes
|
||||
HUMAN_INPUT_TIMEOUT_TASK_INTERVAL=1
|
||||
|
||||
# Whether to recover soft-deleted conversation cleanup jobs periodically
|
||||
ENABLE_CONVERSATION_CLEANUP_TASK=true
|
||||
# Recovery interval in minutes and maximum conversations dispatched per sweep
|
||||
CONVERSATION_CLEANUP_TASK_INTERVAL=5
|
||||
CONVERSATION_CLEANUP_BATCH_SIZE=100
|
||||
|
||||
# Nacos remote settings source HTTP timeouts (seconds).
|
||||
# Bound how long requests to the Nacos endpoint wait before failing, so a slow or
|
||||
# unresponsive Nacos server cannot stall API startup or token refresh.
|
||||
|
||||
@@ -65,48 +65,6 @@ forbidden_modules =
|
||||
sqlalchemy
|
||||
werkzeug
|
||||
|
||||
[importlinter:contract:init-validation-service-boundary]
|
||||
name = Initialization validation application service is framework and persistence neutral
|
||||
type = forbidden
|
||||
source_modules =
|
||||
services.init_validation_service
|
||||
forbidden_modules =
|
||||
controllers
|
||||
extensions
|
||||
flask
|
||||
models
|
||||
repositories
|
||||
sqlalchemy
|
||||
werkzeug
|
||||
|
||||
[importlinter:contract:explore-banner-query-service-boundary]
|
||||
name = Explore banner query application service is framework and persistence neutral
|
||||
type = forbidden
|
||||
source_modules =
|
||||
services.explore_banner_query_service
|
||||
forbidden_modules =
|
||||
controllers
|
||||
extensions
|
||||
flask
|
||||
models
|
||||
repositories
|
||||
sqlalchemy
|
||||
werkzeug
|
||||
|
||||
[importlinter:contract:app-definition-query-service-boundary]
|
||||
name = App definition query application service is framework and persistence neutral
|
||||
type = forbidden
|
||||
source_modules =
|
||||
services.app_definition_query_service
|
||||
forbidden_modules =
|
||||
controllers
|
||||
extensions
|
||||
flask
|
||||
models
|
||||
repositories
|
||||
sqlalchemy
|
||||
werkzeug
|
||||
|
||||
[importlinter:contract:feature-query-service-boundary]
|
||||
name = Feature query application service is framework and persistence neutral
|
||||
type = forbidden
|
||||
@@ -167,19 +125,3 @@ forbidden_modules =
|
||||
repositories
|
||||
sqlalchemy
|
||||
werkzeug
|
||||
|
||||
[importlinter:contract:account-activation-service-boundary]
|
||||
name = Account activation application service is framework and persistence neutral
|
||||
type = forbidden
|
||||
source_modules =
|
||||
services.account_activation_service
|
||||
services.entities.account_activation_entities
|
||||
forbidden_modules =
|
||||
configs
|
||||
controllers
|
||||
extensions
|
||||
flask
|
||||
models
|
||||
repositories
|
||||
sqlalchemy
|
||||
werkzeug
|
||||
|
||||
@@ -33,6 +33,7 @@ RUN uv sync --frozen --no-dev --no-editable
|
||||
FROM base AS production
|
||||
|
||||
ENV FLASK_APP=app.py
|
||||
ENV EDITION=SELF_HOSTED
|
||||
ENV DEPLOY_ENV=PRODUCTION
|
||||
ENV CONSOLE_API_URL=http://127.0.0.1:5001
|
||||
ENV CONSOLE_WEB_URL=http://127.0.0.1:3000
|
||||
|
||||
@@ -14,7 +14,6 @@ from contexts.wrapper import RecyclableContextVar
|
||||
from controllers.console.error import UnauthorizedAndForceLogout
|
||||
from core.logging.context import init_request_context
|
||||
from dify_app import DifyApp
|
||||
from enums import DeploymentEdition
|
||||
from extensions.ext_socketio import sio
|
||||
from services.enterprise.enterprise_service import EnterpriseService
|
||||
from services.entities.feature_entities import LicenseStatus
|
||||
@@ -113,7 +112,7 @@ def create_flask_app_with_configs() -> DifyApp:
|
||||
init_request_context()
|
||||
RecyclableContextVar.increment_thread_recycles()
|
||||
|
||||
if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.ENTERPRISE:
|
||||
if dify_config.ENTERPRISE_ENABLED:
|
||||
surface = _match_license_gated_surface(request.path)
|
||||
if surface is not None:
|
||||
try:
|
||||
|
||||
@@ -23,7 +23,6 @@ from dify_agent.protocol import (
|
||||
CancelRunResponse,
|
||||
CreateRunRequest,
|
||||
CreateRunResponse,
|
||||
RunCancelledEvent,
|
||||
RunEvent,
|
||||
RunStatusResponse,
|
||||
)
|
||||
@@ -46,15 +45,6 @@ class AgentBackendRunClient(Protocol):
|
||||
def cancel_run(self, run_id: str, request: CancelRunRequest | None = None) -> CancelRunResponse:
|
||||
"""Request explicit cancellation for one Agent backend run."""
|
||||
|
||||
def cancel_run_and_wait(
|
||||
self,
|
||||
run_id: str,
|
||||
request: CancelRunRequest | None = None,
|
||||
*,
|
||||
after: str | None = None,
|
||||
) -> RunCancelledEvent:
|
||||
"""Request cancellation and wait for runner cleanup to finish."""
|
||||
|
||||
def stream_events(
|
||||
self,
|
||||
run_id: str,
|
||||
@@ -77,15 +67,6 @@ class _DifyAgentSyncClient(Protocol):
|
||||
def cancel_run_sync(self, run_id: str, request: CancelRunRequest | None = None) -> CancelRunResponse:
|
||||
"""Cancel one run synchronously."""
|
||||
|
||||
def cancel_run_and_wait_sync(
|
||||
self,
|
||||
run_id: str,
|
||||
request: CancelRunRequest | None = None,
|
||||
*,
|
||||
after: str | None = None,
|
||||
) -> RunCancelledEvent:
|
||||
"""Cancel one run and wait for its terminal event synchronously."""
|
||||
|
||||
def stream_events_sync(
|
||||
self,
|
||||
run_id: str,
|
||||
@@ -111,9 +92,11 @@ class DifyAgentBackendRunClient:
|
||||
client: _DifyAgentSyncClient,
|
||||
*,
|
||||
stream_max_reconnects: int = 3,
|
||||
stream_timeout_seconds: float = 1200,
|
||||
) -> None:
|
||||
self.client = client
|
||||
self._stream_max_reconnects = stream_max_reconnects
|
||||
self._stream_timeout_seconds = stream_timeout_seconds
|
||||
|
||||
def create_run(self, request: CreateRunRequest) -> CreateRunResponse:
|
||||
"""Create one run through ``POST /runs`` and normalize client exceptions."""
|
||||
@@ -129,19 +112,6 @@ class DifyAgentBackendRunClient:
|
||||
except Exception as exc:
|
||||
raise _normalize_dify_agent_error(exc) from exc
|
||||
|
||||
def cancel_run_and_wait(
|
||||
self,
|
||||
run_id: str,
|
||||
request: CancelRunRequest | None = None,
|
||||
*,
|
||||
after: str | None = None,
|
||||
) -> RunCancelledEvent:
|
||||
"""Cancel one run, then wait for the cleanup-complete terminal event."""
|
||||
try:
|
||||
return self.client.cancel_run_and_wait_sync(run_id, request=request, after=after)
|
||||
except Exception as exc:
|
||||
raise _normalize_dify_agent_error(exc) from exc
|
||||
|
||||
def stream_events(
|
||||
self,
|
||||
run_id: str,
|
||||
@@ -155,6 +125,7 @@ class DifyAgentBackendRunClient:
|
||||
run_id,
|
||||
after=after,
|
||||
max_reconnects=self._stream_max_reconnects,
|
||||
timeout_seconds=self._stream_timeout_seconds,
|
||||
should_stop=should_stop,
|
||||
)
|
||||
except Exception as exc:
|
||||
|
||||
@@ -99,8 +99,6 @@ class AgentBackendRunFailedInternalEvent(AgentBackendInternalEventBase):
|
||||
error: str
|
||||
error_type: RunFailureType | None = None
|
||||
reason: str | None = None
|
||||
session_snapshot: CompositorSessionSnapshot | None = None
|
||||
usage: dict[str, JsonValue] | None = None
|
||||
|
||||
|
||||
class AgentBackendRunCancelledInternalEvent(AgentBackendInternalEventBase):
|
||||
@@ -109,8 +107,6 @@ class AgentBackendRunCancelledInternalEvent(AgentBackendInternalEventBase):
|
||||
type: Literal[AgentBackendInternalEventType.RUN_CANCELLED] = AgentBackendInternalEventType.RUN_CANCELLED
|
||||
reason: str | None = None
|
||||
message: str | None = None
|
||||
session_snapshot: CompositorSessionSnapshot | None = None
|
||||
usage: dict[str, JsonValue] | None = None
|
||||
|
||||
|
||||
type AgentBackendInternalEvent = Annotated[
|
||||
@@ -188,8 +184,6 @@ class AgentBackendRunEventAdapter:
|
||||
error=event.data.error,
|
||||
error_type=event.data.error_type,
|
||||
reason=event.data.reason,
|
||||
session_snapshot=event.data.session_snapshot,
|
||||
usage=_agent_run_usage(event.data.usage),
|
||||
)
|
||||
]
|
||||
case RunCancelledEvent():
|
||||
@@ -199,8 +193,6 @@ class AgentBackendRunEventAdapter:
|
||||
source_event_id=event.id,
|
||||
reason=event.data.reason,
|
||||
message=event.data.message,
|
||||
session_snapshot=event.data.session_snapshot,
|
||||
usage=_agent_run_usage(event.data.usage),
|
||||
)
|
||||
]
|
||||
raise TypeError(f"unsupported agent backend run event: {type(event).__name__}")
|
||||
|
||||
@@ -8,12 +8,6 @@ from clients.agent_backend.client import AgentBackendRunClient, DifyAgentBackend
|
||||
from clients.agent_backend.fake_client import FakeAgentBackendRunClient, FakeAgentBackendScenario
|
||||
|
||||
|
||||
def create_agent_backend_client(*, base_url: str, api_token: str | None = None, stream_timeout: float = 30) -> Client:
|
||||
api_token = api_token.strip() if api_token else None
|
||||
headers = {"Authorization": f"Bearer {api_token}"} if api_token else None
|
||||
return Client(base_url=base_url, stream_timeout=stream_timeout, headers=headers)
|
||||
|
||||
|
||||
def create_agent_backend_run_client(
|
||||
*,
|
||||
base_url: str | None = None,
|
||||
@@ -22,17 +16,18 @@ def create_agent_backend_run_client(
|
||||
fake_scenario: str | FakeAgentBackendScenario = FakeAgentBackendScenario.SUCCESS,
|
||||
stream_read_timeout_seconds: float = 30,
|
||||
stream_max_reconnects: int = 3,
|
||||
stream_run_timeout_seconds: float = 1200,
|
||||
) -> AgentBackendRunClient:
|
||||
"""Create the API-side run client without hiding the ``dify-agent`` protocol."""
|
||||
if use_fake:
|
||||
return FakeAgentBackendRunClient(scenario=FakeAgentBackendScenario(fake_scenario))
|
||||
if base_url is None:
|
||||
raise ValueError("base_url is required when creating a real Agent backend client")
|
||||
headers: dict[str, str] = {}
|
||||
if api_token:
|
||||
headers["Authorization"] = f"Bearer {api_token}"
|
||||
return DifyAgentBackendRunClient(
|
||||
create_agent_backend_client(
|
||||
base_url=base_url,
|
||||
api_token=api_token,
|
||||
stream_timeout=stream_read_timeout_seconds,
|
||||
),
|
||||
Client(base_url=base_url, stream_timeout=stream_read_timeout_seconds, headers=headers),
|
||||
stream_max_reconnects=stream_max_reconnects,
|
||||
stream_timeout_seconds=stream_run_timeout_seconds,
|
||||
)
|
||||
|
||||
@@ -18,8 +18,6 @@ from dify_agent.protocol import (
|
||||
CreateRunRequest,
|
||||
CreateRunResponse,
|
||||
DeferredToolCallPayload,
|
||||
RunCancelledEvent,
|
||||
RunCancelledEventData,
|
||||
RunEvent,
|
||||
RunFailedEvent,
|
||||
RunFailedEventData,
|
||||
@@ -71,28 +69,6 @@ class FakeAgentBackendRunClient:
|
||||
del request
|
||||
return CancelRunResponse(run_id=run_id, status="cancelled")
|
||||
|
||||
def cancel_run_and_wait(
|
||||
self,
|
||||
run_id: str,
|
||||
request: CancelRunRequest | None = None,
|
||||
*,
|
||||
after: str | None = None,
|
||||
) -> RunCancelledEvent:
|
||||
"""Return a deterministic cleanup-complete cancellation event."""
|
||||
del after
|
||||
request = request or CancelRunRequest()
|
||||
_ = self.cancel_run(run_id, request)
|
||||
return RunCancelledEvent(
|
||||
id="cancel-0",
|
||||
run_id=run_id,
|
||||
created_at=_FIXED_TIME,
|
||||
data=RunCancelledEventData(
|
||||
reason=request.reason,
|
||||
message=request.message,
|
||||
session_snapshot=CompositorSessionSnapshot(layers=[]),
|
||||
),
|
||||
)
|
||||
|
||||
def stream_events(
|
||||
self,
|
||||
run_id: str,
|
||||
@@ -157,11 +133,7 @@ class FakeAgentBackendRunClient:
|
||||
id="2-0",
|
||||
run_id=run_id,
|
||||
created_at=_FIXED_TIME,
|
||||
data=RunFailedEventData(
|
||||
error="fake failure",
|
||||
reason="unit_test",
|
||||
session_snapshot=CompositorSessionSnapshot(layers=[]),
|
||||
),
|
||||
data=RunFailedEventData(error="fake failure", reason="unit_test"),
|
||||
),
|
||||
)
|
||||
case FakeAgentBackendScenario.PAUSED:
|
||||
|
||||
@@ -25,9 +25,11 @@ from dify_agent.layers.dify_core_tools import DIFY_CORE_TOOLS_LAYER_TYPE_ID, Dif
|
||||
from dify_agent.layers.dify_plugin import (
|
||||
DIFY_PLUGIN_LLM_LAYER_TYPE_ID,
|
||||
DIFY_PLUGIN_TOOLS_LAYER_TYPE_ID,
|
||||
DifyPluginCredentialValue,
|
||||
DifyPluginLLMLayerConfig,
|
||||
DifyPluginToolsLayerConfig,
|
||||
)
|
||||
from dify_agent.layers.drive import DIFY_DRIVE_LAYER_TYPE_ID, DifyDriveLayerConfig
|
||||
from dify_agent.layers.execution_context import (
|
||||
DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID,
|
||||
DifyExecutionContextLayerConfig,
|
||||
@@ -55,6 +57,7 @@ AGENT_APP_USER_PROMPT_LAYER_ID = "agent_app_user_prompt"
|
||||
DIFY_EXECUTION_CONTEXT_LAYER_ID = "execution_context"
|
||||
DIFY_RUNTIME_LAYER_ID = "runtime"
|
||||
DIFY_CONFIG_LAYER_ID = "config"
|
||||
DIFY_DRIVE_LAYER_ID = "drive"
|
||||
DIFY_PLUGIN_TOOLS_LAYER_ID = "tools"
|
||||
DIFY_CORE_TOOLS_LAYER_ID = "core_tools"
|
||||
DIFY_KNOWLEDGE_BASE_LAYER_ID = "knowledge"
|
||||
@@ -70,10 +73,24 @@ def _shell_layer_deps() -> dict[str, str]:
|
||||
}
|
||||
|
||||
|
||||
def _drive_layer_deps() -> dict[str, str]:
|
||||
return {"shell": DIFY_SHELL_LAYER_ID}
|
||||
|
||||
|
||||
def _config_layer_deps() -> dict[str, str]:
|
||||
return {"shell": DIFY_SHELL_LAYER_ID}
|
||||
|
||||
|
||||
def _shell_config_with_drive_ref(
|
||||
shell_config: DifyShellLayerConfig | None,
|
||||
drive_config: DifyDriveLayerConfig | None,
|
||||
) -> DifyShellLayerConfig:
|
||||
config = shell_config or DifyShellLayerConfig()
|
||||
if drive_config is None:
|
||||
return config
|
||||
return config.model_copy(update={"agent_stub_drive_ref": drive_config.drive_ref})
|
||||
|
||||
|
||||
def _markdown_backtick_fence(text: str) -> str:
|
||||
"""Choose a fence that will not terminate inside the prompt body."""
|
||||
longest_backtick_run = max((len(match.group(0)) for match in re.finditer(r"`+", text)), default=0)
|
||||
@@ -133,8 +150,8 @@ class AgentBackendModelConfig(BaseModel):
|
||||
plugin_id: str
|
||||
model_provider: str
|
||||
model: str
|
||||
credentials: dict[str, DifyPluginCredentialValue] = Field(default_factory=dict)
|
||||
model_settings: dict[str, JsonValue] = Field(default_factory=dict)
|
||||
context_window_tokens: int | None = Field(default=None, gt=0)
|
||||
|
||||
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
|
||||
|
||||
@@ -208,6 +225,9 @@ class AgentBackendWorkflowNodeRunInput(BaseModel):
|
||||
core_tools: DifyCoreToolsLayerConfig | None = None
|
||||
knowledge: DifyKnowledgeBaseLayerConfig | None = None
|
||||
config_layer_config: DifyConfigLayerConfig | None = None
|
||||
# Drive Skills & Files declaration (dify.drive) — an index the agent pulls
|
||||
# through the back proxy, never inline content.
|
||||
drive_config: DifyDriveLayerConfig | None = None
|
||||
# Human-in-the-loop ask_human deferred tool (dify.ask_human). Present only when
|
||||
# the Agent Soul configures human involvement; a deferred call ends the run and
|
||||
# the workflow pauses via the existing HITL form mechanism (ENG-635).
|
||||
@@ -254,6 +274,9 @@ class AgentBackendAgentAppRunInput(BaseModel):
|
||||
core_tools: DifyCoreToolsLayerConfig | None = None
|
||||
knowledge: DifyKnowledgeBaseLayerConfig | None = None
|
||||
config_layer_config: DifyConfigLayerConfig | None = None
|
||||
# Drive Skills & Files declaration (dify.drive) — an index the agent pulls
|
||||
# through the back proxy, never inline content.
|
||||
drive_config: DifyDriveLayerConfig | None = None
|
||||
# Human-in-the-loop ask_human deferred tool (dify.ask_human). Present only when
|
||||
# the Agent Soul configures human involvement (ENG-635).
|
||||
ask_human_config: DifyAskHumanLayerConfig | None = None
|
||||
@@ -285,7 +308,7 @@ class AgentBackendRunRequestBuilder:
|
||||
"""Build an Agent App conversation-turn run request.
|
||||
|
||||
Layer graph: optional Agent Soul system prompt → user prompt →
|
||||
execution context → optional shell / config / history
|
||||
execution context → optional shell / config / drive / history
|
||||
(multi-turn) → LLM → optional plugin-direct tools / core-routed tools /
|
||||
knowledge search / ask_human / structured output. Mirrors the
|
||||
workflow-node layer ordering minus the workflow-job / previous-node
|
||||
@@ -323,7 +346,9 @@ class AgentBackendRunRequestBuilder:
|
||||
]
|
||||
)
|
||||
|
||||
include_shell = run_input.include_shell or run_input.config_layer_config is not None
|
||||
include_shell = (
|
||||
run_input.include_shell or run_input.config_layer_config is not None or run_input.drive_config is not None
|
||||
)
|
||||
if include_shell:
|
||||
layers.append(
|
||||
RunLayerSpec(
|
||||
@@ -333,15 +358,16 @@ class AgentBackendRunRequestBuilder:
|
||||
config=DifyRuntimeLayerConfig(backend_binding_ref=run_input.backend_binding_ref),
|
||||
)
|
||||
)
|
||||
# Sandboxed bash workspace (dify.shell). It enters before config so
|
||||
# eager pulls materialize content in the same filesystem used by model commands.
|
||||
# Sandboxed bash workspace (dify.shell). It enters before config/drive
|
||||
# so eager pulls materialize content in the same filesystem used by
|
||||
# model commands.
|
||||
layers.append(
|
||||
RunLayerSpec(
|
||||
name=DIFY_SHELL_LAYER_ID,
|
||||
type=DIFY_SHELL_LAYER_TYPE_ID,
|
||||
deps=_shell_layer_deps(),
|
||||
metadata=run_input.metadata,
|
||||
config=run_input.shell_config or DifyShellLayerConfig(),
|
||||
config=_shell_config_with_drive_ref(run_input.shell_config, run_input.drive_config),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -356,6 +382,19 @@ class AgentBackendRunRequestBuilder:
|
||||
)
|
||||
)
|
||||
|
||||
if run_input.drive_config is not None:
|
||||
# Drive Skills & Files declaration (dify.drive): the catalog plus
|
||||
# prompt-mentioned entries eagerly pulled through the shell layer.
|
||||
layers.append(
|
||||
RunLayerSpec(
|
||||
name=DIFY_DRIVE_LAYER_ID,
|
||||
type=DIFY_DRIVE_LAYER_TYPE_ID,
|
||||
deps=_drive_layer_deps(),
|
||||
metadata=run_input.metadata,
|
||||
config=run_input.drive_config,
|
||||
)
|
||||
)
|
||||
|
||||
if run_input.include_history:
|
||||
layers.append(
|
||||
RunLayerSpec(
|
||||
@@ -375,8 +414,8 @@ class AgentBackendRunRequestBuilder:
|
||||
plugin_id=run_input.model.plugin_id,
|
||||
model_provider=run_input.model.model_provider,
|
||||
model=run_input.model.model,
|
||||
credentials=run_input.model.credentials,
|
||||
model_settings=_agent_model_settings(run_input.model.model_settings),
|
||||
context_window_tokens=run_input.model.context_window_tokens,
|
||||
),
|
||||
)
|
||||
)
|
||||
@@ -457,7 +496,7 @@ class AgentBackendRunRequestBuilder:
|
||||
"""Build a workflow Agent Node run request without defining another wire schema.
|
||||
|
||||
Layer graph mirrors the workflow surface: prompts → execution context →
|
||||
optional shell / config / history → LLM → optional
|
||||
optional shell / config / drive / history → LLM → optional
|
||||
plugin-direct tools / core-routed tools / knowledge search /
|
||||
ask_human / structured output.
|
||||
"""
|
||||
@@ -499,7 +538,9 @@ class AgentBackendRunRequestBuilder:
|
||||
]
|
||||
)
|
||||
|
||||
include_shell = run_input.include_shell or run_input.config_layer_config is not None
|
||||
include_shell = (
|
||||
run_input.include_shell or run_input.config_layer_config is not None or run_input.drive_config is not None
|
||||
)
|
||||
if include_shell:
|
||||
layers.append(
|
||||
RunLayerSpec(
|
||||
@@ -509,15 +550,16 @@ class AgentBackendRunRequestBuilder:
|
||||
config=DifyRuntimeLayerConfig(backend_binding_ref=run_input.backend_binding_ref),
|
||||
)
|
||||
)
|
||||
# Sandboxed bash workspace (dify.shell). It enters before config so
|
||||
# eager pulls materialize content in the same filesystem used by model commands.
|
||||
# Sandboxed bash workspace (dify.shell). It enters before drive so
|
||||
# drive can materialize mentioned targets with `dify-agent drive pull`
|
||||
# in the same shell-visible filesystem used by model commands.
|
||||
layers.append(
|
||||
RunLayerSpec(
|
||||
name=DIFY_SHELL_LAYER_ID,
|
||||
type=DIFY_SHELL_LAYER_TYPE_ID,
|
||||
deps=_shell_layer_deps(),
|
||||
metadata=run_input.metadata,
|
||||
config=run_input.shell_config or DifyShellLayerConfig(),
|
||||
config=_shell_config_with_drive_ref(run_input.shell_config, run_input.drive_config),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -532,6 +574,19 @@ class AgentBackendRunRequestBuilder:
|
||||
)
|
||||
)
|
||||
|
||||
if run_input.drive_config is not None:
|
||||
# Drive Skills & Files declaration (dify.drive): the catalog plus
|
||||
# prompt-mentioned entries eagerly pulled through the shell layer.
|
||||
layers.append(
|
||||
RunLayerSpec(
|
||||
name=DIFY_DRIVE_LAYER_ID,
|
||||
type=DIFY_DRIVE_LAYER_TYPE_ID,
|
||||
deps=_drive_layer_deps(),
|
||||
metadata=run_input.metadata,
|
||||
config=run_input.drive_config,
|
||||
)
|
||||
)
|
||||
|
||||
if run_input.include_history:
|
||||
layers.append(
|
||||
RunLayerSpec(
|
||||
@@ -552,8 +607,8 @@ class AgentBackendRunRequestBuilder:
|
||||
plugin_id=run_input.model.plugin_id,
|
||||
model_provider=run_input.model.model_provider,
|
||||
model=run_input.model.model,
|
||||
credentials=run_input.model.credentials,
|
||||
model_settings=_agent_model_settings(run_input.model.model_settings),
|
||||
context_window_tokens=run_input.model.context_window_tokens,
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
@@ -20,7 +20,7 @@ def reset_password(email, new_password, password_confirm):
|
||||
Reset password of owner account
|
||||
Only available in SELF_HOSTED mode
|
||||
"""
|
||||
if new_password.strip() != password_confirm.strip():
|
||||
if str(new_password).strip() != str(password_confirm).strip():
|
||||
click.echo(click.style("Passwords do not match.", fg="red"))
|
||||
return
|
||||
normalized_email = email.strip().lower()
|
||||
@@ -33,7 +33,7 @@ def reset_password(email, new_password, password_confirm):
|
||||
|
||||
try:
|
||||
valid_password(new_password)
|
||||
except ValueError:
|
||||
except:
|
||||
click.echo(click.style(f"Invalid password. Must match {password_pattern}", fg="red"))
|
||||
return
|
||||
|
||||
@@ -62,7 +62,7 @@ def reset_email(email, new_email, email_confirm):
|
||||
Replace account email
|
||||
:return:
|
||||
"""
|
||||
if new_email.strip() != email_confirm.strip():
|
||||
if str(new_email).strip() != str(email_confirm).strip():
|
||||
click.echo(click.style("New emails do not match.", fg="red"))
|
||||
return
|
||||
normalized_new_email = new_email.strip().lower()
|
||||
@@ -75,7 +75,7 @@ def reset_email(email, new_email, email_confirm):
|
||||
|
||||
try:
|
||||
email_validate(normalized_new_email)
|
||||
except ValueError:
|
||||
except:
|
||||
click.echo(click.style(f"Invalid email: {new_email}", fg="red"))
|
||||
return
|
||||
|
||||
|
||||
@@ -10,8 +10,6 @@ import click
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from configs import dify_config
|
||||
from enums import CloudPlan, DeploymentEdition
|
||||
from extensions.ext_database import db
|
||||
from libs.datetime_utils import naive_utc_now
|
||||
from services.clear_free_plan_tenant_expired_logs import ClearFreePlanTenantExpiredLogs
|
||||
@@ -128,12 +126,14 @@ def _get_archive_candidate_tenant_ids_by_prefix(
|
||||
|
||||
|
||||
def _filter_paid_workflow_archive_tenant_ids(tenant_ids: list[str]) -> tuple[list[str], list[str]]:
|
||||
from configs import dify_config
|
||||
from enums.cloud_plan import CloudPlan
|
||||
from services.billing_service import BillingService
|
||||
|
||||
tenant_ids = sorted(set(tenant_ids))
|
||||
if not tenant_ids:
|
||||
return [], []
|
||||
if dify_config.DEPLOYMENT_EDITION != DeploymentEdition.CLOUD:
|
||||
if not dify_config.BILLING_ENABLED:
|
||||
return tenant_ids, []
|
||||
|
||||
plans = BillingService.get_plan_bulk_with_cache(tenant_ids)
|
||||
@@ -1462,7 +1462,7 @@ def cleanup_orphaned_draft_variables(
|
||||
"--graceful-period",
|
||||
default=21,
|
||||
show_default=True,
|
||||
help="Graceful period in days after subscription expiration; ignored outside the Cloud edition.",
|
||||
help="Graceful period in days after subscription expiration, will be ignored when billing is disabled.",
|
||||
)
|
||||
@click.option("--dry-run", is_flag=True, default=False, help="Show messages logs would be cleaned without deleting")
|
||||
def clean_expired_messages(
|
||||
@@ -1514,8 +1514,8 @@ def clean_expired_messages(
|
||||
if from_days_ago <= before_days:
|
||||
raise click.UsageError("--from-days-ago must be greater than --before-days.")
|
||||
|
||||
# Create the policy for the configured deployment edition.
|
||||
# NOTE: graceful_period is ignored outside the Cloud edition.
|
||||
# Create policy based on billing configuration
|
||||
# NOTE: graceful_period will be ignored when billing is disabled.
|
||||
policy = create_message_clean_policy(graceful_period_days=graceful_period)
|
||||
|
||||
if from_days_ago is not None and before_days is not None:
|
||||
|
||||
@@ -6,7 +6,7 @@ from sqlalchemy import delete, select, update
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from configs import dify_config
|
||||
from enums import DeploymentEdition
|
||||
from enums.deployment_edition import DeploymentEdition
|
||||
from events.app_event import app_was_created
|
||||
from extensions.ext_database import db
|
||||
from extensions.ext_redis import redis_client
|
||||
|
||||
@@ -5,6 +5,7 @@ from typing import Any, override
|
||||
from pydantic.fields import FieldInfo
|
||||
from pydantic_settings import BaseSettings, PydanticBaseSettingsSource, SettingsConfigDict, TomlConfigSettingsSource
|
||||
|
||||
from enums.deployment_edition import DeploymentEdition
|
||||
from libs.file_utils import search_file_upwards
|
||||
|
||||
from .deploy import DeploymentConfig
|
||||
@@ -116,3 +117,11 @@ class DifyConfig(
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@property
|
||||
def DEPLOYMENT_EDITION(self) -> DeploymentEdition:
|
||||
if self.EDITION == "CLOUD":
|
||||
return DeploymentEdition.CLOUD
|
||||
if self.ENTERPRISE_ENABLED:
|
||||
return DeploymentEdition.ENTERPRISE
|
||||
return DeploymentEdition.COMMUNITY
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import Field
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
from enums import DeploymentEdition
|
||||
|
||||
|
||||
class DeploymentConfig(BaseSettings):
|
||||
"""
|
||||
@@ -25,14 +25,9 @@ class DeploymentConfig(BaseSettings):
|
||||
default=False,
|
||||
)
|
||||
|
||||
DEPLOYMENT_EDITION: DeploymentEdition = Field(
|
||||
description="Product edition of the application.",
|
||||
default=DeploymentEdition.COMMUNITY,
|
||||
)
|
||||
|
||||
INIT_PASSWORD: str = Field(
|
||||
description="Password required before initializing a self-hosted deployment",
|
||||
default="",
|
||||
EDITION: Literal["SELF_HOSTED", "CLOUD"] = Field(
|
||||
description="Deployment edition of the application (e.g., 'SELF_HOSTED', 'CLOUD')",
|
||||
default="SELF_HOSTED",
|
||||
)
|
||||
|
||||
DEPLOY_ENV: str = Field(
|
||||
|
||||
@@ -8,6 +8,12 @@ class EnterpriseFeatureConfig(BaseSettings):
|
||||
**Before using, please contact business@dify.ai by email to inquire about licensing matters.**
|
||||
"""
|
||||
|
||||
ENTERPRISE_ENABLED: bool = Field(
|
||||
description="Enable or disable enterprise-level features."
|
||||
"Before using, please contact business@dify.ai by email to inquire about licensing matters.",
|
||||
default=False,
|
||||
)
|
||||
|
||||
WEBAPP_PUBLIC_ACCESS_ENABLED: bool = Field(
|
||||
description="Whether admins are allowed to set a webapp's access mode to public (anyone with the link, "
|
||||
"no auth). Disable in security-sensitive on-prem deployments.",
|
||||
@@ -53,7 +59,7 @@ class EnterpriseTelemetryConfig(BaseSettings):
|
||||
"""
|
||||
|
||||
ENTERPRISE_TELEMETRY_ENABLED: bool = Field(
|
||||
description="Enable enterprise telemetry collection for enterprise deployments.",
|
||||
description="Enable enterprise telemetry collection (also requires ENTERPRISE_ENABLED=true).",
|
||||
default=False,
|
||||
)
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ from configs.extra.archive_config import ArchiveStorageConfig
|
||||
from configs.extra.knowledge_fs_config import KnowledgeFSConfig
|
||||
from configs.extra.notion_config import NotionConfig
|
||||
from configs.extra.sentry_config import SentryConfig
|
||||
from configs.extra.turnstile_config import TurnstileConfig
|
||||
|
||||
|
||||
class ExtraServiceConfig(
|
||||
@@ -13,6 +12,5 @@ class ExtraServiceConfig(
|
||||
KnowledgeFSConfig,
|
||||
NotionConfig,
|
||||
SentryConfig,
|
||||
TurnstileConfig,
|
||||
):
|
||||
pass
|
||||
|
||||
@@ -13,7 +13,7 @@ class AgentBackendConfig(BaseSettings):
|
||||
)
|
||||
|
||||
AGENT_BACKEND_API_TOKEN: str | None = Field(
|
||||
description="Bearer token for authenticating with the Agent backend control-plane API.",
|
||||
description="Bearer token for authenticating with the Agent backend /runs API.",
|
||||
default=None,
|
||||
)
|
||||
|
||||
@@ -37,6 +37,11 @@ class AgentBackendConfig(BaseSettings):
|
||||
default=3,
|
||||
)
|
||||
|
||||
AGENT_BACKEND_RUN_TIMEOUT_SECONDS: PositiveFloat = Field(
|
||||
description="Total deadline for one Agent backend run event stream.",
|
||||
default=1200,
|
||||
)
|
||||
|
||||
AGENT_SHELL_ENABLED: bool = Field(
|
||||
description=(
|
||||
"Inject the Home, Workspace, Sandbox, and Shell runtime layers into Agent runs. "
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
from pydantic import Field, SecretStr, field_validator
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
|
||||
class TurnstileConfig(BaseSettings):
|
||||
"""Server-side Cloudflare Turnstile settings for Cloud sign-in."""
|
||||
|
||||
TURNSTILE_SECRET_KEY: SecretStr | None = Field(
|
||||
default=None,
|
||||
description="Secret key used to validate Cloudflare Turnstile tokens.",
|
||||
)
|
||||
TURNSTILE_ALLOWED_HOSTNAMES: str = Field(
|
||||
default="",
|
||||
description="Comma-separated parent or exact hostnames accepted from Turnstile.",
|
||||
)
|
||||
TURNSTILE_EMAIL_CODE_VERIFY_REQUIRED: bool = Field(
|
||||
default=False,
|
||||
description=(
|
||||
"Require a separate Turnstile challenge when verifying email login codes on Dify Cloud. "
|
||||
"Enable after the compatible web client has been deployed."
|
||||
),
|
||||
)
|
||||
|
||||
@field_validator("TURNSTILE_SECRET_KEY", mode="before")
|
||||
@classmethod
|
||||
def normalize_secret_key(cls, value: object) -> object:
|
||||
if isinstance(value, SecretStr):
|
||||
normalized = value.get_secret_value().strip()
|
||||
return SecretStr(normalized) if normalized else None
|
||||
if isinstance(value, str):
|
||||
normalized = value.strip()
|
||||
return normalized or None
|
||||
return value
|
||||
|
||||
@property
|
||||
def TURNSTILE_ALLOWED_HOSTNAME_SET(self) -> frozenset[str]:
|
||||
return frozenset(
|
||||
hostname.strip().lower().strip(".")
|
||||
for hostname in self.TURNSTILE_ALLOWED_HOSTNAMES.split(",")
|
||||
if hostname.strip().strip(".")
|
||||
)
|
||||
@@ -810,6 +810,17 @@ class ModelLoadBalanceConfig(BaseSettings):
|
||||
)
|
||||
|
||||
|
||||
class BillingConfig(BaseSettings):
|
||||
"""
|
||||
Configuration for platform billing features
|
||||
"""
|
||||
|
||||
BILLING_ENABLED: bool = Field(
|
||||
description="Enable or disable billing functionality",
|
||||
default=False,
|
||||
)
|
||||
|
||||
|
||||
class UpdateConfig(BaseSettings):
|
||||
"""
|
||||
Configuration for application update checks
|
||||
@@ -1324,17 +1335,6 @@ class MultiModalTransferConfig(BaseSettings):
|
||||
)
|
||||
|
||||
|
||||
class NewAgentBetaConfig(BaseSettings):
|
||||
NEW_AGENT_BETA_ACTIVITY_START_AT: datetime | None = Field(
|
||||
description="New Agent Beta Publish window start in RFC3339 UTC (inclusive)",
|
||||
default=None,
|
||||
)
|
||||
NEW_AGENT_BETA_ACTIVITY_END_AT: datetime | None = Field(
|
||||
description="New Agent Beta Publish window end in RFC3339 UTC (exclusive)",
|
||||
default=None,
|
||||
)
|
||||
|
||||
|
||||
class OpsTraceConfig(BaseSettings):
|
||||
OPS_TRACE_RETRYABLE_DISPATCH_MAX_RETRIES: PositiveInt = Field(
|
||||
description="Maximum retry attempts for transient ops trace provider dispatch failures.",
|
||||
@@ -1355,18 +1355,6 @@ class CeleryBeatConfig(BaseSettings):
|
||||
|
||||
|
||||
class CeleryScheduleTasksConfig(BaseSettings):
|
||||
ENABLE_CONVERSATION_CLEANUP_TASK: bool = Field(
|
||||
description="Enable periodic recovery of soft-deleted conversation cleanup",
|
||||
default=True,
|
||||
)
|
||||
CONVERSATION_CLEANUP_TASK_INTERVAL: PositiveInt = Field(
|
||||
description="Soft-deleted conversation cleanup recovery interval in minutes",
|
||||
default=5,
|
||||
)
|
||||
CONVERSATION_CLEANUP_BATCH_SIZE: PositiveInt = Field(
|
||||
description="Maximum soft-deleted conversations dispatched per cleanup sweep",
|
||||
default=100,
|
||||
)
|
||||
ENABLE_CLEAN_EMBEDDING_CACHE_TASK: bool = Field(
|
||||
description="Enable clean embedding cache task",
|
||||
default=False,
|
||||
@@ -1549,10 +1537,6 @@ class LoginConfig(BaseSettings):
|
||||
description="expiry time in minutes for email code login token",
|
||||
default=5,
|
||||
)
|
||||
EMAIL_CODE_LOGIN_MAX_ATTEMPTS: PositiveInt = Field(
|
||||
description="maximum number of verification attempts for an email code login challenge",
|
||||
default=5,
|
||||
)
|
||||
ALLOW_REGISTER: bool = Field(
|
||||
description="whether to enable register",
|
||||
default=False,
|
||||
@@ -1637,6 +1621,7 @@ class FeatureConfig(
|
||||
# place the configs in alphabet order
|
||||
AppExecutionConfig,
|
||||
AuthConfig, # Changed from OAuthConfig to AuthConfig
|
||||
BillingConfig,
|
||||
CodeExecutionSandboxConfig,
|
||||
CreatorsPlatformConfig,
|
||||
TriggerConfig,
|
||||
@@ -1656,7 +1641,6 @@ class FeatureConfig(
|
||||
ModelLoadBalanceConfig,
|
||||
ModerationConfig,
|
||||
MultiModalTransferConfig,
|
||||
NewAgentBetaConfig,
|
||||
OpsTraceConfig,
|
||||
PositionConfig,
|
||||
RagEtlConfig,
|
||||
|
||||
@@ -451,12 +451,6 @@ class HostedFetchAppTemplateConfig(BaseSettings):
|
||||
default="https://tmpl.dify.ai",
|
||||
)
|
||||
|
||||
HOSTED_FETCH_APP_TEMPLATES_CACHE_TTL: int = Field(
|
||||
description="TTL in seconds for caching remote app template HTTP responses. 0 disables caching.",
|
||||
default=600,
|
||||
ge=0,
|
||||
)
|
||||
|
||||
|
||||
class HostedFetchPipelineTemplateConfig(BaseSettings):
|
||||
"""
|
||||
|
||||
@@ -335,6 +335,22 @@ class CeleryConfig(DatabaseConfig):
|
||||
return self.CELERY_BROKER_URL.startswith("rediss://") if self.CELERY_BROKER_URL else False
|
||||
|
||||
|
||||
class InternalTestConfig(BaseSettings):
|
||||
"""
|
||||
Configuration settings for Internal Test
|
||||
"""
|
||||
|
||||
AWS_SECRET_ACCESS_KEY: str | None = Field(
|
||||
description="Internal test AWS secret access key",
|
||||
default=None,
|
||||
)
|
||||
|
||||
AWS_ACCESS_KEY_ID: str | None = Field(
|
||||
description="Internal test AWS access key ID",
|
||||
default=None,
|
||||
)
|
||||
|
||||
|
||||
class DatasetQueueMonitorConfig(BaseSettings):
|
||||
"""
|
||||
Configuration settings for Dataset Queue Monitor
|
||||
@@ -400,6 +416,7 @@ class MiddlewareConfig(
|
||||
WeaviateConfig,
|
||||
ElasticsearchConfig,
|
||||
CouchbaseConfig,
|
||||
InternalTestConfig,
|
||||
VikingDBConfig,
|
||||
UpstashConfig,
|
||||
TidbOnQdrantConfig,
|
||||
|
||||
60
api/controllers/common/agent_app_parameters.py
Normal file
60
api/controllers/common/agent_app_parameters.py
Normal file
@@ -0,0 +1,60 @@
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from core.agent.publish_visibility import agent_has_workflow_callable_active_snapshot
|
||||
from core.app.apps.agent_app.app_feature_projection import merge_agent_app_features
|
||||
from core.app.apps.agent_app.app_variable_projection import agent_app_variables_to_user_input_form
|
||||
from core.app.apps.agent_app.errors import AgentAppGeneratorError, AgentAppNotPublishedError
|
||||
from models.agent import Agent, AgentConfigSnapshot, AgentStatus
|
||||
from models.agent_config_entities import AgentSoulConfig
|
||||
from models.model import App, load_annotation_reply_config
|
||||
|
||||
|
||||
def get_published_agent_app_feature_dict_and_user_input_form(
|
||||
app_model: App,
|
||||
*,
|
||||
session: Session,
|
||||
) -> tuple[dict[str, Any], list[dict[str, Any]]]:
|
||||
"""Return public Agent App parameters backed by the published Agent Soul."""
|
||||
app_model_config = app_model.app_model_config_with_session(session=session)
|
||||
|
||||
agent_id = app_model.bound_agent_id
|
||||
if not agent_id:
|
||||
raise AgentAppGeneratorError("Agent App has no bound Agent")
|
||||
|
||||
agent = session.scalar(
|
||||
select(Agent)
|
||||
.where(
|
||||
Agent.tenant_id == app_model.tenant_id,
|
||||
Agent.id == agent_id,
|
||||
Agent.status == AgentStatus.ACTIVE,
|
||||
)
|
||||
.limit(1)
|
||||
)
|
||||
if agent is None:
|
||||
raise AgentAppGeneratorError("Agent App has no bound Agent")
|
||||
if not agent_has_workflow_callable_active_snapshot(session=session, agent=agent):
|
||||
raise AgentAppNotPublishedError("Agent has not been published")
|
||||
|
||||
snapshot = session.scalar(
|
||||
select(AgentConfigSnapshot)
|
||||
.where(
|
||||
AgentConfigSnapshot.tenant_id == app_model.tenant_id,
|
||||
AgentConfigSnapshot.agent_id == agent.id,
|
||||
AgentConfigSnapshot.id == agent.active_config_snapshot_id,
|
||||
)
|
||||
.limit(1)
|
||||
)
|
||||
if snapshot is None:
|
||||
raise AgentAppGeneratorError("Agent published version not found")
|
||||
|
||||
agent_soul = AgentSoulConfig.model_validate(snapshot.config_snapshot_dict)
|
||||
annotation_reply = load_annotation_reply_config(session, app_model.id) if app_model_config else None
|
||||
features_dict = merge_agent_app_features(
|
||||
agent_soul=agent_soul,
|
||||
app_model_config=app_model_config,
|
||||
annotation_reply=annotation_reply,
|
||||
)
|
||||
return features_dict, agent_app_variables_to_user_input_form(agent_soul.app_variables)
|
||||
@@ -126,6 +126,12 @@ class UsageCountResponse(ResponseModel):
|
||||
count: int
|
||||
|
||||
|
||||
class IndexInfoResponse(ResponseModel):
|
||||
welcome: str
|
||||
api_version: str
|
||||
server_version: str
|
||||
|
||||
|
||||
class AvatarUrlResponse(ResponseModel):
|
||||
avatar_url: str
|
||||
|
||||
|
||||
@@ -152,7 +152,7 @@ def _extract_resource_id(
|
||||
if resource_type == RBACResourceScope.APP:
|
||||
app_id = matched_args.get("app_id")
|
||||
if app_id:
|
||||
return str(app_id) # pyrefly: ignore[unnecessary-type-conversion]
|
||||
return str(app_id)
|
||||
|
||||
agent_id = matched_args.get("agent_id")
|
||||
if agent_id:
|
||||
@@ -161,7 +161,7 @@ def _extract_resource_id(
|
||||
|
||||
resource_id = matched_args.get("resource_id")
|
||||
if resource_id:
|
||||
return str(resource_id) # pyrefly: ignore[unnecessary-type-conversion]
|
||||
return str(resource_id)
|
||||
raise ValueError("Missing app_id in request path")
|
||||
|
||||
if resource_type == RBACResourceScope.DATASET:
|
||||
@@ -171,11 +171,9 @@ def _extract_resource_id(
|
||||
|
||||
pipeline_id = matched_args.get("pipeline_id")
|
||||
if pipeline_id:
|
||||
dataset = db.session.scalar(
|
||||
select(Dataset).where(Dataset.pipeline_id == str(pipeline_id), Dataset.tenant_id == tenant_id)
|
||||
)
|
||||
dataset = db.session.scalar(select(Dataset).where(Dataset.pipeline_id == str(pipeline_id)))
|
||||
if not dataset:
|
||||
raise NotFound("Dataset not found for pipeline")
|
||||
return str(dataset.id) # pyrefly: ignore[unnecessary-type-conversion]
|
||||
return str(dataset.id)
|
||||
raise ValueError("Missing dataset_id or pipeline_id in request path")
|
||||
raise ValueError(f"Unknown resource_type: {resource_type}")
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
from http import HTTPStatus
|
||||
from importlib import import_module
|
||||
|
||||
from flask import Blueprint, current_app, got_request_exception
|
||||
from flask import Blueprint
|
||||
from flask_restx import Namespace
|
||||
|
||||
from libs.external_api import ExternalApi
|
||||
from machinery.errors import ActiveWorkspaceRequiredError
|
||||
|
||||
bp = Blueprint("console", __name__, url_prefix="/console/api")
|
||||
|
||||
@@ -16,19 +14,6 @@ api = ExternalApi(
|
||||
description="Console management APIs for app configuration, monitoring, and administration",
|
||||
)
|
||||
|
||||
|
||||
@api.errorhandler(ActiveWorkspaceRequiredError)
|
||||
def _handle_active_workspace_required_error(error: ActiveWorkspaceRequiredError):
|
||||
"""Map a broken Console admission invariant without exposing internal details."""
|
||||
got_request_exception.send(current_app, exception=error)
|
||||
status = HTTPStatus.INTERNAL_SERVER_ERROR
|
||||
return {
|
||||
"code": error.error_code,
|
||||
"message": status.phrase,
|
||||
"status": status.value,
|
||||
}, status.value
|
||||
|
||||
|
||||
console_ns = Namespace("console", description="Console management API operations", path="/")
|
||||
|
||||
RESOURCE_MODULES = (
|
||||
@@ -56,9 +41,10 @@ from . import (
|
||||
knowledge_fs_proxy,
|
||||
notification,
|
||||
onboarding,
|
||||
ping,
|
||||
setup,
|
||||
spec,
|
||||
system,
|
||||
version,
|
||||
workflow_run_archive,
|
||||
)
|
||||
from .agent import composer as agent_composer
|
||||
@@ -72,6 +58,7 @@ from .app import (
|
||||
agent_app_feature,
|
||||
agent_app_sandbox,
|
||||
agent_config_inspector,
|
||||
agent_drive_inspector,
|
||||
annotation,
|
||||
app,
|
||||
audio,
|
||||
@@ -175,6 +162,7 @@ __all__ = [
|
||||
"agent_app_sandbox",
|
||||
"agent_composer",
|
||||
"agent_config_inspector",
|
||||
"agent_drive_inspector",
|
||||
"agent_providers",
|
||||
"agent_roster",
|
||||
"annotation",
|
||||
@@ -225,6 +213,7 @@ __all__ = [
|
||||
"onboarding",
|
||||
"ops_trace",
|
||||
"parameter",
|
||||
"ping",
|
||||
"plugin",
|
||||
"rag_pipeline",
|
||||
"rag_pipeline_datasets",
|
||||
@@ -242,11 +231,11 @@ __all__ = [
|
||||
"socketio_workflow",
|
||||
"spec",
|
||||
"statistic",
|
||||
"system",
|
||||
"tags",
|
||||
"tool_providers",
|
||||
"trial",
|
||||
"trigger_providers",
|
||||
"version",
|
||||
"website",
|
||||
"workflow",
|
||||
"workflow_app_log",
|
||||
|
||||
@@ -182,7 +182,14 @@ class WorkflowAgentComposerValidateApi(Resource):
|
||||
AgentComposerService.validate_knowledge_datasets(
|
||||
session=session, tenant_id=tenant_id, agent_soul=req_data.agent_soul
|
||||
)
|
||||
findings = AgentComposerService.collect_validation_findings(payload=req_data)
|
||||
findings = AgentComposerService.collect_validation_findings(
|
||||
session=session,
|
||||
tenant_id=tenant_id,
|
||||
payload=req_data,
|
||||
agent_id=AgentComposerService.resolve_workflow_node_agent_id(
|
||||
session=session, tenant_id=tenant_id, app_id=app_model.id, node_id=node_id
|
||||
),
|
||||
)
|
||||
return dump_response(AgentComposerValidateResponse, {"result": "success", "errors": [], **findings})
|
||||
|
||||
|
||||
@@ -277,7 +284,7 @@ class WorkflowAgentComposerSaveToRosterApi(Resource):
|
||||
|
||||
def _require_snippet_app_id(*, session: Session, tenant_id: str, snippet_id: UUID) -> str:
|
||||
snippet = SnippetService(session=session).get_snippet_by_id(
|
||||
snippet_id=str(snippet_id), # pyrefly: ignore[unnecessary-type-conversion]
|
||||
snippet_id=str(snippet_id),
|
||||
tenant_id=tenant_id,
|
||||
)
|
||||
if snippet is None:
|
||||
@@ -406,12 +413,22 @@ class SnippetAgentComposerValidateApi(Resource):
|
||||
@with_session(write=False)
|
||||
@model_validate(ComposerSavePayload)
|
||||
def post(self, req_data: ComposerSavePayload, session: Session, tenant_id: str, snippet_id: UUID, node_id: str):
|
||||
_require_snippet_app_id(session=session, tenant_id=tenant_id, snippet_id=snippet_id)
|
||||
app_id = _require_snippet_app_id(session=session, tenant_id=tenant_id, snippet_id=snippet_id)
|
||||
ComposerConfigValidator.validate_publish_payload(req_data)
|
||||
AgentComposerService.validate_knowledge_datasets(
|
||||
session=session, tenant_id=tenant_id, agent_soul=req_data.agent_soul
|
||||
)
|
||||
findings = AgentComposerService.collect_validation_findings(payload=req_data)
|
||||
findings = AgentComposerService.collect_validation_findings(
|
||||
session=session,
|
||||
tenant_id=tenant_id,
|
||||
payload=req_data,
|
||||
agent_id=AgentComposerService.resolve_workflow_node_agent_id(
|
||||
session=session,
|
||||
tenant_id=tenant_id,
|
||||
app_id=app_id,
|
||||
node_id=node_id,
|
||||
),
|
||||
)
|
||||
return dump_response(AgentComposerValidateResponse, {"result": "success", "errors": [], **findings})
|
||||
|
||||
|
||||
@@ -563,7 +580,12 @@ class AgentComposerValidateApi(Resource):
|
||||
AgentComposerService.validate_knowledge_datasets(
|
||||
session=session, tenant_id=tenant_id, agent_soul=req_data.agent_soul
|
||||
)
|
||||
findings = AgentComposerService.collect_validation_findings(payload=req_data)
|
||||
findings = AgentComposerService.collect_validation_findings(
|
||||
session=session,
|
||||
tenant_id=tenant_id,
|
||||
payload=req_data,
|
||||
agent_id=str(agent_id),
|
||||
)
|
||||
return dump_response(AgentComposerValidateResponse, {"result": "success", "errors": [], **findings})
|
||||
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ from uuid import UUID
|
||||
from flask import abort, request
|
||||
from flask_restx import Resource
|
||||
from pydantic import AliasChoices, BaseModel, Field, field_validator
|
||||
from sqlalchemy import func, or_, select
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from controllers.common.schema import (
|
||||
@@ -484,13 +484,12 @@ def _resolve_agent_runtime_app_model(session: Session, *, tenant_id: str, agent_
|
||||
return _agent_roster_service(session).get_agent_runtime_app_model(tenant_id=tenant_id, agent_id=str(agent_id))
|
||||
|
||||
|
||||
def _agent_api_key_count(session: Session, app_model: App) -> int:
|
||||
def _agent_api_key_count(session: Session, app_id: str) -> int:
|
||||
return (
|
||||
session.scalar(
|
||||
select(func.count(ApiToken.id)).where(
|
||||
or_(ApiToken.tenant_id == app_model.tenant_id, ApiToken.tenant_id.is_(None)),
|
||||
ApiToken.type == ApiTokenType.APP,
|
||||
ApiToken.app_id == app_model.id,
|
||||
ApiToken.app_id == app_id,
|
||||
)
|
||||
)
|
||||
or 0
|
||||
@@ -522,7 +521,7 @@ def _serialize_agent_api_access(session: Session, app_model: App) -> dict:
|
||||
meta_endpoint=f"{base_url}/meta",
|
||||
api_rpm=app_model.api_rpm or 0,
|
||||
api_rph=app_model.api_rph or 0,
|
||||
api_key_count=_agent_api_key_count(session, app_model),
|
||||
api_key_count=_agent_api_key_count(session, str(app_model.id)),
|
||||
)
|
||||
return response.model_dump(mode="json")
|
||||
|
||||
@@ -599,7 +598,6 @@ class AgentAppListApi(Resource):
|
||||
|
||||
@console_ns.expect(console_ns.models[AgentAppCreatePayload.__name__])
|
||||
@console_ns.response(201, "Agent app created successfully", console_ns.models[AgentAppDetailWithSite.__name__])
|
||||
@console_ns.response(409, "Agent name already exists")
|
||||
@console_ns.response(403, "Insufficient permissions")
|
||||
@console_ns.response(400, "Invalid request parameters")
|
||||
@setup_required
|
||||
@@ -942,8 +940,6 @@ class AgentApiKeyListApi(BaseApiKeyListResource):
|
||||
@console_ns.response(200, "Agent service API keys", console_ns.models[ApiKeyList.__name__])
|
||||
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.AGENT_MANAGE, resource_required=False)
|
||||
@with_current_tenant_id
|
||||
@edit_permission_required
|
||||
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_RELEASE_AND_VERSION)
|
||||
@with_session(write=False)
|
||||
def get(self, session: Session, tenant_id: str, agent_id: UUID) -> dict[str, object]:
|
||||
app_model = _resolve_agent_app_model(session, tenant_id=tenant_id, agent_id=agent_id)
|
||||
@@ -1073,7 +1069,7 @@ class AgentLogMessagesApi(Resource):
|
||||
payload = _agent_observability_service(session).list_log_messages(
|
||||
app=app_model,
|
||||
agent_id=str(agent_id),
|
||||
conversation_id=str(conversation_id), # pyrefly: ignore[unnecessary-type-conversion]
|
||||
conversation_id=str(conversation_id),
|
||||
params=AgentLogQueryParams(
|
||||
page=query.page,
|
||||
limit=query.limit,
|
||||
|
||||
@@ -5,7 +5,7 @@ import flask_restx
|
||||
from flask_restx import Resource
|
||||
from flask_restx._http import HTTPStatus
|
||||
from pydantic import field_validator
|
||||
from sqlalchemy import func, or_, select
|
||||
from sqlalchemy import delete, func, select
|
||||
from sqlalchemy.orm import Session
|
||||
from werkzeug.exceptions import Forbidden
|
||||
|
||||
@@ -89,9 +89,7 @@ class BaseApiKeyListResource(Resource):
|
||||
_get_resource(resource_id, current_tenant_id, self.resource_model, session=session)
|
||||
keys = session.scalars(
|
||||
select(ApiToken).where(
|
||||
or_(ApiToken.tenant_id == current_tenant_id, ApiToken.tenant_id.is_(None)),
|
||||
ApiToken.type == self.resource_type,
|
||||
getattr(ApiToken, self.resource_id_field) == resource_id,
|
||||
ApiToken.type == self.resource_type, getattr(ApiToken, self.resource_id_field) == resource_id
|
||||
)
|
||||
).all()
|
||||
return ApiKeyList.model_validate({"data": keys}, from_attributes=True)
|
||||
@@ -112,9 +110,7 @@ class BaseApiKeyListResource(Resource):
|
||||
current_key_count: int = (
|
||||
session.scalar(
|
||||
select(func.count(ApiToken.id)).where(
|
||||
or_(ApiToken.tenant_id == current_tenant_id, ApiToken.tenant_id.is_(None)),
|
||||
ApiToken.type == self.resource_type,
|
||||
getattr(ApiToken, self.resource_id_field) == resource_id,
|
||||
ApiToken.type == self.resource_type, getattr(ApiToken, self.resource_id_field) == resource_id
|
||||
)
|
||||
)
|
||||
or 0
|
||||
@@ -176,7 +172,6 @@ class BaseApiKeyResource(Resource):
|
||||
key = session.scalar(
|
||||
select(ApiToken)
|
||||
.where(
|
||||
or_(ApiToken.tenant_id == current_tenant_id, ApiToken.tenant_id.is_(None)),
|
||||
getattr(ApiToken, self.resource_id_field) == resource_id,
|
||||
ApiToken.type == self.resource_type,
|
||||
ApiToken.id == api_key_id,
|
||||
@@ -192,7 +187,7 @@ class BaseApiKeyResource(Resource):
|
||||
assert key is not None # nosec - for type checker only
|
||||
ApiTokenCache.delete(key.token, key.type)
|
||||
|
||||
session.delete(key)
|
||||
session.execute(delete(ApiToken).where(ApiToken.id == api_key_id))
|
||||
session.commit()
|
||||
|
||||
|
||||
@@ -203,15 +198,13 @@ class AppApiKeyListResource(BaseApiKeyListResource):
|
||||
@console_ns.doc(params={"resource_id": "App ID"})
|
||||
@console_ns.response(200, "API keys retrieved successfully", console_ns.models[ApiKeyList.__name__])
|
||||
@with_current_tenant_id
|
||||
@edit_permission_required
|
||||
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_RELEASE_AND_VERSION)
|
||||
@agent_manage_required_for_agent_app
|
||||
@with_session(write=False)
|
||||
def get(self, session: Session, current_tenant_id: str, resource_id: UUID) -> dict[str, object]:
|
||||
"""Get all API keys for an app"""
|
||||
return dump_response(
|
||||
ApiKeyList,
|
||||
self._get_api_key_list(str(resource_id), current_tenant_id, session=session), # pyrefly: ignore[unnecessary-type-conversion]
|
||||
self._get_api_key_list(str(resource_id), current_tenant_id, session=session),
|
||||
)
|
||||
|
||||
@console_ns.doc("create_app_api_key")
|
||||
@@ -228,7 +221,7 @@ class AppApiKeyListResource(BaseApiKeyListResource):
|
||||
"""Create a new API key for an app"""
|
||||
return dump_response(
|
||||
ApiKeyItem,
|
||||
self._create_api_key(str(resource_id), current_tenant_id, session=session), # pyrefly: ignore[unnecessary-type-conversion]
|
||||
self._create_api_key(str(resource_id), current_tenant_id, session=session),
|
||||
), 201
|
||||
|
||||
resource_type = ApiTokenType.APP
|
||||
@@ -258,7 +251,7 @@ class AppApiKeyResource(BaseApiKeyResource):
|
||||
) -> tuple[str, int]:
|
||||
"""Delete an API key for an app"""
|
||||
self._delete_api_key(
|
||||
str(resource_id), # pyrefly: ignore[unnecessary-type-conversion]
|
||||
str(resource_id),
|
||||
str(api_key_id),
|
||||
current_tenant_id,
|
||||
current_user,
|
||||
@@ -278,14 +271,12 @@ class DatasetApiKeyListResource(BaseApiKeyListResource):
|
||||
@console_ns.doc(params={"resource_id": "Dataset ID"})
|
||||
@console_ns.response(200, "API keys retrieved successfully", console_ns.models[ApiKeyList.__name__])
|
||||
@with_current_tenant_id
|
||||
@edit_permission_required
|
||||
@rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_API_KEY_MANAGE)
|
||||
@with_session(write=False)
|
||||
def get(self, session: Session, current_tenant_id: str, resource_id: UUID) -> dict[str, object]:
|
||||
"""Get all API keys for a dataset"""
|
||||
return dump_response(
|
||||
ApiKeyList,
|
||||
self._get_api_key_list(str(resource_id), current_tenant_id, session=session), # pyrefly: ignore[unnecessary-type-conversion]
|
||||
self._get_api_key_list(str(resource_id), current_tenant_id, session=session),
|
||||
)
|
||||
|
||||
@console_ns.doc("create_dataset_api_key")
|
||||
@@ -301,7 +292,7 @@ class DatasetApiKeyListResource(BaseApiKeyListResource):
|
||||
"""Create a new API key for a dataset"""
|
||||
return dump_response(
|
||||
ApiKeyItem,
|
||||
self._create_api_key(str(resource_id), current_tenant_id, session=session), # pyrefly: ignore[unnecessary-type-conversion]
|
||||
self._create_api_key(str(resource_id), current_tenant_id, session=session),
|
||||
), 201
|
||||
|
||||
resource_type = ApiTokenType.DATASET
|
||||
@@ -330,7 +321,7 @@ class DatasetApiKeyResource(BaseApiKeyResource):
|
||||
) -> tuple[str, int]:
|
||||
"""Delete an API key for a dataset"""
|
||||
self._delete_api_key(
|
||||
str(resource_id), # pyrefly: ignore[unnecessary-type-conversion]
|
||||
str(resource_id),
|
||||
str(api_key_id),
|
||||
current_tenant_id,
|
||||
current_user,
|
||||
|
||||
@@ -1,12 +1,21 @@
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from flask import request
|
||||
from flask_restx import Resource
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from controllers.common.schema import query_params_from_model, register_response_schema_models
|
||||
from controllers.common.schema import (
|
||||
query_params_from_model,
|
||||
query_params_from_request,
|
||||
register_response_schema_models,
|
||||
register_schema_models,
|
||||
)
|
||||
from controllers.common.session import with_session
|
||||
from controllers.console import console_ns
|
||||
from controllers.console.agent.app_helpers import resolve_agent_runtime_app_model
|
||||
from controllers.console.app.wraps import get_app_model
|
||||
from controllers.console.wraps import (
|
||||
RBACPermission,
|
||||
@@ -15,13 +24,41 @@ from controllers.console.wraps import (
|
||||
model_validate,
|
||||
rbac_permission_required,
|
||||
setup_required,
|
||||
with_current_tenant_id,
|
||||
with_current_user,
|
||||
)
|
||||
from fields.base import ResponseModel
|
||||
from libs.helper import uuid_value
|
||||
from libs.login import login_required
|
||||
from models.model import App, AppMode
|
||||
from models import Account
|
||||
from models.model import App, AppMode, UploadFile
|
||||
from services.agent.composer_service import AgentComposerService
|
||||
from services.agent.skill_package_service import SkillManifest, SkillPackageError
|
||||
from services.agent.skill_standardize_service import SkillStandardizeService
|
||||
from services.agent.skill_tool_inference_service import (
|
||||
SkillToolInferenceError,
|
||||
SkillToolInferenceResult,
|
||||
SkillToolInferenceService,
|
||||
)
|
||||
from services.agent_drive_service import (
|
||||
AgentDriveError,
|
||||
AgentDriveService,
|
||||
DriveCommitItem,
|
||||
DriveFileRef,
|
||||
normalize_drive_key,
|
||||
)
|
||||
from services.agent_service import AgentService
|
||||
|
||||
_WORKFLOW_AGENT_DRIVE_APP_MODES = [AppMode.WORKFLOW, AppMode.ADVANCED_CHAT]
|
||||
_AGENT_SKILL_UPLOAD_PARAMS = {
|
||||
"file": {
|
||||
"in": "formData",
|
||||
"type": "file",
|
||||
"required": True,
|
||||
"description": "Skill package (.zip or .skill).",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class AgentLogQuery(BaseModel):
|
||||
message_id: str = Field(..., description="Message UUID")
|
||||
@@ -33,6 +70,27 @@ class AgentLogQuery(BaseModel):
|
||||
return uuid_value(value)
|
||||
|
||||
|
||||
class AgentDriveFilePayload(BaseModel):
|
||||
upload_file_id: str = Field(..., description="UploadFile UUID from POST /console/api/files/upload")
|
||||
|
||||
@field_validator("upload_file_id")
|
||||
@classmethod
|
||||
def validate_upload_file_id(cls, value: str) -> str:
|
||||
return uuid_value(value)
|
||||
|
||||
|
||||
class AgentDriveMutationQuery(BaseModel):
|
||||
node_id: str | None = Field(default=None, description="Workflow node ID (workflow composer variant)")
|
||||
|
||||
|
||||
class AgentDriveDeleteFileQuery(AgentDriveMutationQuery):
|
||||
key: str = Field(min_length=1, description="Drive key, e.g. files/sample.pdf")
|
||||
|
||||
|
||||
class AgentDriveDeleteFileByAgentQuery(BaseModel):
|
||||
key: str = Field(min_length=1, description="Drive key, e.g. files/sample.pdf")
|
||||
|
||||
|
||||
class AgentLogMetaResponse(ResponseModel):
|
||||
status: str
|
||||
executor: str
|
||||
@@ -70,7 +128,204 @@ class AgentLogResponse(ResponseModel):
|
||||
files: list[Any] = Field(default_factory=list)
|
||||
|
||||
|
||||
register_response_schema_models(console_ns, AgentLogResponse)
|
||||
class AgentUploadedSkillResponse(ResponseModel):
|
||||
name: str
|
||||
description: str
|
||||
path: str
|
||||
skill_md_key: str
|
||||
archive_key: str | None = None
|
||||
|
||||
|
||||
class AgentSkillUploadResponse(ResponseModel):
|
||||
skill: AgentUploadedSkillResponse
|
||||
manifest: SkillManifest
|
||||
|
||||
|
||||
class AgentDriveFileResponse(ResponseModel):
|
||||
name: str
|
||||
drive_key: str
|
||||
file_id: str
|
||||
size: int | None = None
|
||||
mime_type: str | None = None
|
||||
|
||||
|
||||
class AgentDriveFileCommitResponse(ResponseModel):
|
||||
file: AgentDriveFileResponse
|
||||
|
||||
|
||||
class AgentDriveDeleteResponse(ResponseModel):
|
||||
result: str
|
||||
removed_keys: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
register_schema_models(console_ns, AgentLogQuery, AgentDriveFilePayload, AgentDriveDeleteFileByAgentQuery)
|
||||
register_response_schema_models(
|
||||
console_ns,
|
||||
AgentDriveDeleteResponse,
|
||||
AgentDriveFileCommitResponse,
|
||||
AgentDriveFileResponse,
|
||||
AgentLogResponse,
|
||||
AgentUploadedSkillResponse,
|
||||
AgentSkillUploadResponse,
|
||||
SkillToolInferenceResult,
|
||||
)
|
||||
|
||||
|
||||
def _resolve_agent_id(session: Session, app_model: App, node_id: str | None) -> str | None:
|
||||
if node_id and app_model.mode != AppMode.AGENT:
|
||||
return AgentComposerService.resolve_workflow_node_agent_id(
|
||||
session=session, tenant_id=app_model.tenant_id, app_id=app_model.id, node_id=node_id
|
||||
)
|
||||
return app_model.bound_agent_id_with_session(session=session)
|
||||
|
||||
|
||||
def _agent_not_bound() -> tuple[dict[str, str], int]:
|
||||
return {"code": "agent_not_bound", "message": "no agent is bound for this app/node"}, 400
|
||||
|
||||
|
||||
def _upload_skill_for_app(*, session: Session, current_user: Account, app_model: App):
|
||||
"""Upload one skill package and commit its normalized files into the agent drive."""
|
||||
|
||||
query = query_params_from_request(AgentDriveMutationQuery)
|
||||
agent_id = _resolve_agent_id(session, app_model, query.node_id)
|
||||
if not agent_id:
|
||||
return _agent_not_bound()
|
||||
if "file" not in request.files:
|
||||
return {"code": "no_file", "message": "no skill file uploaded"}, 400
|
||||
if len(request.files) > 1:
|
||||
return {"code": "too_many_files", "message": "only one skill file is allowed"}, 400
|
||||
|
||||
upload = request.files["file"]
|
||||
content = upload.stream.read()
|
||||
try:
|
||||
result = SkillStandardizeService().standardize(
|
||||
content=content,
|
||||
filename=upload.filename or "",
|
||||
tenant_id=app_model.tenant_id,
|
||||
user_id=current_user.id,
|
||||
agent_id=agent_id,
|
||||
session=session,
|
||||
)
|
||||
except (SkillPackageError, AgentDriveError) as exc:
|
||||
return {"code": exc.code, "message": exc.message}, exc.status_code
|
||||
return result, 201
|
||||
|
||||
|
||||
def _commit_drive_file_for_app(*, session: Session, current_user: Account, app_model: App, allow_node_id: bool = True):
|
||||
payload = AgentDriveFilePayload.model_validate(console_ns.payload or {})
|
||||
query = query_params_from_request(AgentDriveMutationQuery)
|
||||
node_id = query.node_id if allow_node_id else None
|
||||
agent_id = _resolve_agent_id(session, app_model, node_id)
|
||||
if not agent_id:
|
||||
return _agent_not_bound()
|
||||
|
||||
upload_file = session.scalar(
|
||||
select(UploadFile).where(
|
||||
UploadFile.id == payload.upload_file_id,
|
||||
UploadFile.tenant_id == app_model.tenant_id,
|
||||
)
|
||||
)
|
||||
if upload_file is None:
|
||||
return {"code": "upload_file_not_found", "message": "upload file not found in this workspace"}, 404
|
||||
|
||||
try:
|
||||
key = normalize_drive_key(f"files/{upload_file.name}")
|
||||
committed = AgentDriveService().commit(
|
||||
tenant_id=app_model.tenant_id,
|
||||
user_id=current_user.id,
|
||||
agent_id=agent_id,
|
||||
items=[
|
||||
DriveCommitItem(
|
||||
key=key,
|
||||
file_ref=DriveFileRef(kind="upload_file", id=upload_file.id),
|
||||
# ADD FILE uploads exist solely to live in the drive, so the
|
||||
# drive owns (and physically cleans) the value on delete.
|
||||
value_owned_by_drive=True,
|
||||
)
|
||||
],
|
||||
session=session,
|
||||
)
|
||||
except AgentDriveError as exc:
|
||||
return {"code": exc.code, "message": exc.message}, exc.status_code
|
||||
|
||||
row = committed[0]
|
||||
return {
|
||||
"file": {
|
||||
"name": upload_file.name,
|
||||
"drive_key": row["key"],
|
||||
"file_id": upload_file.id,
|
||||
"size": row.get("size"),
|
||||
"mime_type": row.get("mime_type"),
|
||||
},
|
||||
}, 201
|
||||
|
||||
|
||||
def _delete_drive_file_for_app(*, session: Session, current_user: Account, app_model: App, allow_node_id: bool = True):
|
||||
query = query_params_from_request(AgentDriveDeleteFileQuery)
|
||||
node_id = query.node_id if allow_node_id else None
|
||||
agent_id = _resolve_agent_id(session, app_model, node_id)
|
||||
if not agent_id:
|
||||
return _agent_not_bound()
|
||||
try:
|
||||
key = normalize_drive_key(query.key)
|
||||
except AgentDriveError as exc:
|
||||
return {"code": exc.code, "message": exc.message}, exc.status_code
|
||||
|
||||
try:
|
||||
result = AgentDriveService().commit(
|
||||
tenant_id=app_model.tenant_id,
|
||||
user_id=current_user.id,
|
||||
agent_id=agent_id,
|
||||
items=[DriveCommitItem(key=key, file_ref=None)],
|
||||
session=session,
|
||||
)
|
||||
except AgentDriveError as exc:
|
||||
return {"code": exc.code, "message": exc.message}, exc.status_code
|
||||
removed_keys = [item["key"] for item in result if item.get("removed")]
|
||||
return {"result": "success", "removed_keys": removed_keys}
|
||||
|
||||
|
||||
def _delete_skill_for_app(
|
||||
*, session: Session, current_user: Account, app_model: App, slug: str, allow_node_id: bool = True
|
||||
):
|
||||
query = query_params_from_request(AgentDriveMutationQuery)
|
||||
node_id = query.node_id if allow_node_id else None
|
||||
agent_id = _resolve_agent_id(session, app_model, node_id)
|
||||
if not agent_id:
|
||||
return _agent_not_bound()
|
||||
if "/" in slug or not slug.strip():
|
||||
return {"code": "drive_key_invalid", "message": "skill slug must be a single path segment"}, 400
|
||||
|
||||
try:
|
||||
result = AgentDriveService().commit(
|
||||
tenant_id=app_model.tenant_id,
|
||||
user_id=current_user.id,
|
||||
agent_id=agent_id,
|
||||
items=[
|
||||
DriveCommitItem(key=f"{slug}/SKILL.md", file_ref=None),
|
||||
DriveCommitItem(key=f"{slug}/.DIFY-SKILL-FULL.zip", file_ref=None),
|
||||
],
|
||||
session=session,
|
||||
)
|
||||
except AgentDriveError as exc:
|
||||
return {"code": exc.code, "message": exc.message}, exc.status_code
|
||||
removed_keys = [item["key"] for item in result if item.get("removed")]
|
||||
return {"result": "success", "removed_keys": removed_keys}
|
||||
|
||||
|
||||
def _infer_skill_tools_for_app(*, session: Session, app_model: App, slug: str):
|
||||
query = query_params_from_request(AgentDriveMutationQuery)
|
||||
agent_id = _resolve_agent_id(session, app_model, query.node_id)
|
||||
if not agent_id:
|
||||
return _agent_not_bound()
|
||||
if "/" in slug or not slug.strip():
|
||||
return {"code": "drive_key_invalid", "message": "skill slug must be a single path segment"}, 400
|
||||
try:
|
||||
return SkillToolInferenceService().infer(
|
||||
tenant_id=app_model.tenant_id, agent_id=agent_id, slug=slug, session=session
|
||||
)
|
||||
except SkillToolInferenceError as exc:
|
||||
return {"code": exc.code, "message": exc.message}, exc.status_code
|
||||
|
||||
|
||||
@console_ns.route("/apps/<uuid:app_id>/agent/logs")
|
||||
@@ -89,6 +344,209 @@ class AgentLogApi(Resource):
|
||||
@get_app_model(mode=[AppMode.AGENT_CHAT])
|
||||
@model_validate(AgentLogQuery)
|
||||
def get(self, req_data: AgentLogQuery, session: Session, app_model: App):
|
||||
"""Get agent logs."""
|
||||
"""Get agent logs"""
|
||||
|
||||
return AgentService.get_agent_logs(app_model, req_data.conversation_id, req_data.message_id, session)
|
||||
|
||||
|
||||
@console_ns.route("/agent/<uuid:agent_id>/skills/upload")
|
||||
class AgentSkillUploadByAgentApi(Resource):
|
||||
@console_ns.doc("upload_agent_skill_by_agent")
|
||||
@console_ns.doc(description="Upload + standardize a Skill into an Agent App drive")
|
||||
@console_ns.doc(consumes=["multipart/form-data"], params={"agent_id": "Agent ID", **_AGENT_SKILL_UPLOAD_PARAMS})
|
||||
@console_ns.response(201, "Skill uploaded into drive", console_ns.models[AgentSkillUploadResponse.__name__])
|
||||
@console_ns.response(400, "Invalid skill package or no bound agent")
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
@with_session
|
||||
def post(self, session: Session, tenant_id: str, current_user: Account, agent_id: UUID):
|
||||
app_model = resolve_agent_runtime_app_model(session=session, tenant_id=tenant_id, agent_id=agent_id)
|
||||
return _upload_skill_for_app(session=session, current_user=current_user, app_model=app_model)
|
||||
|
||||
|
||||
@console_ns.route("/apps/<uuid:app_id>/agent/skills/upload")
|
||||
class AgentSkillUploadApi(Resource):
|
||||
@console_ns.doc("upload_agent_skill")
|
||||
@console_ns.doc(description="Upload + standardize a Skill into the agent drive")
|
||||
@console_ns.doc(
|
||||
consumes=["multipart/form-data"],
|
||||
params={
|
||||
"app_id": "Application ID",
|
||||
**query_params_from_model(AgentDriveMutationQuery),
|
||||
**_AGENT_SKILL_UPLOAD_PARAMS,
|
||||
},
|
||||
)
|
||||
@console_ns.response(201, "Skill uploaded into drive", console_ns.models[AgentSkillUploadResponse.__name__])
|
||||
@console_ns.response(400, "Invalid skill package or no bound agent")
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_current_user
|
||||
@with_session
|
||||
@get_app_model(mode=_WORKFLOW_AGENT_DRIVE_APP_MODES)
|
||||
def post(self, session: Session, current_user: Account, app_model: App):
|
||||
"""Upload a Skill, validate it, and commit drive-backed skill files."""
|
||||
return _upload_skill_for_app(session=session, current_user=current_user, app_model=app_model)
|
||||
|
||||
|
||||
@console_ns.route("/agent/<uuid:agent_id>/files")
|
||||
class AgentDriveFilesByAgentApi(Resource):
|
||||
@console_ns.doc("commit_agent_drive_file_by_agent")
|
||||
@console_ns.doc(description="Commit an uploaded file into the Agent App drive under files/<name>")
|
||||
@console_ns.doc(params={"agent_id": "Agent ID"})
|
||||
@console_ns.expect(console_ns.models[AgentDriveFilePayload.__name__])
|
||||
@console_ns.response(
|
||||
201, "File committed into the agent drive", console_ns.models[AgentDriveFileCommitResponse.__name__]
|
||||
)
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
@with_session
|
||||
def post(self, session: Session, tenant_id: str, current_user: Account, agent_id: UUID):
|
||||
app_model = resolve_agent_runtime_app_model(session=session, tenant_id=tenant_id, agent_id=agent_id)
|
||||
return _commit_drive_file_for_app(
|
||||
session=session, current_user=current_user, app_model=app_model, allow_node_id=False
|
||||
)
|
||||
|
||||
@console_ns.doc("delete_agent_drive_file_by_agent")
|
||||
@console_ns.doc(description="Delete one Agent App drive file by key")
|
||||
@console_ns.doc(params={"agent_id": "Agent ID", **query_params_from_model(AgentDriveDeleteFileByAgentQuery)})
|
||||
@console_ns.response(200, "File removed", console_ns.models[AgentDriveDeleteResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
@with_session
|
||||
def delete(self, session: Session, tenant_id: str, current_user: Account, agent_id: UUID):
|
||||
app_model = resolve_agent_runtime_app_model(session=session, tenant_id=tenant_id, agent_id=agent_id)
|
||||
return _delete_drive_file_for_app(
|
||||
session=session, current_user=current_user, app_model=app_model, allow_node_id=False
|
||||
)
|
||||
|
||||
|
||||
@console_ns.route("/apps/<uuid:app_id>/agent/files")
|
||||
class AgentDriveFilesApi(Resource):
|
||||
@console_ns.doc("commit_agent_drive_file")
|
||||
@console_ns.doc(description="Commit an uploaded file into the agent drive under files/<name> (ENG-625 D3)")
|
||||
@console_ns.doc(params={"app_id": "Application ID", **query_params_from_model(AgentDriveMutationQuery)})
|
||||
@console_ns.expect(console_ns.models[AgentDriveFilePayload.__name__])
|
||||
@console_ns.response(
|
||||
201, "File committed into the agent drive", console_ns.models[AgentDriveFileCommitResponse.__name__]
|
||||
)
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_current_user
|
||||
@with_session
|
||||
@get_app_model(mode=_WORKFLOW_AGENT_DRIVE_APP_MODES)
|
||||
def post(self, session: Session, current_user: Account, app_model: App):
|
||||
"""ADD FILE: commit one uploaded file into the bound agent's drive."""
|
||||
return _commit_drive_file_for_app(session=session, current_user=current_user, app_model=app_model)
|
||||
|
||||
@console_ns.doc("delete_agent_drive_file")
|
||||
@console_ns.doc(description="Delete one drive file by key via drive commit-null semantics")
|
||||
@console_ns.doc(params={"app_id": "Application ID", **query_params_from_model(AgentDriveDeleteFileQuery)})
|
||||
@console_ns.response(200, "File removed", console_ns.models[AgentDriveDeleteResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_current_user
|
||||
@with_session
|
||||
@get_app_model(mode=_WORKFLOW_AGENT_DRIVE_APP_MODES)
|
||||
def delete(self, session: Session, current_user: Account, app_model: App):
|
||||
return _delete_drive_file_for_app(session=session, current_user=current_user, app_model=app_model)
|
||||
|
||||
|
||||
@console_ns.route("/agent/<uuid:agent_id>/skills/<string:slug>")
|
||||
class AgentSkillByAgentApi(Resource):
|
||||
@console_ns.doc("delete_agent_skill_by_agent")
|
||||
@console_ns.doc(description="Delete a standardized skill from an Agent App drive")
|
||||
@console_ns.doc(params={"agent_id": "Agent ID", "slug": "Skill slug (single path segment)"})
|
||||
@console_ns.response(200, "Skill removed", console_ns.models[AgentDriveDeleteResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
@with_session
|
||||
def delete(self, session: Session, tenant_id: str, current_user: Account, agent_id: UUID, slug: str):
|
||||
app_model = resolve_agent_runtime_app_model(session=session, tenant_id=tenant_id, agent_id=agent_id)
|
||||
return _delete_skill_for_app(
|
||||
session=session, current_user=current_user, app_model=app_model, slug=slug, allow_node_id=False
|
||||
)
|
||||
|
||||
|
||||
@console_ns.route("/apps/<uuid:app_id>/agent/skills/<string:slug>")
|
||||
class AgentSkillApi(Resource):
|
||||
@console_ns.doc("delete_agent_skill")
|
||||
@console_ns.doc(description="Delete a standardized skill by removing its known drive keys via commit-null")
|
||||
@console_ns.doc(
|
||||
params={
|
||||
"app_id": "Application ID",
|
||||
"slug": "Skill slug (single path segment)",
|
||||
**query_params_from_model(AgentDriveMutationQuery),
|
||||
}
|
||||
)
|
||||
@console_ns.response(200, "Skill removed", console_ns.models[AgentDriveDeleteResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_current_user
|
||||
@with_session
|
||||
@get_app_model(mode=_WORKFLOW_AGENT_DRIVE_APP_MODES)
|
||||
def delete(self, session: Session, current_user: Account, app_model: App, slug: str):
|
||||
return _delete_skill_for_app(session=session, current_user=current_user, app_model=app_model, slug=slug)
|
||||
|
||||
|
||||
@console_ns.route("/agent/<uuid:agent_id>/skills/<string:slug>/infer-tools")
|
||||
class AgentSkillInferToolsByAgentApi(Resource):
|
||||
@console_ns.doc("infer_agent_skill_tools_by_agent")
|
||||
@console_ns.doc(description="Infer CLI tool + ENV suggestions from a standardized Agent App skill")
|
||||
@console_ns.doc(params={"agent_id": "Agent ID", "slug": "Skill slug (single path segment)"})
|
||||
@console_ns.response(
|
||||
200,
|
||||
"Inference result (draft suggestions, nothing persisted)",
|
||||
console_ns.models[SkillToolInferenceResult.__name__],
|
||||
)
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_current_tenant_id
|
||||
@with_session(write=False)
|
||||
def post(self, session: Session, tenant_id: str, agent_id: UUID, slug: str):
|
||||
app_model = resolve_agent_runtime_app_model(session=session, tenant_id=tenant_id, agent_id=agent_id)
|
||||
return _infer_skill_tools_for_app(session=session, app_model=app_model, slug=slug)
|
||||
|
||||
|
||||
@console_ns.route("/apps/<uuid:app_id>/agent/skills/<string:slug>/infer-tools")
|
||||
class AgentSkillInferToolsApi(Resource):
|
||||
@console_ns.doc("infer_agent_skill_tools")
|
||||
@console_ns.doc(
|
||||
description="Infer CLI tool + ENV suggestions from a standardized skill's SKILL.md (draft only, ENG-371)"
|
||||
)
|
||||
@console_ns.doc(
|
||||
params={
|
||||
"app_id": "Application ID",
|
||||
"slug": "Skill slug (single path segment)",
|
||||
**query_params_from_model(AgentDriveMutationQuery),
|
||||
}
|
||||
)
|
||||
@console_ns.response(
|
||||
200,
|
||||
"Inference result (draft suggestions, nothing persisted)",
|
||||
console_ns.models[SkillToolInferenceResult.__name__],
|
||||
)
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_session(write=False)
|
||||
@get_app_model(mode=_WORKFLOW_AGENT_DRIVE_APP_MODES)
|
||||
def post(self, session: Session, app_model: App, slug: str):
|
||||
"""Suggest CLI tools/env for a skill. Saving still goes through composer validation."""
|
||||
return _infer_skill_tools_for_app(session=session, app_model=app_model, slug=slug)
|
||||
|
||||
@@ -24,11 +24,8 @@ from controllers.console import console_ns
|
||||
from controllers.console.app.error import AppNotFoundError
|
||||
from controllers.console.app.wraps import get_app_model
|
||||
from controllers.console.wraps import (
|
||||
RBACPermission,
|
||||
RBACResourceScope,
|
||||
account_initialization_required,
|
||||
model_validate,
|
||||
rbac_permission_required,
|
||||
setup_required,
|
||||
with_current_tenant_id,
|
||||
with_current_user,
|
||||
@@ -157,7 +154,6 @@ class AgentAppSandboxInfoResource(Resource):
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_VIEW_LAYOUT)
|
||||
@with_current_tenant_id
|
||||
@with_current_user
|
||||
def get(self, current_user: Account, tenant_id: str, agent_id: UUID):
|
||||
@@ -187,7 +183,6 @@ class AgentAppSandboxListResource(Resource):
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_VIEW_LAYOUT)
|
||||
@with_current_tenant_id
|
||||
@with_current_user
|
||||
def get(self, current_user: Account, tenant_id: str, agent_id: UUID):
|
||||
@@ -218,7 +213,6 @@ class AgentAppSandboxReadResource(Resource):
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_VIEW_LAYOUT)
|
||||
@with_current_tenant_id
|
||||
@with_current_user
|
||||
def get(self, current_user: Account, tenant_id: str, agent_id: UUID):
|
||||
@@ -249,7 +243,6 @@ class AgentAppSandboxDownloadResource(Resource):
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_VIEW_LAYOUT)
|
||||
@with_current_tenant_id
|
||||
@with_current_user
|
||||
@model_validate(AgentSandboxDownloadPayload)
|
||||
@@ -293,7 +286,6 @@ class WorkflowAgentSandboxListResource(Resource):
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_VIEW_LAYOUT)
|
||||
@get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW])
|
||||
@with_current_tenant_id
|
||||
def get(self, tenant_id: str, app_model: App, workflow_run_id: UUID, node_id: str):
|
||||
@@ -331,7 +323,6 @@ class WorkflowAgentSandboxReadResource(Resource):
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_VIEW_LAYOUT)
|
||||
@get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW])
|
||||
@with_current_tenant_id
|
||||
def get(self, tenant_id: str, app_model: App, workflow_run_id: UUID, node_id: str):
|
||||
@@ -362,7 +353,6 @@ class WorkflowAgentSandboxDownloadResource(Resource):
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_VIEW_LAYOUT)
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
@model_validate(WorkflowAgentSandboxDownloadPayload)
|
||||
|
||||
434
api/controllers/console/app/agent_drive_inspector.py
Normal file
434
api/controllers/console/app/agent_drive_inspector.py
Normal file
@@ -0,0 +1,434 @@
|
||||
"""Console read-only inspector for the agent drive (ENG-624).
|
||||
|
||||
``agent-drive`` looks at the *static* drive assets (standardized skills and
|
||||
committed files); the sibling ``agent-sandbox`` routes look at a *runtime*
|
||||
sandbox workspace. Unlike the sandbox routes this never proxies to the agent
|
||||
backend — drive data lives in the API's own DB/storage, served straight from
|
||||
``AgentDriveService``. Download hands the browser an **external** signed URL
|
||||
(the inner manifest hands agents internal ones — the two must never mix).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from flask import Response
|
||||
from flask_restx import Resource
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from controllers.common.schema import (
|
||||
query_params_from_model,
|
||||
query_params_from_request,
|
||||
register_response_schema_models,
|
||||
)
|
||||
from controllers.common.session import with_session
|
||||
from controllers.console import console_ns
|
||||
from controllers.console.agent.app_helpers import resolve_agent_runtime_app_model
|
||||
from controllers.console.app.wraps import get_app_model
|
||||
from controllers.console.wraps import account_initialization_required, setup_required, with_current_tenant_id
|
||||
from fields.base import ResponseModel
|
||||
from libs.login import login_required
|
||||
from models.model import App, AppMode
|
||||
from services.agent.composer_service import AgentComposerService
|
||||
from services.agent_drive_service import AgentDriveError, AgentDriveService
|
||||
|
||||
|
||||
class AgentDriveListQuery(BaseModel):
|
||||
prefix: str = Field(default="", description="Key prefix filter: '<slug>/' for one skill, 'files/' for files")
|
||||
node_id: str | None = Field(default=None, description="Workflow node ID (workflow composer variant)")
|
||||
|
||||
|
||||
class AgentDriveListByAgentQuery(BaseModel):
|
||||
prefix: str = Field(default="", description="Key prefix filter: '<slug>/' for one skill, 'files/' for files")
|
||||
|
||||
|
||||
class AgentDriveFileQuery(BaseModel):
|
||||
key: str = Field(min_length=1, description="Drive key, e.g. tender-analyzer/SKILL.md")
|
||||
node_id: str | None = Field(default=None, description="Workflow node ID (workflow composer variant)")
|
||||
|
||||
|
||||
class AgentDriveFileByAgentQuery(BaseModel):
|
||||
key: str = Field(min_length=1, description="Drive key, e.g. tender-analyzer/SKILL.md")
|
||||
|
||||
|
||||
class AgentDriveSkillInspectQuery(BaseModel):
|
||||
node_id: str | None = Field(default=None, description="Workflow node ID (workflow composer variant)")
|
||||
|
||||
|
||||
class AgentDriveItemResponse(ResponseModel):
|
||||
key: str
|
||||
size: int | None = None
|
||||
mime_type: str | None = None
|
||||
hash: str | None = None
|
||||
file_kind: str
|
||||
created_at: int | None = None
|
||||
is_skill: bool | None = None
|
||||
skill_metadata: str | None = None
|
||||
|
||||
|
||||
class AgentDriveListResponse(ResponseModel):
|
||||
items: list[AgentDriveItemResponse] = Field(default_factory=list)
|
||||
|
||||
|
||||
class AgentDriveSkillItemResponse(ResponseModel):
|
||||
path: str
|
||||
skill_md_key: str
|
||||
archive_key: str | None = None
|
||||
name: str
|
||||
description: str
|
||||
size: int | None = None
|
||||
mime_type: str | None = None
|
||||
hash: str | None = None
|
||||
created_at: int | None = None
|
||||
|
||||
|
||||
class AgentDriveSkillListResponse(ResponseModel):
|
||||
items: list[AgentDriveSkillItemResponse] = Field(default_factory=list)
|
||||
|
||||
|
||||
class AgentDriveSkillFileResponse(ResponseModel):
|
||||
path: str
|
||||
name: str
|
||||
type: str
|
||||
drive_key: str | None = None
|
||||
available_in_drive: bool
|
||||
|
||||
|
||||
class AgentDriveSkillMarkdownResponse(ResponseModel):
|
||||
key: str
|
||||
size: int | None = None
|
||||
truncated: bool
|
||||
binary: bool
|
||||
text: str | None = None
|
||||
|
||||
|
||||
class AgentDriveSkillInspectResponse(ResponseModel):
|
||||
path: str
|
||||
skill_md_key: str
|
||||
archive_key: str | None = None
|
||||
name: str
|
||||
description: str
|
||||
size: int | None = None
|
||||
mime_type: str | None = None
|
||||
hash: str | None = None
|
||||
created_at: int | None = None
|
||||
source: str
|
||||
files: list[AgentDriveSkillFileResponse] = Field(default_factory=list)
|
||||
file_tree: list[dict[str, Any]] = Field(default_factory=list)
|
||||
skill_md: AgentDriveSkillMarkdownResponse
|
||||
warnings: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class AgentDrivePreviewResponse(ResponseModel):
|
||||
key: str
|
||||
size: int | None = None
|
||||
truncated: bool
|
||||
binary: bool
|
||||
text: str | None = None
|
||||
|
||||
|
||||
class AgentDriveDownloadResponse(ResponseModel):
|
||||
url: str
|
||||
|
||||
|
||||
register_response_schema_models(
|
||||
console_ns,
|
||||
AgentDriveDownloadResponse,
|
||||
AgentDriveListResponse,
|
||||
AgentDrivePreviewResponse,
|
||||
AgentDriveSkillInspectResponse,
|
||||
AgentDriveSkillListResponse,
|
||||
)
|
||||
|
||||
|
||||
def _resolve_agent_id(session: Session, app_model: App, node_id: str | None) -> str | None:
|
||||
"""Agent identity for the drive: app-bound agent, or the workflow node binding."""
|
||||
if node_id:
|
||||
return AgentComposerService.resolve_workflow_node_agent_id(
|
||||
session=session, tenant_id=app_model.tenant_id, app_id=app_model.id, node_id=node_id
|
||||
)
|
||||
return app_model.bound_agent_id_with_session(session=session)
|
||||
|
||||
|
||||
def _agent_not_bound() -> tuple[dict[str, object], int]:
|
||||
return {"code": "agent_not_bound", "message": "no agent is bound for this app/node"}, 400
|
||||
|
||||
|
||||
def _handle(exc: AgentDriveError) -> tuple[dict[str, object], int]:
|
||||
return {"code": exc.code, "message": exc.message}, exc.status_code
|
||||
|
||||
|
||||
def _json_response(data: Mapping[str, Any]):
|
||||
return Response(
|
||||
response=json.dumps(data, ensure_ascii=False, separators=(",", ":")),
|
||||
content_type="application/json; charset=utf-8",
|
||||
)
|
||||
|
||||
|
||||
_WORKFLOW_APP_MODES = [AppMode.WORKFLOW, AppMode.ADVANCED_CHAT]
|
||||
|
||||
|
||||
@console_ns.route("/agent/<uuid:agent_id>/drive/files")
|
||||
class AgentDriveListByAgentApi(Resource):
|
||||
@console_ns.doc("list_agent_drive_files_by_agent")
|
||||
@console_ns.doc(description="List agent drive entries for an Agent App")
|
||||
@console_ns.doc(params={"agent_id": "Agent ID", **query_params_from_model(AgentDriveListByAgentQuery)})
|
||||
@console_ns.response(200, "Drive entries", console_ns.models[AgentDriveListResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_current_tenant_id
|
||||
@with_session(write=False)
|
||||
def get(self, session: Session, tenant_id: str, agent_id: UUID):
|
||||
query = query_params_from_request(AgentDriveListByAgentQuery)
|
||||
resolve_agent_runtime_app_model(session=session, tenant_id=tenant_id, agent_id=agent_id)
|
||||
try:
|
||||
items = AgentDriveService().manifest(
|
||||
tenant_id=tenant_id, agent_id=str(agent_id), prefix=query.prefix, session=session
|
||||
)
|
||||
except AgentDriveError as exc:
|
||||
return _handle(exc)
|
||||
return {"items": [{k: v for k, v in item.items() if k != "file_id"} for item in items]}
|
||||
|
||||
|
||||
@console_ns.route("/agent/<uuid:agent_id>/drive/skills")
|
||||
class AgentDriveSkillListByAgentApi(Resource):
|
||||
@console_ns.doc("list_agent_drive_skills_by_agent")
|
||||
@console_ns.doc(description="List drive-backed skills for an Agent App")
|
||||
@console_ns.doc(params={"agent_id": "Agent ID"})
|
||||
@console_ns.response(200, "Drive skills", console_ns.models[AgentDriveSkillListResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_current_tenant_id
|
||||
@with_session(write=False)
|
||||
def get(self, session: Session, tenant_id: str, agent_id: UUID):
|
||||
resolve_agent_runtime_app_model(session=session, tenant_id=tenant_id, agent_id=agent_id)
|
||||
try:
|
||||
items = AgentDriveService().list_skills(tenant_id=tenant_id, agent_id=str(agent_id), session=session)
|
||||
except AgentDriveError as exc:
|
||||
return _handle(exc)
|
||||
return {"items": items}
|
||||
|
||||
|
||||
@console_ns.route("/agent/<uuid:agent_id>/drive/skills/<path:skill_path>/inspect")
|
||||
class AgentDriveSkillInspectByAgentApi(Resource):
|
||||
@console_ns.doc("inspect_agent_drive_skill_by_agent")
|
||||
@console_ns.doc(description="Inspect one drive-backed skill for slash-menu hover/detail UI")
|
||||
@console_ns.doc(params={"agent_id": "Agent ID", "skill_path": "Skill path/slug, e.g. tender-analyzer"})
|
||||
@console_ns.response(200, "Drive skill inspect view", console_ns.models[AgentDriveSkillInspectResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_current_tenant_id
|
||||
@with_session(write=False)
|
||||
def get(self, session: Session, tenant_id: str, agent_id: UUID, skill_path: str):
|
||||
resolve_agent_runtime_app_model(session=session, tenant_id=tenant_id, agent_id=agent_id)
|
||||
try:
|
||||
return _json_response(
|
||||
AgentDriveService().inspect_skill(
|
||||
tenant_id=tenant_id,
|
||||
agent_id=str(agent_id),
|
||||
skill_path=skill_path,
|
||||
session=session,
|
||||
)
|
||||
)
|
||||
except AgentDriveError as exc:
|
||||
return _handle(exc)
|
||||
|
||||
|
||||
@console_ns.route("/agent/<uuid:agent_id>/drive/files/preview")
|
||||
class AgentDrivePreviewByAgentApi(Resource):
|
||||
@console_ns.doc("preview_agent_drive_file_by_agent")
|
||||
@console_ns.doc(description="Truncated text preview of one Agent App drive value")
|
||||
@console_ns.doc(params={"agent_id": "Agent ID", **query_params_from_model(AgentDriveFileByAgentQuery)})
|
||||
@console_ns.response(200, "Preview", console_ns.models[AgentDrivePreviewResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_current_tenant_id
|
||||
@with_session(write=False)
|
||||
def get(self, session: Session, tenant_id: str, agent_id: UUID):
|
||||
query = query_params_from_request(AgentDriveFileByAgentQuery)
|
||||
resolve_agent_runtime_app_model(session=session, tenant_id=tenant_id, agent_id=agent_id)
|
||||
try:
|
||||
return AgentDriveService().preview(
|
||||
tenant_id=tenant_id, agent_id=str(agent_id), key=query.key, session=session
|
||||
)
|
||||
except AgentDriveError as exc:
|
||||
return _handle(exc)
|
||||
|
||||
|
||||
@console_ns.route("/agent/<uuid:agent_id>/drive/files/download")
|
||||
class AgentDriveDownloadByAgentApi(Resource):
|
||||
@console_ns.doc("download_agent_drive_file_by_agent")
|
||||
@console_ns.doc(description="Time-limited external signed URL for one Agent App drive value")
|
||||
@console_ns.doc(params={"agent_id": "Agent ID", **query_params_from_model(AgentDriveFileByAgentQuery)})
|
||||
@console_ns.response(200, "Signed URL", console_ns.models[AgentDriveDownloadResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_current_tenant_id
|
||||
@with_session(write=False)
|
||||
def get(self, session: Session, tenant_id: str, agent_id: UUID):
|
||||
query = query_params_from_request(AgentDriveFileByAgentQuery)
|
||||
resolve_agent_runtime_app_model(session=session, tenant_id=tenant_id, agent_id=agent_id)
|
||||
try:
|
||||
url = AgentDriveService().download_url(
|
||||
tenant_id=tenant_id, agent_id=str(agent_id), key=query.key, session=session
|
||||
)
|
||||
except AgentDriveError as exc:
|
||||
return _handle(exc)
|
||||
return {"url": url}
|
||||
|
||||
|
||||
@console_ns.route("/apps/<uuid:app_id>/agent/drive/files")
|
||||
class AgentDriveListApi(Resource):
|
||||
@console_ns.doc("list_agent_drive_files")
|
||||
@console_ns.doc(description="List agent drive entries (read-only inspector; one endpoint for both tabs)")
|
||||
@console_ns.doc(params={"app_id": "Application ID", **query_params_from_model(AgentDriveListQuery)})
|
||||
@console_ns.response(200, "Drive entries", console_ns.models[AgentDriveListResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_session(write=False)
|
||||
@get_app_model(mode=_WORKFLOW_APP_MODES)
|
||||
def get(self, session: Session, app_model: App):
|
||||
query = query_params_from_request(AgentDriveListQuery)
|
||||
agent_id = _resolve_agent_id(session, app_model, query.node_id)
|
||||
if not agent_id:
|
||||
return _agent_not_bound()
|
||||
try:
|
||||
items = AgentDriveService().manifest(
|
||||
tenant_id=app_model.tenant_id, agent_id=agent_id, prefix=query.prefix, session=session
|
||||
)
|
||||
except AgentDriveError as exc:
|
||||
return _handle(exc)
|
||||
# the inner manifest exposes file_id for agent-side pulls; the console
|
||||
# inspector is a pure read surface and does not need value pointers
|
||||
return {"items": [{k: v for k, v in item.items() if k != "file_id"} for item in items]}
|
||||
|
||||
|
||||
@console_ns.route("/apps/<uuid:app_id>/agent/drive/skills")
|
||||
class AgentDriveSkillListApi(Resource):
|
||||
@console_ns.doc("list_agent_drive_skills")
|
||||
@console_ns.doc(description="List drive-backed skills for the bound agent")
|
||||
@console_ns.doc(params={"app_id": "Application ID", **query_params_from_model(AgentDriveListQuery)})
|
||||
@console_ns.response(200, "Drive skills", console_ns.models[AgentDriveSkillListResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_session(write=False)
|
||||
@get_app_model(mode=_WORKFLOW_APP_MODES)
|
||||
def get(self, session: Session, app_model: App):
|
||||
query = query_params_from_request(AgentDriveListQuery)
|
||||
agent_id = _resolve_agent_id(session, app_model, query.node_id)
|
||||
if not agent_id:
|
||||
return _agent_not_bound()
|
||||
try:
|
||||
items = AgentDriveService().list_skills(tenant_id=app_model.tenant_id, agent_id=agent_id, session=session)
|
||||
except AgentDriveError as exc:
|
||||
return _handle(exc)
|
||||
return {"items": items}
|
||||
|
||||
|
||||
@console_ns.route("/apps/<uuid:app_id>/agent/drive/skills/<path:skill_path>/inspect")
|
||||
class AgentDriveSkillInspectApi(Resource):
|
||||
@console_ns.doc("inspect_agent_drive_skill")
|
||||
@console_ns.doc(description="Inspect one drive-backed skill for slash-menu hover/detail UI")
|
||||
@console_ns.doc(
|
||||
params={
|
||||
"app_id": "Application ID",
|
||||
"skill_path": "Skill path/slug, e.g. tender-analyzer",
|
||||
**query_params_from_model(AgentDriveSkillInspectQuery),
|
||||
}
|
||||
)
|
||||
@console_ns.response(200, "Drive skill inspect view", console_ns.models[AgentDriveSkillInspectResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_session(write=False)
|
||||
@get_app_model(mode=_WORKFLOW_APP_MODES)
|
||||
def get(self, session: Session, app_model: App, skill_path: str):
|
||||
query = query_params_from_request(AgentDriveSkillInspectQuery)
|
||||
agent_id = _resolve_agent_id(session, app_model, query.node_id)
|
||||
if not agent_id:
|
||||
return _agent_not_bound()
|
||||
try:
|
||||
return _json_response(
|
||||
AgentDriveService().inspect_skill(
|
||||
tenant_id=app_model.tenant_id,
|
||||
agent_id=agent_id,
|
||||
skill_path=skill_path,
|
||||
session=session,
|
||||
)
|
||||
)
|
||||
except AgentDriveError as exc:
|
||||
return _handle(exc)
|
||||
|
||||
|
||||
@console_ns.route("/apps/<uuid:app_id>/agent/drive/files/preview")
|
||||
class AgentDrivePreviewApi(Resource):
|
||||
@console_ns.doc("preview_agent_drive_file")
|
||||
@console_ns.doc(description="Truncated text preview of one drive value (binary-safe; SKILL.md is the main case)")
|
||||
@console_ns.doc(params={"app_id": "Application ID", **query_params_from_model(AgentDriveFileQuery)})
|
||||
@console_ns.response(200, "Preview", console_ns.models[AgentDrivePreviewResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_session(write=False)
|
||||
@get_app_model(mode=_WORKFLOW_APP_MODES)
|
||||
def get(self, session: Session, app_model: App):
|
||||
query = query_params_from_request(AgentDriveFileQuery)
|
||||
agent_id = _resolve_agent_id(session, app_model, query.node_id)
|
||||
if not agent_id:
|
||||
return _agent_not_bound()
|
||||
try:
|
||||
return AgentDriveService().preview(
|
||||
tenant_id=app_model.tenant_id, agent_id=agent_id, key=query.key, session=session
|
||||
)
|
||||
except AgentDriveError as exc:
|
||||
return _handle(exc)
|
||||
|
||||
|
||||
@console_ns.route("/apps/<uuid:app_id>/agent/drive/files/download")
|
||||
class AgentDriveDownloadApi(Resource):
|
||||
@console_ns.doc("download_agent_drive_file")
|
||||
@console_ns.doc(description="Time-limited external signed URL for one drive value (no streaming proxy)")
|
||||
@console_ns.doc(params={"app_id": "Application ID", **query_params_from_model(AgentDriveFileQuery)})
|
||||
@console_ns.response(200, "Signed URL", console_ns.models[AgentDriveDownloadResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_session(write=False)
|
||||
@get_app_model(mode=_WORKFLOW_APP_MODES)
|
||||
def get(self, session: Session, app_model: App):
|
||||
query = query_params_from_request(AgentDriveFileQuery)
|
||||
agent_id = _resolve_agent_id(session, app_model, query.node_id)
|
||||
if not agent_id:
|
||||
return _agent_not_bound()
|
||||
try:
|
||||
url = AgentDriveService().download_url(
|
||||
tenant_id=app_model.tenant_id, agent_id=agent_id, key=query.key, session=session
|
||||
)
|
||||
except AgentDriveError as exc:
|
||||
return _handle(exc)
|
||||
return {"url": url}
|
||||
|
||||
|
||||
__all__ = [
|
||||
"AgentDriveDownloadApi",
|
||||
"AgentDriveDownloadByAgentApi",
|
||||
"AgentDriveListApi",
|
||||
"AgentDriveListByAgentApi",
|
||||
"AgentDrivePreviewApi",
|
||||
"AgentDrivePreviewByAgentApi",
|
||||
"AgentDriveSkillInspectApi",
|
||||
"AgentDriveSkillInspectByAgentApi",
|
||||
"AgentDriveSkillListApi",
|
||||
"AgentDriveSkillListByAgentApi",
|
||||
]
|
||||
@@ -315,9 +315,6 @@ class WorkflowResponse(ResponseModel):
|
||||
)
|
||||
hash: str = Field(validation_alias=AliasChoices("unique_hash", "hash"))
|
||||
version: str
|
||||
# NULL for drafts and for versions published before numbering was introduced; those
|
||||
# render as "Untitled Version" instead of `#N`. Never 0, so clients must test for null.
|
||||
version_number: int | None = None
|
||||
marked_name: str
|
||||
marked_comment: str
|
||||
created_by: SimpleAccount | None = Field(
|
||||
|
||||
@@ -1,22 +1,24 @@
|
||||
from flask import request
|
||||
from flask_restx import Resource
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from sqlalchemy import select
|
||||
|
||||
from configs import dify_config
|
||||
from constants.languages import supported_language
|
||||
from controllers.common.schema import query_params_from_model, register_schema_models
|
||||
from controllers.console import console_ns
|
||||
from controllers.console.auth.error import InvitationAccountMismatchError as InvitationAccountMismatchHTTPError
|
||||
from controllers.console.auth.error import InvitationAccountMismatchError
|
||||
from controllers.console.error import AccountInFreezeError, AlreadyActivateError
|
||||
from extensions.ext_application_services import application_services
|
||||
from libs.helper import EmailStr, dump_response, timezone
|
||||
from controllers.console.wraps import model_validate
|
||||
from extensions.ext_database import db
|
||||
from libs.datetime_utils import naive_utc_now
|
||||
from libs.helper import EmailStr, timezone
|
||||
from libs.login import current_account_with_tenant
|
||||
from libs.token import extract_access_token
|
||||
from services.account_activation_service import (
|
||||
FrozenAccountError,
|
||||
InvalidInvitationError,
|
||||
InvitationAccountMismatchError,
|
||||
)
|
||||
from services.entities.account_activation_entities import ActivationCommand, InvitationLookup
|
||||
from models import AccountStatus
|
||||
from models.account import TenantAccountJoin, TenantAccountRole
|
||||
from services.account_service import RegisterService, TenantService
|
||||
from services.billing_service import BillingService
|
||||
|
||||
|
||||
class ActivateCheckQuery(BaseModel):
|
||||
@@ -85,19 +87,45 @@ class ActivateCheckApi(Resource):
|
||||
"Success",
|
||||
console_ns.models[ActivationCheckResponse.__name__],
|
||||
)
|
||||
def get(self):
|
||||
args = ActivateCheckQuery.model_validate(request.args.to_dict(flat=True))
|
||||
result = application_services().account_activation.check(
|
||||
InvitationLookup(
|
||||
workspace_id=args.workspace_id,
|
||||
email=args.email,
|
||||
token=args.token,
|
||||
),
|
||||
)
|
||||
return ActivationCheckResponse.model_validate(result, from_attributes=True).model_dump(
|
||||
mode="json",
|
||||
exclude_none=True,
|
||||
@model_validate(ActivateCheckQuery)
|
||||
def get(self, req_data: ActivateCheckQuery):
|
||||
|
||||
workspaceId = req_data.workspace_id
|
||||
token = req_data.token
|
||||
|
||||
invitation = RegisterService.get_invitation_with_case_fallback(
|
||||
workspaceId, req_data.email, token, session=db.session()
|
||||
)
|
||||
if invitation:
|
||||
data = invitation.get("data", {})
|
||||
tenant = invitation.get("tenant", None)
|
||||
|
||||
# Check workspace permission
|
||||
if tenant:
|
||||
from libs.workspace_permission import check_workspace_member_invite_permission
|
||||
|
||||
check_workspace_member_invite_permission(tenant.id)
|
||||
|
||||
workspace_name = tenant.name if tenant else None
|
||||
workspace_id = tenant.id if tenant else None
|
||||
invitee_email = data.get("email") if data else None
|
||||
account = invitation.get("account")
|
||||
account_status = account.status if account else None
|
||||
requires_setup = data.get("requires_setup")
|
||||
if requires_setup is None:
|
||||
requires_setup = account_status == AccountStatus.PENDING
|
||||
return {
|
||||
"is_valid": invitation is not None,
|
||||
"data": {
|
||||
"workspace_name": workspace_name,
|
||||
"workspace_id": workspace_id,
|
||||
"email": invitee_email,
|
||||
"account_status": account_status,
|
||||
"requires_setup": requires_setup,
|
||||
},
|
||||
}
|
||||
else:
|
||||
return {"is_valid": False}
|
||||
|
||||
|
||||
@console_ns.route("/activate")
|
||||
@@ -111,37 +139,70 @@ class ActivateApi(Resource):
|
||||
console_ns.models[ActivationResponse.__name__],
|
||||
)
|
||||
@console_ns.response(400, "Already activated or invalid token")
|
||||
def post(self):
|
||||
@model_validate(ActivatePayload)
|
||||
def post(self, req_data: ActivatePayload):
|
||||
"""Accept an invitation without letting an existing session act for another account.
|
||||
|
||||
Token-only activation remains available for legacy clients. When the request already
|
||||
carries a console session, that session must belong to the account encoded in the
|
||||
invitation before the token is consumed or tenant membership is changed.
|
||||
"""
|
||||
args = ActivatePayload.model_validate(console_ns.payload or {})
|
||||
authenticated_account_id: str | None = None
|
||||
if extract_access_token(request) is not None:
|
||||
authenticated_account_id = current_account_with_tenant().account.id
|
||||
|
||||
normalized_request_email = req_data.email.lower() if req_data.email else None
|
||||
invitation = RegisterService.get_invitation_with_case_fallback(
|
||||
req_data.workspace_id, req_data.email, req_data.token, session=db.session()
|
||||
)
|
||||
if invitation is None:
|
||||
raise AlreadyActivateError()
|
||||
|
||||
account = invitation["account"]
|
||||
if extract_access_token(request):
|
||||
current_account, _ = current_account_with_tenant()
|
||||
if current_account.id != account.id:
|
||||
raise InvitationAccountMismatchError()
|
||||
|
||||
if dify_config.BILLING_ENABLED and BillingService.is_email_in_freeze(account.email):
|
||||
raise AccountInFreezeError()
|
||||
|
||||
tenant = invitation["tenant"]
|
||||
raw_role = invitation["data"].get("role")
|
||||
try:
|
||||
application_services().account_activation.activate(
|
||||
ActivationCommand(
|
||||
invitation=InvitationLookup(
|
||||
workspace_id=args.workspace_id,
|
||||
email=args.email,
|
||||
token=args.token,
|
||||
),
|
||||
name=args.name,
|
||||
interface_language=args.interface_language,
|
||||
timezone=args.timezone,
|
||||
),
|
||||
authenticated_account_id=authenticated_account_id,
|
||||
)
|
||||
except InvalidInvitationError:
|
||||
raise AlreadyActivateError() from None
|
||||
except InvitationAccountMismatchError:
|
||||
raise InvitationAccountMismatchHTTPError() from None
|
||||
except FrozenAccountError:
|
||||
raise AccountInFreezeError() from None
|
||||
role = TenantAccountRole(raw_role) if raw_role else TenantAccountRole.NORMAL
|
||||
except ValueError:
|
||||
role = TenantAccountRole.NORMAL
|
||||
if not TenantAccountRole.is_non_owner_role(role):
|
||||
role = TenantAccountRole.NORMAL
|
||||
|
||||
return dump_response(ActivationResponse, {"result": "success"})
|
||||
membership_id = db.session.scalar(
|
||||
select(TenantAccountJoin.id).where(
|
||||
TenantAccountJoin.tenant_id == tenant.id,
|
||||
TenantAccountJoin.account_id == account.id,
|
||||
)
|
||||
)
|
||||
|
||||
requires_setup = invitation["data"].get("requires_setup")
|
||||
if requires_setup is None:
|
||||
requires_setup = account.status == AccountStatus.PENDING
|
||||
|
||||
setup_fields: tuple[str, str, str] | None = None
|
||||
if requires_setup:
|
||||
if not req_data.name or not req_data.interface_language or not req_data.timezone:
|
||||
raise AlreadyActivateError()
|
||||
setup_fields = (req_data.name, req_data.interface_language, req_data.timezone)
|
||||
|
||||
RegisterService.revoke_token(req_data.workspace_id, normalized_request_email, req_data.token)
|
||||
|
||||
if membership_id is None:
|
||||
TenantService.create_tenant_member(tenant, account, db.session(), role=role)
|
||||
|
||||
if setup_fields:
|
||||
account.name = setup_fields[0]
|
||||
account.interface_language = setup_fields[1]
|
||||
account.timezone = setup_fields[2]
|
||||
account.interface_theme = "light"
|
||||
account.status = AccountStatus.ACTIVE
|
||||
account.initialized_at = naive_utc_now()
|
||||
|
||||
TenantService.switch_tenant(account, tenant.id, session=db.session())
|
||||
|
||||
return {"result": "success"}
|
||||
|
||||
@@ -1,52 +1,33 @@
|
||||
from http import HTTPStatus
|
||||
from uuid import UUID
|
||||
|
||||
from flask_restx import Resource
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from controllers.common.fields import SimpleResultResponse
|
||||
from controllers.common.schema import register_response_schema_models, register_schema_models
|
||||
from controllers.console import console_ns
|
||||
from controllers.console.auth.error import (
|
||||
DataSourceApiKeyAuthCredentialsRejectedRequestError,
|
||||
DataSourceApiKeyAuthProviderNotSupportedError,
|
||||
DataSourceApiKeyAuthProviderUnavailableRequestError,
|
||||
InvalidDataSourceApiKeyAuthCredentialsRequestError,
|
||||
)
|
||||
from controllers.console.flask_admission import console_account_admission
|
||||
from controllers.console.wraps import RBACPermission, RBACResourceScope, model_validate
|
||||
from extensions.ext_application_services import application_services
|
||||
from extensions.ext_database import db
|
||||
from fields.base import ResponseModel
|
||||
from libs.helper import dump_response
|
||||
from machinery.context import RequestContext
|
||||
from models.account import TenantAccountRole
|
||||
from services.auth.errors import (
|
||||
DataSourceApiKeyAuthCredentialValidationError,
|
||||
DataSourceApiKeyAuthProviderUnavailableError,
|
||||
InvalidDataSourceApiKeyAuthCredentialsError,
|
||||
UnsupportedDataSourceApiKeyAuthProviderError,
|
||||
from libs.login import login_required
|
||||
from services.auth.api_key_auth_service import ApiKeyAuthService
|
||||
|
||||
from .. import console_ns
|
||||
from ..auth.error import ApiKeyAuthFailedError
|
||||
from ..wraps import (
|
||||
RBACPermission,
|
||||
RBACResourceScope,
|
||||
account_initialization_required,
|
||||
is_admin_or_owner_required,
|
||||
model_validate,
|
||||
rbac_permission_required,
|
||||
setup_required,
|
||||
with_current_tenant_id,
|
||||
)
|
||||
from services.entities.data_source_api_key_auth_entities import (
|
||||
DataSourceApiKeyAuthBindingCreate,
|
||||
DataSourceApiKeyAuthCredentials,
|
||||
)
|
||||
|
||||
|
||||
class ApiKeyAuthConfigPayload(BaseModel):
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
api_key: str = Field(min_length=1)
|
||||
|
||||
|
||||
class ApiKeyAuthCredentialsPayload(BaseModel):
|
||||
auth_type: str = Field(min_length=1)
|
||||
config: ApiKeyAuthConfigPayload
|
||||
|
||||
|
||||
class ApiKeyAuthBindingPayload(BaseModel):
|
||||
category: str = Field(min_length=1)
|
||||
provider: str = Field(min_length=1)
|
||||
credentials: ApiKeyAuthCredentialsPayload
|
||||
category: str = Field(...)
|
||||
provider: str = Field(...)
|
||||
credentials: dict = Field(...)
|
||||
|
||||
|
||||
class ApiKeyAuthDataSourceItem(ResponseModel):
|
||||
@@ -71,77 +52,65 @@ register_response_schema_models(
|
||||
)
|
||||
|
||||
|
||||
_ADMIN_OR_OWNER_ROLES = frozenset({TenantAccountRole.ADMIN, TenantAccountRole.OWNER})
|
||||
|
||||
|
||||
@console_ns.route("/api-key-auth/data-source")
|
||||
class ApiKeyAuthDataSource(Resource):
|
||||
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[ApiKeyAuthDataSourceListResponse.__name__])
|
||||
@console_account_admission()
|
||||
def get(self, request_context: RequestContext):
|
||||
bindings = application_services().data_source_api_key_auth.list_bindings(request_context)
|
||||
return dump_response(
|
||||
ApiKeyAuthDataSourceListResponse,
|
||||
{
|
||||
@console_ns.response(200, "Success", console_ns.models[ApiKeyAuthDataSourceListResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_current_tenant_id
|
||||
def get(self, current_tenant_id: str):
|
||||
data_source_api_key_bindings = ApiKeyAuthService.get_provider_auth_list(current_tenant_id, session=db.session())
|
||||
if data_source_api_key_bindings:
|
||||
return {
|
||||
"sources": [
|
||||
{
|
||||
"id": binding.id,
|
||||
"category": binding.category,
|
||||
"provider": binding.provider,
|
||||
"disabled": binding.disabled,
|
||||
"created_at": int(binding.created_at.timestamp()),
|
||||
"updated_at": int(binding.updated_at.timestamp()),
|
||||
"id": data_source_api_key_binding.id,
|
||||
"category": data_source_api_key_binding.category,
|
||||
"provider": data_source_api_key_binding.provider,
|
||||
"disabled": data_source_api_key_binding.disabled,
|
||||
"created_at": int(data_source_api_key_binding.created_at.timestamp()),
|
||||
"updated_at": int(data_source_api_key_binding.updated_at.timestamp()),
|
||||
}
|
||||
for binding in bindings
|
||||
for data_source_api_key_binding in data_source_api_key_bindings
|
||||
]
|
||||
},
|
||||
)
|
||||
}
|
||||
return {"sources": []}
|
||||
|
||||
|
||||
@console_ns.route("/api-key-auth/data-source/binding")
|
||||
class ApiKeyAuthDataSourceBinding(Resource):
|
||||
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[SimpleResultResponse.__name__])
|
||||
@console_ns.response(200, "Success", console_ns.models[SimpleResultResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@is_admin_or_owner_required
|
||||
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.CREDENTIAL_CREATE, resource_required=False)
|
||||
@console_ns.expect(console_ns.models[ApiKeyAuthBindingPayload.__name__])
|
||||
@console_account_admission(
|
||||
allowed_roles=_ADMIN_OR_OWNER_ROLES,
|
||||
rbac_resource_scope=RBACResourceScope.WORKSPACE,
|
||||
rbac_permission=RBACPermission.CREDENTIAL_CREATE,
|
||||
rbac_resource_required=False,
|
||||
)
|
||||
@with_current_tenant_id
|
||||
@model_validate(ApiKeyAuthBindingPayload)
|
||||
def post(self, req_data: ApiKeyAuthBindingPayload, request_context: RequestContext):
|
||||
credential_options = req_data.credentials.config.model_dump(exclude={"api_key"})
|
||||
command = DataSourceApiKeyAuthBindingCreate(
|
||||
category=req_data.category,
|
||||
provider=req_data.provider,
|
||||
credentials=DataSourceApiKeyAuthCredentials(
|
||||
auth_type=req_data.credentials.auth_type,
|
||||
api_key=req_data.credentials.config.api_key,
|
||||
options=credential_options,
|
||||
),
|
||||
)
|
||||
def post(self, req_data: ApiKeyAuthBindingPayload, current_tenant_id: str):
|
||||
# The role of the current user in the table must be admin or owner
|
||||
data = req_data.model_dump()
|
||||
ApiKeyAuthService.validate_api_key_auth_args(data)
|
||||
try:
|
||||
application_services().data_source_api_key_auth.create_binding(request_context, command)
|
||||
except UnsupportedDataSourceApiKeyAuthProviderError as exc:
|
||||
raise DataSourceApiKeyAuthProviderNotSupportedError() from exc
|
||||
except InvalidDataSourceApiKeyAuthCredentialsError as exc:
|
||||
raise InvalidDataSourceApiKeyAuthCredentialsRequestError(description=str(exc)) from exc
|
||||
except DataSourceApiKeyAuthCredentialValidationError as exc:
|
||||
raise DataSourceApiKeyAuthCredentialsRejectedRequestError(description=str(exc)) from exc
|
||||
except DataSourceApiKeyAuthProviderUnavailableError as exc:
|
||||
raise DataSourceApiKeyAuthProviderUnavailableRequestError() from exc
|
||||
return dump_response(SimpleResultResponse, {"result": "success"}), HTTPStatus.OK
|
||||
ApiKeyAuthService.create_provider_auth(current_tenant_id, data, session=db.session())
|
||||
except Exception as e:
|
||||
raise ApiKeyAuthFailedError(str(e))
|
||||
return {"result": "success"}, 200
|
||||
|
||||
|
||||
@console_ns.route("/api-key-auth/data-source/<uuid:binding_id>")
|
||||
class ApiKeyAuthDataSourceBindingDelete(Resource):
|
||||
@console_ns.response(HTTPStatus.NO_CONTENT, "Binding deleted successfully")
|
||||
@console_account_admission(
|
||||
allowed_roles=_ADMIN_OR_OWNER_ROLES,
|
||||
rbac_resource_scope=RBACResourceScope.WORKSPACE,
|
||||
rbac_permission=RBACPermission.CREDENTIAL_MANAGE,
|
||||
rbac_resource_required=False,
|
||||
)
|
||||
def delete(self, request_context: RequestContext, binding_id: UUID):
|
||||
application_services().data_source_api_key_auth.delete_binding(request_context, str(binding_id))
|
||||
return "", HTTPStatus.NO_CONTENT
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@is_admin_or_owner_required
|
||||
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.CREDENTIAL_MANAGE, resource_required=False)
|
||||
@console_ns.response(204, "Binding deleted successfully")
|
||||
@with_current_tenant_id
|
||||
def delete(self, current_tenant_id: str, binding_id: UUID):
|
||||
# The role of the current user in the table must be admin or owner
|
||||
ApiKeyAuthService.delete_provider_auth(current_tenant_id, str(binding_id), session=db.session())
|
||||
|
||||
return "", 204
|
||||
|
||||
@@ -15,7 +15,6 @@ from controllers.console.auth.error import (
|
||||
InvalidTokenError,
|
||||
PasswordMismatchError,
|
||||
)
|
||||
from enums import DeploymentEdition
|
||||
from extensions.ext_database import db
|
||||
from fields.base import ResponseModel
|
||||
from libs.helper import EmailStr, extract_remote_ip
|
||||
@@ -99,9 +98,7 @@ class EmailRegisterSendEmailApi(Resource):
|
||||
if req_data.language is not None and req_data.language in languages:
|
||||
language = req_data.language
|
||||
|
||||
if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD and BillingService.is_email_in_freeze(
|
||||
normalized_email
|
||||
):
|
||||
if dify_config.BILLING_ENABLED and BillingService.is_email_in_freeze(normalized_email):
|
||||
raise AccountInFreezeError()
|
||||
|
||||
account = AccountService.get_account_by_email_with_case_fallback(req_data.email, session=db.session())
|
||||
@@ -184,15 +181,13 @@ class EmailRegisterResetApi(Resource):
|
||||
if account:
|
||||
raise EmailAlreadyInUseError()
|
||||
|
||||
ip_address = extract_remote_ip(request)
|
||||
account = self._create_new_account(
|
||||
email=normalized_email,
|
||||
password=req_data.password_confirm,
|
||||
timezone=req_data.timezone,
|
||||
language=req_data.language,
|
||||
ip_address=ip_address,
|
||||
)
|
||||
token_pair = AccountService.login(account=account, session=db.session(), ip_address=ip_address)
|
||||
token_pair = AccountService.login(account=account, session=db.session(), ip_address=extract_remote_ip(request))
|
||||
AccountService.reset_login_error_rate_limit(normalized_email)
|
||||
|
||||
return {"result": "success", "data": token_pair.model_dump()}
|
||||
@@ -203,7 +198,6 @@ class EmailRegisterResetApi(Resource):
|
||||
password: str,
|
||||
timezone: str | None = None,
|
||||
language: str | None = None,
|
||||
ip_address: str | None = None,
|
||||
) -> Account:
|
||||
try:
|
||||
return AccountService.create_account_and_tenant(
|
||||
@@ -212,7 +206,6 @@ class EmailRegisterResetApi(Resource):
|
||||
password=password,
|
||||
interface_language=get_valid_language(language),
|
||||
timezone=timezone,
|
||||
ip_address=ip_address,
|
||||
session=db.session(),
|
||||
)
|
||||
except SeatsLimitExceededError:
|
||||
|
||||
@@ -1,28 +1,10 @@
|
||||
from libs.exception import BaseHTTPException
|
||||
|
||||
|
||||
class DataSourceApiKeyAuthProviderNotSupportedError(BaseHTTPException):
|
||||
error_code = "unsupported_data_source_api_key_auth_provider"
|
||||
description = "The data-source API-key authentication provider is not supported."
|
||||
code = 400
|
||||
|
||||
|
||||
class InvalidDataSourceApiKeyAuthCredentialsRequestError(BaseHTTPException):
|
||||
error_code = "invalid_data_source_api_key_auth_credentials"
|
||||
description = "The data-source API-key authentication credentials are invalid."
|
||||
code = 400
|
||||
|
||||
|
||||
class DataSourceApiKeyAuthCredentialsRejectedRequestError(BaseHTTPException):
|
||||
error_code = "data_source_api_key_auth_credentials_rejected"
|
||||
description = "The data-source provider rejected the API-key authentication credentials."
|
||||
code = 400
|
||||
|
||||
|
||||
class DataSourceApiKeyAuthProviderUnavailableRequestError(BaseHTTPException):
|
||||
error_code = "data_source_api_key_auth_provider_unavailable"
|
||||
description = "The data-source API-key authentication provider is temporarily unavailable."
|
||||
code = 502
|
||||
class ApiKeyAuthFailedError(BaseHTTPException):
|
||||
error_code = "auth_failed"
|
||||
description = "{message}"
|
||||
code = 500
|
||||
|
||||
|
||||
class InvalidEmailError(BaseHTTPException):
|
||||
@@ -95,12 +77,6 @@ class EmailCodeError(BaseHTTPException):
|
||||
code = 400
|
||||
|
||||
|
||||
class EmailCodeLoginServiceUnavailableError(BaseHTTPException):
|
||||
error_code = "email_code_login_service_unavailable"
|
||||
description = "Email code verification is temporarily unavailable. Please try again later."
|
||||
code = 503
|
||||
|
||||
|
||||
class EmailOrPasswordMismatchError(BaseHTTPException):
|
||||
error_code = "email_or_password_mismatch"
|
||||
description = "The email or password is mismatched."
|
||||
@@ -119,18 +95,6 @@ class EmailPasswordLoginLimitError(BaseHTTPException):
|
||||
code = 429
|
||||
|
||||
|
||||
class TurnstileVerificationFailedError(BaseHTTPException):
|
||||
error_code = "turnstile_verification_failed"
|
||||
description = "Turnstile verification failed. Please try again."
|
||||
code = 400
|
||||
|
||||
|
||||
class TurnstileServiceUnavailableError(BaseHTTPException):
|
||||
error_code = "turnstile_service_unavailable"
|
||||
description = "Turnstile verification is temporarily unavailable. Please try again later."
|
||||
code = 503
|
||||
|
||||
|
||||
class EmailCodeLoginRateLimitExceededError(BaseHTTPException):
|
||||
error_code = "email_code_login_rate_limit_exceeded"
|
||||
description = "Too many login emails have been sent. Please try again in {minutes} minutes."
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import logging
|
||||
from uuid import UUID
|
||||
|
||||
import flask_login
|
||||
from flask import make_response, request
|
||||
@@ -23,12 +22,9 @@ from controllers.console import console_ns
|
||||
from controllers.console.auth.error import (
|
||||
AuthenticationFailedError,
|
||||
EmailCodeError,
|
||||
EmailCodeLoginServiceUnavailableError,
|
||||
EmailPasswordLoginLimitError,
|
||||
InvalidEmailError,
|
||||
InvalidTokenError,
|
||||
TurnstileServiceUnavailableError,
|
||||
TurnstileVerificationFailedError,
|
||||
)
|
||||
from controllers.console.error import (
|
||||
AccountBannedError,
|
||||
@@ -47,7 +43,6 @@ from controllers.console.wraps import (
|
||||
setup_required,
|
||||
with_current_user,
|
||||
)
|
||||
from enums import DeploymentEdition
|
||||
from extensions.ext_database import db
|
||||
from libs.helper import EmailStr, extract_remote_ip
|
||||
from libs.helper import timezone as validate_timezone_string
|
||||
@@ -63,10 +58,6 @@ from libs.token import (
|
||||
from models.account import Account
|
||||
from services.account_service import AccountService, InvitationDetailDict, RegisterService, TenantService
|
||||
from services.billing_service import BillingService
|
||||
from services.email_code_login_challenge import (
|
||||
EmailCodeLoginChallengeStatus,
|
||||
EmailCodeLoginChallengeUnavailableError,
|
||||
)
|
||||
from services.entities.auth_entities import LoginFailureReason, LoginPayloadBase
|
||||
from services.errors.account import (
|
||||
AccountRegisterError,
|
||||
@@ -76,12 +67,6 @@ from services.errors.account import (
|
||||
)
|
||||
from services.errors.workspace import WorkSpaceNotAllowedCreateError, WorkspacesLimitExceededError
|
||||
from services.feature_service import FeatureService
|
||||
from services.turnstile_service import (
|
||||
EMAIL_CODE_VERIFY_ACTION,
|
||||
TurnstileChallengeRejectedError,
|
||||
TurnstileService,
|
||||
TurnstileUpstreamError,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -96,23 +81,10 @@ class EmailPayload(BaseModel):
|
||||
language: str | None = Field(default=None)
|
||||
|
||||
|
||||
class EmailCodeSendPayload(EmailPayload):
|
||||
turnstile_token: str | None = Field(
|
||||
default=None,
|
||||
max_length=2048,
|
||||
description="Cloudflare Turnstile token. Required at runtime for Dify Cloud.",
|
||||
)
|
||||
|
||||
|
||||
class EmailCodeLoginPayload(BaseModel):
|
||||
email: EmailStr = Field(...)
|
||||
code: str
|
||||
token: UUID
|
||||
turnstile_token: str | None = Field(
|
||||
default=None,
|
||||
max_length=2048,
|
||||
description="Cloudflare Turnstile token for email-code verification.",
|
||||
)
|
||||
code: str = Field(...)
|
||||
token: str = Field(...)
|
||||
language: str | None = Field(default=None)
|
||||
timezone: str | None = Field(default=None)
|
||||
|
||||
@@ -124,7 +96,7 @@ class EmailCodeLoginPayload(BaseModel):
|
||||
return validate_timezone_string(value)
|
||||
|
||||
|
||||
register_schema_models(console_ns, LoginPayload, EmailPayload, EmailCodeSendPayload, EmailCodeLoginPayload)
|
||||
register_schema_models(console_ns, LoginPayload, EmailPayload, EmailCodeLoginPayload)
|
||||
register_response_schema_models(
|
||||
console_ns,
|
||||
SimpleResultDataResponse,
|
||||
@@ -149,9 +121,7 @@ class LoginApi(Resource):
|
||||
request_email = req_data.email
|
||||
normalized_email = request_email.lower()
|
||||
|
||||
if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD and BillingService.is_email_in_freeze(
|
||||
normalized_email
|
||||
):
|
||||
if dify_config.BILLING_ENABLED and BillingService.is_email_in_freeze(normalized_email):
|
||||
_log_console_login_failure(email=normalized_email, reason=LoginFailureReason.ACCOUNT_IN_FREEZE)
|
||||
raise AccountInFreezeError()
|
||||
|
||||
@@ -190,6 +160,7 @@ class LoginApi(Resource):
|
||||
AccountService.add_login_error_rate_limit(normalized_email)
|
||||
_log_console_login_failure(email=normalized_email, reason=LoginFailureReason.INVALID_CREDENTIALS)
|
||||
raise AuthenticationFailedError() from exc
|
||||
# SELF_HOSTED only have one workspace
|
||||
tenants = TenantService.get_join_tenants(account, session=db.session())
|
||||
if len(tenants) == 0:
|
||||
if (
|
||||
@@ -271,26 +242,16 @@ class ResetPasswordSendEmailApi(Resource):
|
||||
@console_ns.route("/email-code-login")
|
||||
class EmailCodeLoginSendEmailApi(Resource):
|
||||
@setup_required
|
||||
@console_ns.expect(console_ns.models[EmailCodeSendPayload.__name__])
|
||||
@console_ns.expect(console_ns.models[EmailPayload.__name__])
|
||||
@console_ns.response(200, "Success", console_ns.models[SimpleResultDataResponse.__name__])
|
||||
@model_validate(EmailCodeSendPayload)
|
||||
def post(self, req_data: EmailCodeSendPayload):
|
||||
@model_validate(EmailPayload)
|
||||
def post(self, req_data: EmailPayload):
|
||||
normalized_email = req_data.email.lower()
|
||||
|
||||
ip_address = extract_remote_ip(request)
|
||||
if AccountService.is_email_send_ip_limit(ip_address):
|
||||
raise EmailSendIpLimitError()
|
||||
|
||||
if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD:
|
||||
try:
|
||||
TurnstileService.verify(token=req_data.turnstile_token, remote_ip=ip_address)
|
||||
except TurnstileChallengeRejectedError as exc:
|
||||
logger.info("Turnstile rejected an email-code login challenge")
|
||||
raise TurnstileVerificationFailedError() from exc
|
||||
except TurnstileUpstreamError as exc:
|
||||
logger.warning("Turnstile verification is unavailable", exc_info=True)
|
||||
raise TurnstileServiceUnavailableError() from exc
|
||||
|
||||
if req_data.language is not None and req_data.language == "zh-Hans":
|
||||
language = "zh-Hans"
|
||||
else:
|
||||
@@ -323,55 +284,23 @@ class EmailCodeLoginApi(Resource):
|
||||
original_email = req_data.email
|
||||
user_email = original_email.lower()
|
||||
language = req_data.language
|
||||
ip_address = extract_remote_ip(request)
|
||||
|
||||
# ``code`` is Base64 on the wire and is decoded by
|
||||
# ``decrypt_code_field`` before model validation reaches this handler.
|
||||
if len(req_data.code) != 6 or not req_data.code.isascii() or not req_data.code.isdigit():
|
||||
_log_console_login_failure(email=user_email, reason=LoginFailureReason.INVALID_EMAIL_CODE)
|
||||
raise EmailCodeError()
|
||||
|
||||
if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD and (
|
||||
dify_config.TURNSTILE_EMAIL_CODE_VERIFY_REQUIRED or req_data.turnstile_token
|
||||
):
|
||||
try:
|
||||
TurnstileService.verify(
|
||||
token=req_data.turnstile_token,
|
||||
remote_ip=ip_address,
|
||||
expected_action=EMAIL_CODE_VERIFY_ACTION,
|
||||
)
|
||||
except TurnstileChallengeRejectedError as exc:
|
||||
logger.info("Turnstile rejected an email-code verification challenge")
|
||||
raise TurnstileVerificationFailedError() from exc
|
||||
except TurnstileUpstreamError as exc:
|
||||
logger.warning("Turnstile verification is unavailable", exc_info=True)
|
||||
raise TurnstileServiceUnavailableError() from exc
|
||||
|
||||
try:
|
||||
verification = AccountService.verify_email_code_login_challenge(
|
||||
email=user_email,
|
||||
code=req_data.code,
|
||||
token=str(req_data.token),
|
||||
)
|
||||
except EmailCodeLoginChallengeUnavailableError as exc:
|
||||
logger.warning("Email-code challenge verification is unavailable", exc_info=True)
|
||||
raise EmailCodeLoginServiceUnavailableError() from exc
|
||||
|
||||
if verification.status == EmailCodeLoginChallengeStatus.INVALID_TOKEN:
|
||||
token_data = AccountService.get_email_code_login_data(req_data.token)
|
||||
if token_data is None:
|
||||
_log_console_login_failure(email=user_email, reason=LoginFailureReason.INVALID_EMAIL_CODE_TOKEN)
|
||||
raise InvalidTokenError()
|
||||
|
||||
if verification.status == EmailCodeLoginChallengeStatus.EMAIL_MISMATCH:
|
||||
token_email = token_data.get("email")
|
||||
normalized_token_email = token_email.lower() if isinstance(token_email, str) else token_email
|
||||
if normalized_token_email != user_email:
|
||||
_log_console_login_failure(email=user_email, reason=LoginFailureReason.EMAIL_CODE_EMAIL_MISMATCH)
|
||||
raise InvalidEmailError()
|
||||
|
||||
if verification.status in {
|
||||
EmailCodeLoginChallengeStatus.INVALID_CODE,
|
||||
EmailCodeLoginChallengeStatus.EXHAUSTED,
|
||||
}:
|
||||
if token_data["code"] != req_data.code:
|
||||
_log_console_login_failure(email=user_email, reason=LoginFailureReason.INVALID_EMAIL_CODE)
|
||||
raise EmailCodeError()
|
||||
|
||||
AccountService.revoke_email_code_login_token(req_data.token)
|
||||
try:
|
||||
account = _get_account_with_case_fallback(original_email)
|
||||
except Unauthorized as exc:
|
||||
@@ -398,7 +327,6 @@ class EmailCodeLoginApi(Resource):
|
||||
name=user_email,
|
||||
interface_language=get_valid_language(language),
|
||||
timezone=req_data.timezone,
|
||||
ip_address=ip_address,
|
||||
session=db.session(),
|
||||
)
|
||||
except WorkSpaceNotAllowedCreateError:
|
||||
@@ -410,7 +338,7 @@ class EmailCodeLoginApi(Resource):
|
||||
raise AccountInFreezeError()
|
||||
except WorkspacesLimitExceededError:
|
||||
raise WorkspacesLimitExceeded()
|
||||
token_pair = AccountService.login(account, session=db.session(), ip_address=ip_address)
|
||||
token_pair = AccountService.login(account, session=db.session(), ip_address=extract_remote_ip(request))
|
||||
AccountService.reset_login_error_rate_limit(user_email)
|
||||
|
||||
# Create response with cookies instead of returning tokens in body
|
||||
|
||||
@@ -12,7 +12,6 @@ from configs import dify_config
|
||||
from constants.languages import languages
|
||||
from controllers.common.fields import RedirectResponse
|
||||
from controllers.common.schema import query_params_from_model, register_response_schema_model, register_schema_models
|
||||
from enums import DeploymentEdition
|
||||
from extensions.ext_database import db
|
||||
from libs.datetime_utils import naive_utc_now
|
||||
from libs.helper import extract_remote_ip
|
||||
@@ -233,13 +232,7 @@ class OAuthCallback(Resource):
|
||||
return _redirect_with_console_session(account, target_url)
|
||||
|
||||
try:
|
||||
account, oauth_new_user = _generate_account(
|
||||
provider,
|
||||
user_info,
|
||||
timezone=timezone,
|
||||
language=language,
|
||||
ip_address=extract_remote_ip(request),
|
||||
)
|
||||
account, oauth_new_user = _generate_account(provider, user_info, timezone=timezone, language=language)
|
||||
except AccountNotFoundError:
|
||||
return redirect(f"{dify_config.CONSOLE_WEB_URL}/signin?message=Account not found.")
|
||||
except (WorkSpaceNotFoundError, WorkSpaceNotAllowedCreateError):
|
||||
@@ -291,7 +284,6 @@ def _generate_account(
|
||||
user_info: OAuthUserInfo,
|
||||
timezone: str | None = None,
|
||||
language: str | None = None,
|
||||
ip_address: str | None = None,
|
||||
) -> tuple[Account, bool]:
|
||||
# Get account by openid or email.
|
||||
account = _get_account_by_openid_or_email(provider, user_info)
|
||||
@@ -309,9 +301,7 @@ def _generate_account(
|
||||
normalized_email = user_info.email.lower()
|
||||
oauth_new_user = True
|
||||
if not FeatureService.get_system_features().is_allow_register:
|
||||
if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD and BillingService.is_email_in_freeze(
|
||||
normalized_email
|
||||
):
|
||||
if dify_config.BILLING_ENABLED and BillingService.is_email_in_freeze(normalized_email):
|
||||
raise AccountRegisterError(
|
||||
description=(
|
||||
"This email account has been deleted within the past "
|
||||
@@ -329,7 +319,6 @@ def _generate_account(
|
||||
provider=provider,
|
||||
language=interface_language,
|
||||
timezone=timezone,
|
||||
ip_address=ip_address,
|
||||
session=db.session(),
|
||||
)
|
||||
|
||||
|
||||
@@ -42,7 +42,6 @@ class OAuthProviderAppResponse(BaseModel):
|
||||
app_icon: str
|
||||
app_label: dict[str, Any]
|
||||
scope: str
|
||||
auto_authorize: bool
|
||||
|
||||
|
||||
class OAuthProviderAuthorizeResponse(BaseModel):
|
||||
@@ -168,7 +167,6 @@ class OAuthServerAppApi(Resource):
|
||||
"app_icon": oauth_provider_app.app_icon,
|
||||
"app_label": oauth_provider_app.app_label,
|
||||
"scope": oauth_provider_app.scope,
|
||||
"auto_authorize": oauth_provider_app.auto_authorize,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ from controllers.console.wraps import (
|
||||
with_current_tenant_id,
|
||||
with_current_user,
|
||||
)
|
||||
from enums import CloudPlan
|
||||
from enums.cloud_plan import CloudPlan
|
||||
from extensions.ext_database import db
|
||||
from fields.base import ResponseModel
|
||||
from libs.login import login_required
|
||||
@@ -40,18 +40,14 @@ class BillingInvoiceResponse(ResponseModel):
|
||||
url: str
|
||||
|
||||
|
||||
class BillingSubscriptionResponse(ResponseModel):
|
||||
url: str
|
||||
|
||||
|
||||
register_schema_models(console_ns, SubscriptionQuery, PartnerTenantsPayload)
|
||||
register_response_schema_models(console_ns, BillingResponse, BillingInvoiceResponse, BillingSubscriptionResponse)
|
||||
register_response_schema_models(console_ns, BillingResponse, BillingInvoiceResponse)
|
||||
|
||||
|
||||
@console_ns.route("/billing/subscription")
|
||||
class Subscription(Resource):
|
||||
@console_ns.doc(params=query_params_from_model(SubscriptionQuery))
|
||||
@console_ns.response(200, "Success", console_ns.models[BillingSubscriptionResponse.__name__])
|
||||
@console_ns.response(200, "Success", console_ns.models[BillingResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@@ -96,8 +92,8 @@ class PartnerTenants(Resource):
|
||||
try:
|
||||
click_id = req_data.click_id
|
||||
decoded_partner_key = base64.b64decode(partner_key).decode("utf-8")
|
||||
except Exception as e:
|
||||
raise BadRequest("Invalid partner_key") from e
|
||||
except Exception:
|
||||
raise BadRequest("Invalid partner_key")
|
||||
|
||||
if not click_id or not decoded_partner_key or not current_user.id:
|
||||
raise BadRequest("Invalid partner information")
|
||||
|
||||
@@ -36,7 +36,6 @@ from ..wraps import (
|
||||
RBACPermission,
|
||||
RBACResourceScope,
|
||||
account_initialization_required,
|
||||
is_admin_or_owner_required,
|
||||
model_validate,
|
||||
rbac_permission_required,
|
||||
setup_required,
|
||||
@@ -188,8 +187,6 @@ class DataSourceApi(Resource):
|
||||
|
||||
@setup_required
|
||||
@login_required
|
||||
@is_admin_or_owner_required
|
||||
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.CREDENTIAL_MANAGE, resource_required=False)
|
||||
@account_initialization_required
|
||||
@console_ns.response(200, "Success", console_ns.models[SimpleResultResponse.__name__])
|
||||
@with_current_tenant_id
|
||||
|
||||
@@ -53,7 +53,6 @@ from models.enums import ApiTokenType, SegmentStatus
|
||||
from models.provider_ids import ModelProviderID
|
||||
from services.api_token_service import ApiTokenCache
|
||||
from services.app_service import AppService
|
||||
from services.dataset_ref_service import DatasetRefService
|
||||
from services.dataset_service import DatasetPermissionService, DatasetService, DocumentService
|
||||
from services.enterprise import rbac_service as enterprise_rbac_service
|
||||
from services.enterprise.rbac_service import RBACResourceWhitelistScope, ReplaceMemberBindings
|
||||
@@ -68,18 +67,6 @@ def _has_dataset_list_permission(permission_keys: list[str]) -> bool:
|
||||
return any(permission_key in DATASET_LIST_PERMISSION_KEYS for permission_key in permission_keys)
|
||||
|
||||
|
||||
def _get_accessible_dataset(dataset_id: UUID, tenant_id: str, current_user: Account, session: Session) -> Dataset:
|
||||
dataset = DatasetService.get_dataset_for_tenant(str(dataset_id), tenant_id, session=session)
|
||||
if dataset is None:
|
||||
raise NotFound("Dataset not found.")
|
||||
if not dify_config.RBAC_ENABLED:
|
||||
try:
|
||||
DatasetService.check_dataset_permission(dataset, current_user, session)
|
||||
except services.errors.account.NoPermissionError as e:
|
||||
raise Forbidden(str(e))
|
||||
return dataset
|
||||
|
||||
|
||||
def _validate_indexing_technique(value: str | None) -> str | None:
|
||||
if value is None:
|
||||
return value
|
||||
@@ -824,13 +811,12 @@ class DatasetUseCheckApi(Resource):
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
@rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_READONLY)
|
||||
@with_session(write=False)
|
||||
def get(self, session: Session, current_tenant_id: str, current_user: Account, dataset_id: UUID):
|
||||
dataset = _get_accessible_dataset(dataset_id, current_tenant_id, current_user, session)
|
||||
dataset_is_using = DatasetService.dataset_use_check(DatasetRefService.create_dataset_ref(dataset), session)
|
||||
def get(self, session: Session, dataset_id: UUID):
|
||||
dataset_id_str = str(dataset_id)
|
||||
|
||||
dataset_is_using = DatasetService.dataset_use_check(dataset_id_str, session)
|
||||
return UsageCheckResponse(is_using=dataset_is_using).model_dump(mode="json"), 200
|
||||
|
||||
|
||||
@@ -1041,15 +1027,13 @@ class DatasetIndexingStatusApi(Resource):
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
@rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_READONLY)
|
||||
@with_session(write=False)
|
||||
def get(self, session: Session, current_tenant_id: str, current_user: Account, dataset_id: UUID):
|
||||
dataset = _get_accessible_dataset(dataset_id, current_tenant_id, current_user, session)
|
||||
|
||||
def get(self, session: Session, current_tenant_id: str, dataset_id: UUID):
|
||||
dataset_id_str = str(dataset_id)
|
||||
documents = session.scalars(
|
||||
select(Document).where(Document.dataset_id == dataset.id, Document.tenant_id == dataset.tenant_id)
|
||||
select(Document).where(Document.dataset_id == dataset_id_str, Document.tenant_id == current_tenant_id)
|
||||
).all()
|
||||
documents_status = []
|
||||
for document in documents:
|
||||
@@ -1057,8 +1041,6 @@ class DatasetIndexingStatusApi(Resource):
|
||||
session.scalar(
|
||||
select(func.count(DocumentSegment.id)).where(
|
||||
DocumentSegment.completed_at.isnot(None),
|
||||
DocumentSegment.tenant_id == dataset.tenant_id,
|
||||
DocumentSegment.dataset_id == dataset.id,
|
||||
DocumentSegment.document_id == str(document.id),
|
||||
DocumentSegment.status != SegmentStatus.RE_SEGMENT,
|
||||
)
|
||||
@@ -1068,8 +1050,6 @@ class DatasetIndexingStatusApi(Resource):
|
||||
total_segments = (
|
||||
session.scalar(
|
||||
select(func.count(DocumentSegment.id)).where(
|
||||
DocumentSegment.tenant_id == dataset.tenant_id,
|
||||
DocumentSegment.dataset_id == dataset.id,
|
||||
DocumentSegment.document_id == str(document.id),
|
||||
DocumentSegment.status != SegmentStatus.RE_SEGMENT,
|
||||
)
|
||||
@@ -1106,8 +1086,6 @@ class DatasetApiKeyApi(Resource):
|
||||
@console_ns.response(200, "API keys retrieved successfully", console_ns.models[ApiKeyList.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@is_admin_or_owner_required
|
||||
@rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_API_KEY_MANAGE, resource_required=False)
|
||||
@account_initialization_required
|
||||
@with_current_tenant_id
|
||||
@with_session(write=False)
|
||||
@@ -1199,16 +1177,12 @@ class DatasetEnableApiApi(Resource):
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@console_ns.response(200, "Success", console_ns.models[SimpleResultResponse.__name__])
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
@rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_EDIT)
|
||||
@with_session
|
||||
def post(self, session: Session, current_tenant_id: str, current_user: Account, dataset_id: UUID, status: str):
|
||||
dataset = _get_accessible_dataset(dataset_id, current_tenant_id, current_user, session)
|
||||
if not current_user.is_dataset_editor:
|
||||
raise Forbidden()
|
||||
def post(self, session: Session, dataset_id: UUID, status: str):
|
||||
dataset_id_str = str(dataset_id)
|
||||
|
||||
DatasetService.update_dataset_api_status(dataset, status == "enable", current_user, session)
|
||||
DatasetService.update_dataset_api_status(dataset_id_str, status == "enable", session)
|
||||
|
||||
return SimpleResultResponse(result="success").model_dump(mode="json"), 200
|
||||
|
||||
@@ -1274,15 +1248,14 @@ class DatasetErrorDocs(Resource):
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
@rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_READONLY)
|
||||
@with_session(write=False)
|
||||
def get(self, session: Session, current_tenant_id: str, current_user: Account, dataset_id: UUID):
|
||||
dataset = _get_accessible_dataset(dataset_id, current_tenant_id, current_user, session)
|
||||
results = DocumentService.get_error_documents_by_dataset_ref(
|
||||
DatasetRefService.create_dataset_ref(dataset), session
|
||||
)
|
||||
def get(self, session: Session, dataset_id: UUID):
|
||||
dataset_id_str = str(dataset_id)
|
||||
dataset = DatasetService.get_dataset(dataset_id_str, session)
|
||||
if dataset is None:
|
||||
raise NotFound("Dataset not found.")
|
||||
results = DocumentService.get_error_documents_by_dataset_id(dataset_id_str, session)
|
||||
|
||||
return dump_response(ErrorDocsResponse, {"data": results, "total": len(results)}), 200
|
||||
|
||||
@@ -1334,13 +1307,12 @@ class DatasetAutoDisableLogApi(Resource):
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
@rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_READONLY)
|
||||
@with_session(write=False)
|
||||
def get(self, session: Session, current_tenant_id: str, current_user: Account, dataset_id: UUID):
|
||||
dataset = _get_accessible_dataset(dataset_id, current_tenant_id, current_user, session)
|
||||
auto_disable_logs = DatasetService.get_dataset_auto_disable_logs(
|
||||
DatasetRefService.create_dataset_ref(dataset), session
|
||||
)
|
||||
def get(self, session: Session, dataset_id: UUID):
|
||||
dataset_id_str = str(dataset_id)
|
||||
dataset = DatasetService.get_dataset(dataset_id_str, session)
|
||||
if dataset is None:
|
||||
raise NotFound("Dataset not found.")
|
||||
auto_disable_logs = DatasetService.get_dataset_auto_disable_logs(dataset_id_str, session)
|
||||
return dump_response(AutoDisableLogsResponse, auto_disable_logs), 200
|
||||
|
||||
@@ -16,7 +16,6 @@ from sqlalchemy.orm import Session
|
||||
from werkzeug.exceptions import Forbidden, NotFound
|
||||
|
||||
import services
|
||||
from configs import dify_config
|
||||
from controllers.common.controller_schemas import DocumentBatchDownloadZipPayload
|
||||
from controllers.common.fields import SimpleResultMessageResponse, SimpleResultResponse, UrlResponse
|
||||
from controllers.common.schema import register_response_schema_models, register_schema_models
|
||||
@@ -55,17 +54,14 @@ from libs.helper import dump_response, to_timestamp
|
||||
from libs.login import login_required
|
||||
from libs.pagination import paginate_query
|
||||
from models import Account, Document, DocumentSegment, UploadFile
|
||||
from models.dataset import DatasetPermissionEnum, DocumentPipelineExecutionLog
|
||||
from models.dataset import DocumentPipelineExecutionLog
|
||||
from models.enums import IndexingStatus, ProcessRuleMode, SegmentStatus
|
||||
from services.dataset_ref_service import DatasetRefService
|
||||
from services.dataset_service import DatasetService, DocumentService
|
||||
from services.enterprise import rbac_service as enterprise_rbac_service
|
||||
from services.enterprise.rbac_service import RBACResourceWhitelistScope, ReplaceMemberBindings
|
||||
from services.entities.knowledge_entities.knowledge_entities import KnowledgeConfig, ProcessRule, RetrievalModel
|
||||
from services.file_service import FileService
|
||||
from services.vector_space_admission_service import get_vector_space_admission_error_fields
|
||||
from tasks.generate_summary_index_task import generate_summary_index_task
|
||||
from tasks.initialize_created_app_rbac_access_task import initialize_created_app_rbac_access_task
|
||||
|
||||
from ..app.error import (
|
||||
ProviderModelCurrentlyNotSupportError,
|
||||
@@ -82,7 +78,6 @@ from ..datasets.error import (
|
||||
)
|
||||
from ..wraps import (
|
||||
account_initialization_required,
|
||||
check_knowledge_rate_limit,
|
||||
cloud_edition_billing_rate_limit_check,
|
||||
cloud_edition_billing_resource_check,
|
||||
setup_required,
|
||||
@@ -299,23 +294,23 @@ class DocumentResource(Resource):
|
||||
def get_document(
|
||||
self, session: Session, dataset_id: str, document_id: str, current_user: Account, current_tenant_id: str
|
||||
) -> Document:
|
||||
dataset = DatasetService.get_dataset_for_tenant(dataset_id, current_tenant_id, session=session)
|
||||
dataset = DatasetService.get_dataset(dataset_id, session)
|
||||
if not dataset:
|
||||
raise NotFound("Dataset not found.")
|
||||
|
||||
if not dify_config.RBAC_ENABLED:
|
||||
try:
|
||||
DatasetService.check_dataset_permission(dataset, current_user, session)
|
||||
except services.errors.account.NoPermissionError as e:
|
||||
raise Forbidden(str(e))
|
||||
try:
|
||||
DatasetService.check_dataset_permission(dataset, current_user, session)
|
||||
except services.errors.account.NoPermissionError as e:
|
||||
raise Forbidden(str(e))
|
||||
|
||||
dataset_ref = DatasetRefService.create_dataset_ref(dataset)
|
||||
document_ref = DatasetRefService.create_document_ref_from_id(dataset_ref, document_id)
|
||||
document = DatasetRefService.get_document_by_ref(document_ref, session=session)
|
||||
document = DocumentService.get_document(dataset_id, document_id, session=session)
|
||||
|
||||
if not document:
|
||||
raise NotFound("Document not found.")
|
||||
|
||||
if document.tenant_id != current_tenant_id:
|
||||
raise Forbidden("No permission.")
|
||||
|
||||
return document
|
||||
|
||||
def get_batch_documents(
|
||||
@@ -583,33 +578,18 @@ class DatasetDocumentListApi(Resource):
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@cloud_edition_billing_rate_limit_check("knowledge")
|
||||
@console_ns.response(204, "Documents deleted successfully")
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
@rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_EDIT)
|
||||
@with_session
|
||||
def delete(
|
||||
self,
|
||||
session: Session,
|
||||
current_tenant_id: str,
|
||||
current_user: Account,
|
||||
dataset_id: UUID,
|
||||
):
|
||||
def delete(self, session: Session, dataset_id: UUID):
|
||||
dataset_id_str = str(dataset_id)
|
||||
dataset = DatasetService.get_dataset_for_tenant(dataset_id_str, current_tenant_id, session=session)
|
||||
dataset = DatasetService.get_dataset(dataset_id_str, session)
|
||||
if dataset is None:
|
||||
raise NotFound("Dataset not found.")
|
||||
# check user's model setting
|
||||
DatasetService.check_dataset_model_setting(dataset)
|
||||
|
||||
if not current_user.is_dataset_editor:
|
||||
raise Forbidden()
|
||||
|
||||
if not dify_config.RBAC_ENABLED:
|
||||
try:
|
||||
DatasetService.check_dataset_permission(dataset, current_user, session)
|
||||
except services.errors.account.NoPermissionError as e:
|
||||
raise Forbidden(str(e))
|
||||
|
||||
check_knowledge_rate_limit()
|
||||
try:
|
||||
document_ids = request.args.getlist("document_id")
|
||||
dataset_ref = DatasetRefService.create_dataset_ref(dataset)
|
||||
@@ -682,21 +662,6 @@ class DatasetInitApi(Resource):
|
||||
except ModelCurrentlyNotSupportError:
|
||||
raise ProviderModelCurrentlyNotSupportError()
|
||||
|
||||
if dify_config.RBAC_ENABLED:
|
||||
dataset.permission = DatasetPermissionEnum.ALL_TEAM
|
||||
else:
|
||||
dataset.permission = DatasetPermissionEnum.ONLY_ME
|
||||
session.flush()
|
||||
|
||||
if dify_config.RBAC_ENABLED:
|
||||
enterprise_rbac_service.RBACService.DatasetAccess.replace_whitelist(
|
||||
current_tenant_id,
|
||||
current_user.id,
|
||||
dataset.id,
|
||||
ReplaceMemberBindings(scope=RBACResourceWhitelistScope.ALL),
|
||||
)
|
||||
initialize_created_app_rbac_access_task.delay(current_tenant_id, current_user.id, dataset_id=dataset.id)
|
||||
|
||||
return dump_response(
|
||||
DatasetAndDocumentResponse,
|
||||
{"dataset": dataset, "documents": document_responses(documents, session=session), "batch": batch},
|
||||
@@ -1402,32 +1367,29 @@ class DocumentPauseApi(DocumentResource):
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@cloud_edition_billing_rate_limit_check("knowledge")
|
||||
@console_ns.response(204, "Document paused successfully")
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
@rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_EDIT)
|
||||
@with_session
|
||||
def patch(
|
||||
self,
|
||||
session: Session,
|
||||
current_tenant_id: str,
|
||||
current_user: Account,
|
||||
dataset_id: UUID,
|
||||
document_id: UUID,
|
||||
):
|
||||
def patch(self, session: Session, dataset_id: UUID, document_id: UUID):
|
||||
"""pause document."""
|
||||
dataset_id_str = str(dataset_id)
|
||||
document_id_str = str(document_id)
|
||||
|
||||
document = self.get_document(session, dataset_id_str, document_id_str, current_user, current_tenant_id)
|
||||
if not current_user.is_dataset_editor:
|
||||
raise Forbidden()
|
||||
dataset = DatasetService.get_dataset(dataset_id_str, session)
|
||||
if not dataset:
|
||||
raise NotFound("Dataset not found.")
|
||||
|
||||
document = DocumentService.get_document(dataset.id, document_id_str, session=session)
|
||||
|
||||
# 404 if document not found
|
||||
if document is None:
|
||||
raise NotFound("Document Not Exists.")
|
||||
|
||||
# 403 if document is archived
|
||||
if DocumentService.check_archived(document):
|
||||
raise ArchivedDocumentImmutableError()
|
||||
|
||||
check_knowledge_rate_limit()
|
||||
try:
|
||||
# pause document
|
||||
DocumentService.pause_document(document, session)
|
||||
@@ -1442,31 +1404,26 @@ class DocumentRecoverApi(DocumentResource):
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@cloud_edition_billing_rate_limit_check("knowledge")
|
||||
@console_ns.response(204, "Document resumed successfully")
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
@rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_EDIT)
|
||||
@with_session
|
||||
def patch(
|
||||
self,
|
||||
session: Session,
|
||||
current_tenant_id: str,
|
||||
current_user: Account,
|
||||
dataset_id: UUID,
|
||||
document_id: UUID,
|
||||
):
|
||||
def patch(self, session: Session, dataset_id: UUID, document_id: UUID):
|
||||
"""recover document."""
|
||||
dataset_id_str = str(dataset_id)
|
||||
document_id_str = str(document_id)
|
||||
dataset = DatasetService.get_dataset(dataset_id_str, session)
|
||||
if not dataset:
|
||||
raise NotFound("Dataset not found.")
|
||||
document = DocumentService.get_document(dataset.id, document_id_str, session=session)
|
||||
|
||||
document = self.get_document(session, dataset_id_str, document_id_str, current_user, current_tenant_id)
|
||||
if not current_user.is_dataset_editor:
|
||||
raise Forbidden()
|
||||
# 404 if document not found
|
||||
if document is None:
|
||||
raise NotFound("Document Not Exists.")
|
||||
|
||||
# 403 if document is archived
|
||||
if DocumentService.check_archived(document):
|
||||
raise ArchivedDocumentImmutableError()
|
||||
check_knowledge_rate_limit()
|
||||
try:
|
||||
# pause document
|
||||
DocumentService.recover_document(document, session)
|
||||
@@ -1481,42 +1438,22 @@ class DocumentRetryApi(DocumentResource):
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@cloud_edition_billing_rate_limit_check("knowledge")
|
||||
@console_ns.expect(console_ns.models[DocumentRetryPayload.__name__])
|
||||
@console_ns.response(204, "Documents retry started successfully")
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
@rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_EDIT)
|
||||
@with_session
|
||||
@model_validate(DocumentRetryPayload)
|
||||
def post(
|
||||
self,
|
||||
req_data: DocumentRetryPayload,
|
||||
session: Session,
|
||||
current_tenant_id: str,
|
||||
current_user: Account,
|
||||
dataset_id: UUID,
|
||||
):
|
||||
def post(self, session: Session, dataset_id: UUID):
|
||||
"""retry document."""
|
||||
payload = DocumentRetryPayload.model_validate(console_ns.payload or {})
|
||||
dataset_id_str = str(dataset_id)
|
||||
dataset = DatasetService.get_dataset_for_tenant(dataset_id_str, current_tenant_id, session=session)
|
||||
dataset = DatasetService.get_dataset(dataset_id_str, session)
|
||||
retry_documents = []
|
||||
if not dataset:
|
||||
raise NotFound("Dataset not found.")
|
||||
|
||||
if not current_user.is_dataset_editor:
|
||||
raise Forbidden()
|
||||
|
||||
if not dify_config.RBAC_ENABLED:
|
||||
try:
|
||||
DatasetService.check_dataset_permission(dataset, current_user, session)
|
||||
except services.errors.account.NoPermissionError as e:
|
||||
raise Forbidden(str(e))
|
||||
|
||||
documents = DocumentService.get_documents_by_ids(
|
||||
DatasetRefService.create_dataset_ref(dataset), req_data.document_ids, session
|
||||
)
|
||||
documents = DocumentService.get_documents_by_ids(dataset.id, payload.document_ids, session)
|
||||
documents_by_id = {document.id: document for document in documents}
|
||||
retry_documents = []
|
||||
for document_id in req_data.document_ids:
|
||||
for document_id in payload.document_ids:
|
||||
try:
|
||||
document = documents_by_id.get(document_id)
|
||||
|
||||
@@ -1536,7 +1473,6 @@ class DocumentRetryApi(DocumentResource):
|
||||
logger.exception("Failed to retry document, document id: %s", document_id)
|
||||
continue
|
||||
# retry document
|
||||
check_knowledge_rate_limit()
|
||||
DocumentService.retry_document(dataset_id_str, retry_documents, session)
|
||||
|
||||
return "", 204
|
||||
@@ -1552,15 +1488,7 @@ class DocumentRenameApi(DocumentResource):
|
||||
@with_current_user
|
||||
@rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_EDIT)
|
||||
@with_session
|
||||
@model_validate(DocumentRenamePayload)
|
||||
def post(
|
||||
self,
|
||||
req_data: DocumentRenamePayload,
|
||||
session: Session,
|
||||
current_user: Account,
|
||||
dataset_id: UUID,
|
||||
document_id: UUID,
|
||||
):
|
||||
def post(self, session: Session, current_user: Account, dataset_id: UUID, document_id: UUID):
|
||||
# The role of the current user in the ta table must be admin, owner, editor, or dataset_operator
|
||||
if not current_user.is_dataset_editor:
|
||||
raise Forbidden()
|
||||
@@ -1568,9 +1496,10 @@ class DocumentRenameApi(DocumentResource):
|
||||
if not dataset:
|
||||
raise NotFound("Dataset not found.")
|
||||
DatasetService.check_dataset_operator_permission(current_user, dataset, session=session)
|
||||
payload = DocumentRenamePayload.model_validate(console_ns.payload or {})
|
||||
|
||||
try:
|
||||
document = DocumentService.rename_document(str(dataset_id), str(document_id), req_data.name, session)
|
||||
document = DocumentService.rename_document(str(dataset_id), str(document_id), payload.name, session)
|
||||
except services.errors.document.DocumentIndexingError:
|
||||
raise DocumentIndexingError("Cannot delete document during indexing.")
|
||||
|
||||
@@ -1583,34 +1512,28 @@ class WebsiteDocumentSyncApi(DocumentResource):
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@console_ns.response(200, "Success", console_ns.models[SimpleResultResponse.__name__])
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
@rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_EDIT)
|
||||
@rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_CREATE_AND_MANAGEMENT)
|
||||
@with_session
|
||||
def get(
|
||||
self,
|
||||
session: Session,
|
||||
current_tenant_id: str,
|
||||
current_user: Account,
|
||||
dataset_id: UUID,
|
||||
document_id: UUID,
|
||||
):
|
||||
def get(self, session: Session, current_tenant_id: str, dataset_id: UUID, document_id: UUID):
|
||||
"""sync website document."""
|
||||
dataset_id_str = str(dataset_id)
|
||||
dataset = DatasetService.get_dataset_for_tenant(dataset_id_str, current_tenant_id, session=session)
|
||||
dataset = DatasetService.get_dataset(dataset_id_str, session)
|
||||
if not dataset:
|
||||
raise NotFound("Dataset not found.")
|
||||
if not current_user.is_dataset_editor:
|
||||
raise Forbidden()
|
||||
document_id_str = str(document_id)
|
||||
document = self.get_document(session, dataset.id, document_id_str, current_user, current_tenant_id)
|
||||
document = DocumentService.get_document(dataset.id, document_id_str, session=session)
|
||||
if not document:
|
||||
raise NotFound("Document not found.")
|
||||
if document.tenant_id != current_tenant_id:
|
||||
raise Forbidden("No permission.")
|
||||
if document.data_source_type != "website_crawl":
|
||||
raise ValueError("Document is not a website document.")
|
||||
# 403 if document is archived
|
||||
if DocumentService.check_archived(document):
|
||||
raise ArchivedDocumentImmutableError()
|
||||
# sync document
|
||||
DocumentService.sync_website_document(dataset, document, session)
|
||||
DocumentService.sync_website_document(dataset_id_str, document, session)
|
||||
|
||||
return SimpleResultResponse(result="success").model_dump(mode="json"), 200
|
||||
|
||||
@@ -1625,25 +1548,21 @@ class DocumentPipelineExecutionLogApi(DocumentResource):
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
@rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_CREATE_AND_MANAGEMENT)
|
||||
@with_session(write=False)
|
||||
def get(
|
||||
self,
|
||||
session: Session,
|
||||
current_tenant_id: str,
|
||||
current_user: Account,
|
||||
dataset_id: UUID,
|
||||
document_id: UUID,
|
||||
):
|
||||
def get(self, session: Session, dataset_id: UUID, document_id: UUID):
|
||||
dataset_id_str = str(dataset_id)
|
||||
document_id_str = str(document_id)
|
||||
|
||||
document = self.get_document(session, dataset_id_str, document_id_str, current_user, current_tenant_id)
|
||||
dataset = DatasetService.get_dataset(dataset_id_str, session)
|
||||
if not dataset:
|
||||
raise NotFound("Dataset not found.")
|
||||
document = DocumentService.get_document(dataset.id, document_id_str, session=session)
|
||||
if not document:
|
||||
raise NotFound("Document not found.")
|
||||
log = session.scalar(
|
||||
select(DocumentPipelineExecutionLog)
|
||||
.where(DocumentPipelineExecutionLog.document_id == document.id)
|
||||
.where(DocumentPipelineExecutionLog.document_id == document_id_str)
|
||||
.order_by(DocumentPipelineExecutionLog.created_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
@@ -1681,8 +1600,7 @@ class DocumentGenerateSummaryApi(Resource):
|
||||
@with_current_user
|
||||
@rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_EDIT)
|
||||
@with_session
|
||||
@model_validate(GenerateSummaryPayload)
|
||||
def post(self, req_data: GenerateSummaryPayload, session: Session, current_user: Account, dataset_id: UUID):
|
||||
def post(self, session: Session, current_user: Account, dataset_id: UUID):
|
||||
"""
|
||||
Generate summary index for specified documents.
|
||||
|
||||
@@ -1706,7 +1624,9 @@ class DocumentGenerateSummaryApi(Resource):
|
||||
except services.errors.account.NoPermissionError as e:
|
||||
raise Forbidden(str(e))
|
||||
|
||||
document_list = req_data.document_list
|
||||
# Validate request payload
|
||||
payload = GenerateSummaryPayload.model_validate(console_ns.payload or {})
|
||||
document_list = payload.document_list
|
||||
|
||||
if not document_list:
|
||||
from werkzeug.exceptions import BadRequest
|
||||
@@ -1725,9 +1645,7 @@ class DocumentGenerateSummaryApi(Resource):
|
||||
raise ValueError("Summary index is not enabled for this dataset. Please enable it in the dataset settings.")
|
||||
|
||||
# Verify all documents exist and belong to the dataset
|
||||
documents = DocumentService.get_documents_by_ids(
|
||||
DatasetRefService.create_dataset_ref(dataset), document_list, session
|
||||
)
|
||||
documents = DocumentService.get_documents_by_ids(dataset_id_str, document_list, session)
|
||||
|
||||
if len(documents) != len(document_list):
|
||||
found_ids = {doc.id for doc in documents}
|
||||
|
||||
@@ -42,6 +42,7 @@ from services.enterprise import rbac_service as enterprise_rbac_service
|
||||
from services.entities.external_knowledge_entities.external_knowledge_entities import ExternalDatasetCreatePayload
|
||||
from services.external_knowledge_service import ExternalDatasetService
|
||||
from services.hit_testing_service import HitTestingService
|
||||
from services.knowledge_service import BedrockRetrievalSetting, ExternalDatasetTestService
|
||||
|
||||
|
||||
class ExternalKnowledgeApiPayload(BaseModel):
|
||||
@@ -55,6 +56,12 @@ class ExternalHitTestingPayload(BaseModel):
|
||||
metadata_filtering_conditions: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class BedrockRetrievalPayload(BaseModel):
|
||||
retrieval_setting: BedrockRetrievalSetting
|
||||
query: str
|
||||
knowledge_id: str
|
||||
|
||||
|
||||
class ExternalApiTemplateListQuery(BaseModel):
|
||||
page: int = Field(default=1, description="Page number")
|
||||
limit: int = Field(default=20, description="Number of items per page")
|
||||
@@ -130,11 +137,23 @@ class ExternalHitTestingResponse(ResponseModel):
|
||||
records: list[ExternalHitTestingRecordResponse]
|
||||
|
||||
|
||||
class BedrockRetrievalRecordResponse(ResponseModel):
|
||||
metadata: dict[str, Any] | None = None
|
||||
score: float
|
||||
title: str | None = None
|
||||
content: str | None = None
|
||||
|
||||
|
||||
class BedrockRetrievalResponse(ResponseModel):
|
||||
records: list[BedrockRetrievalRecordResponse]
|
||||
|
||||
|
||||
register_schema_models(
|
||||
console_ns,
|
||||
ExternalKnowledgeApiPayload,
|
||||
ExternalDatasetCreatePayload,
|
||||
ExternalHitTestingPayload,
|
||||
BedrockRetrievalPayload,
|
||||
ExternalApiTemplateListQuery,
|
||||
)
|
||||
register_response_schema_models(
|
||||
@@ -147,6 +166,8 @@ register_response_schema_models(
|
||||
ExternalHitTestingQueryResponse,
|
||||
ExternalHitTestingRecordResponse,
|
||||
ExternalHitTestingResponse,
|
||||
BedrockRetrievalRecordResponse,
|
||||
BedrockRetrievalResponse,
|
||||
)
|
||||
|
||||
|
||||
@@ -430,3 +451,20 @@ class ExternalKnowledgeHitTestingApi(Resource):
|
||||
return dump_response(ExternalHitTestingResponse, response)
|
||||
except Exception as e:
|
||||
raise InternalServerError(str(e))
|
||||
|
||||
|
||||
@console_ns.route("/test/retrieval")
|
||||
class BedrockRetrievalApi(Resource):
|
||||
# this api is only for internal testing
|
||||
@console_ns.doc("bedrock_retrieval_test")
|
||||
@console_ns.doc(description="Bedrock retrieval test (internal use only)")
|
||||
@console_ns.expect(console_ns.models[BedrockRetrievalPayload.__name__])
|
||||
@console_ns.response(200, "Bedrock retrieval test completed", console_ns.models[BedrockRetrievalResponse.__name__])
|
||||
@model_validate(BedrockRetrievalPayload)
|
||||
def post(self, req_data: BedrockRetrievalPayload):
|
||||
|
||||
# Call the knowledge retrieval service
|
||||
result = ExternalDatasetTestService.knowledge_retrieval(
|
||||
req_data.retrieval_setting, req_data.query, req_data.knowledge_id
|
||||
)
|
||||
return dump_response(BedrockRetrievalResponse, result), 200
|
||||
|
||||
@@ -3,10 +3,8 @@ from uuid import UUID
|
||||
|
||||
from flask_restx import Resource
|
||||
from sqlalchemy.orm import Session
|
||||
from werkzeug.exceptions import Forbidden, NotFound
|
||||
from werkzeug.exceptions import NotFound
|
||||
|
||||
import services
|
||||
from configs import dify_config
|
||||
from controllers.common.controller_schemas import MetadataUpdatePayload
|
||||
from controllers.common.schema import register_response_schema_models, register_schema_models
|
||||
from controllers.common.session import with_session
|
||||
@@ -37,7 +35,6 @@ from services.entities.knowledge_entities.knowledge_entities import (
|
||||
MetadataDetail,
|
||||
MetadataOperationData,
|
||||
)
|
||||
from services.errors.metadata import MetadataResourceNotFoundError
|
||||
from services.metadata_service import MetadataService
|
||||
|
||||
register_schema_models(
|
||||
@@ -90,20 +87,13 @@ class DatasetMetadataCreateApi(Resource):
|
||||
@console_ns.response(
|
||||
200, "Metadata retrieved successfully", console_ns.models[DatasetMetadataListResponse.__name__]
|
||||
)
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
@rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_CREATE_AND_MANAGEMENT)
|
||||
@with_session(write=False)
|
||||
def get(self, session: Session, current_tenant_id: str, current_user: Account, dataset_id: UUID):
|
||||
def get(self, session: Session, dataset_id: UUID):
|
||||
dataset_id_str = str(dataset_id)
|
||||
dataset = DatasetService.get_dataset_for_tenant(dataset_id_str, current_tenant_id, session=session)
|
||||
dataset = DatasetService.get_dataset(dataset_id_str, session)
|
||||
if dataset is None:
|
||||
raise NotFound("Dataset not found.")
|
||||
if not dify_config.RBAC_ENABLED:
|
||||
try:
|
||||
DatasetService.check_dataset_permission(dataset, current_user, session)
|
||||
except services.errors.account.NoPermissionError as e:
|
||||
raise Forbidden(str(e))
|
||||
metadata = MetadataService.get_dataset_metadatas(dataset, session)
|
||||
return dump_response(DatasetMetadataListResponse, metadata), 200
|
||||
|
||||
@@ -134,12 +124,14 @@ class DatasetMetadataApi(Resource):
|
||||
|
||||
dataset_id_str = str(dataset_id)
|
||||
metadata_id_str = str(metadata_id)
|
||||
dataset = DatasetService.get_dataset_for_tenant(dataset_id_str, current_tenant_id, session=session)
|
||||
dataset = DatasetService.get_dataset(dataset_id_str, session)
|
||||
if dataset is None:
|
||||
raise NotFound("Dataset not found.")
|
||||
DatasetService.check_dataset_permission(dataset, current_user, session)
|
||||
|
||||
metadata = MetadataService.update_metadata_name(dataset, metadata_id_str, name, current_user, session=session)
|
||||
metadata = MetadataService.update_metadata_name(
|
||||
dataset_id_str, metadata_id_str, name, current_user, current_tenant_id, session=session
|
||||
)
|
||||
return dump_response(DatasetMetadataResponse, metadata), 200
|
||||
|
||||
@setup_required
|
||||
@@ -148,25 +140,17 @@ class DatasetMetadataApi(Resource):
|
||||
@enterprise_license_required
|
||||
@console_ns.response(204, "Metadata deleted successfully")
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
@rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_EDIT)
|
||||
@with_session
|
||||
def delete(
|
||||
self,
|
||||
session: Session,
|
||||
current_tenant_id: str,
|
||||
current_user: Account,
|
||||
dataset_id: UUID,
|
||||
metadata_id: UUID,
|
||||
):
|
||||
def delete(self, session: Session, current_user: Account, dataset_id: UUID, metadata_id: UUID):
|
||||
dataset_id_str = str(dataset_id)
|
||||
metadata_id_str = str(metadata_id)
|
||||
dataset = DatasetService.get_dataset_for_tenant(dataset_id_str, current_tenant_id, session=session)
|
||||
dataset = DatasetService.get_dataset(dataset_id_str, session)
|
||||
if dataset is None:
|
||||
raise NotFound("Dataset not found.")
|
||||
DatasetService.check_dataset_permission(dataset, current_user, session)
|
||||
|
||||
MetadataService.delete_metadata(dataset, metadata_id_str, session)
|
||||
MetadataService.delete_metadata(dataset_id_str, metadata_id_str, session)
|
||||
# Frontend callers only await success and invalidate metadata caches; no response body is consumed.
|
||||
return "", 204
|
||||
|
||||
@@ -224,29 +208,18 @@ class DocumentMetadataEditApi(Resource):
|
||||
204,
|
||||
"Documents metadata updated successfully",
|
||||
)
|
||||
@console_ns.response(404, "Dataset, document, or metadata not found")
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
@rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_EDIT)
|
||||
@with_session
|
||||
@model_validate(MetadataOperationData)
|
||||
def post(
|
||||
self,
|
||||
req_data: MetadataOperationData,
|
||||
session: Session,
|
||||
current_tenant_id: str,
|
||||
current_user: Account,
|
||||
dataset_id: UUID,
|
||||
):
|
||||
dataset = DatasetService.get_dataset_for_tenant(str(dataset_id), current_tenant_id, session=session)
|
||||
def post(self, req_data: MetadataOperationData, session: Session, current_user: Account, dataset_id: UUID):
|
||||
dataset_id_str = str(dataset_id)
|
||||
dataset = DatasetService.get_dataset(dataset_id_str, session)
|
||||
if dataset is None:
|
||||
raise NotFound("Dataset not found.")
|
||||
DatasetService.check_dataset_permission(dataset, current_user, session)
|
||||
|
||||
try:
|
||||
MetadataService.update_documents_metadata(dataset, req_data, current_user, session=session)
|
||||
except MetadataResourceNotFoundError as exc:
|
||||
raise NotFound(str(exc)) from exc
|
||||
MetadataService.update_documents_metadata(dataset, req_data, current_user, session=session)
|
||||
|
||||
# Frontend callers only await success and invalidate caches; no response body is consumed.
|
||||
return "", 204
|
||||
|
||||
@@ -30,7 +30,6 @@ from graphon.model_runtime.errors.validate import CredentialsValidateFailedError
|
||||
from libs.helper import dump_response
|
||||
from libs.login import login_required
|
||||
from models import Account
|
||||
from models.enums import PermissionEnum
|
||||
from models.provider_ids import DatasourceProviderID
|
||||
from services.datasource_provider_service import DatasourceProviderService
|
||||
from services.plugin.oauth_service import OAuthProxyService
|
||||
@@ -75,13 +74,6 @@ class DatasourceUpdateNamePayload(BaseModel):
|
||||
|
||||
class DatasourceOAuthAuthorizationQuery(BaseModel):
|
||||
credential_id: str | None = Field(default=None, description="Credential ID to reauthorize")
|
||||
visibility: str | None = Field(
|
||||
default=None,
|
||||
description=(
|
||||
"Visibility for the credential to be created. Accepts 'only_me' or 'all_team_members'; "
|
||||
"any other value falls back to 'only_me'. Ignored on reauthorization (credential_id set)."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class DatasourceOAuthCallbackQuery(BaseModel):
|
||||
@@ -183,27 +175,12 @@ class DatasourcePluginOAuthAuthorizationUrl(Resource):
|
||||
if not oauth_config:
|
||||
raise ValueError(f"No OAuth Client Config for {provider_id}")
|
||||
|
||||
# Visibility is chosen by the user in the frontend before the redirect,
|
||||
# then read back in the callback below when the credential is created.
|
||||
# Only ONLY_ME / ALL_TEAM are accepted; anything else falls back to
|
||||
# ONLY_ME (OAuth tokens are personal by nature).
|
||||
# For reauthorization (credential_id set), visibility is ignored — we
|
||||
# keep whatever the credential was created with.
|
||||
raw_visibility = request.args.get("visibility")
|
||||
try:
|
||||
requested_visibility = PermissionEnum(raw_visibility) if raw_visibility else PermissionEnum.ONLY_ME
|
||||
except ValueError:
|
||||
requested_visibility = PermissionEnum.ONLY_ME
|
||||
if requested_visibility not in (PermissionEnum.ONLY_ME, PermissionEnum.ALL_TEAM):
|
||||
requested_visibility = PermissionEnum.ONLY_ME
|
||||
|
||||
context_id = OAuthProxyService.create_proxy_context(
|
||||
user_id=current_user.id,
|
||||
tenant_id=tenant_id,
|
||||
plugin_id=plugin_id,
|
||||
provider=provider_name,
|
||||
credential_id=credential_id,
|
||||
extra_data={"visibility": requested_visibility.value},
|
||||
)
|
||||
oauth_handler = OAuthHandler()
|
||||
redirect_uri = f"{dify_config.CONSOLE_API_URL}/console/api/oauth/plugin/{provider_id}/datasource/callback"
|
||||
@@ -276,17 +253,6 @@ class DatasourceOAuthCallback(Resource):
|
||||
credential_id=credential_id,
|
||||
)
|
||||
else:
|
||||
# Visibility was chosen by the user before the redirect and stashed
|
||||
# in the proxy context. Fall back to ONLY_ME for older cookies (or
|
||||
# anything that somehow wrote an unexpected value) — OAuth tokens
|
||||
# are personal by default.
|
||||
stored_visibility = context.get("visibility")
|
||||
try:
|
||||
visibility = PermissionEnum(stored_visibility) if stored_visibility else PermissionEnum.ONLY_ME
|
||||
except ValueError:
|
||||
visibility = PermissionEnum.ONLY_ME
|
||||
if visibility not in (PermissionEnum.ONLY_ME, PermissionEnum.ALL_TEAM):
|
||||
visibility = PermissionEnum.ONLY_ME
|
||||
datasource_provider_service.add_datasource_oauth_provider(
|
||||
tenant_id=tenant_id,
|
||||
provider_id=datasource_provider_id,
|
||||
@@ -294,8 +260,6 @@ class DatasourceOAuthCallback(Resource):
|
||||
name=oauth_response.metadata.get("name") or None,
|
||||
expire_at=oauth_response.expires_at,
|
||||
credentials=dict(oauth_response.credentials),
|
||||
user_id=user_id,
|
||||
visibility=visibility,
|
||||
)
|
||||
return redirect(f"{dify_config.CONSOLE_WEB_URL}/oauth-callback")
|
||||
|
||||
@@ -417,12 +381,11 @@ class DatasourceAuthListApi(Resource):
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
def get(self, current_tenant_id: str, user: Account):
|
||||
def get(self, current_tenant_id: str):
|
||||
datasource_provider_service = DatasourceProviderService()
|
||||
datasources = datasource_provider_service.get_all_datasource_credentials(
|
||||
tenant_id=current_tenant_id, session=db.session(), user=user
|
||||
tenant_id=current_tenant_id, session=db.session()
|
||||
)
|
||||
return dump_response(DatasourceProviderAuthListResponse, {"result": datasources}), 200
|
||||
|
||||
@@ -437,12 +400,11 @@ class DatasourceHardCodeAuthListApi(Resource):
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
def get(self, current_tenant_id: str, user: Account):
|
||||
def get(self, current_tenant_id: str):
|
||||
datasource_provider_service = DatasourceProviderService()
|
||||
datasources = datasource_provider_service.get_hard_code_datasource_credentials(
|
||||
tenant_id=current_tenant_id, session=db.session(), user=user
|
||||
tenant_id=current_tenant_id, session=db.session()
|
||||
)
|
||||
return dump_response(DatasourceProviderAuthListResponse, {"result": datasources}), 200
|
||||
|
||||
@@ -474,8 +436,6 @@ class DatasourceAuthOauthCustomClient(Resource):
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@edit_permission_required
|
||||
@rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.CREDENTIAL_MANAGE, resource_required=False)
|
||||
@console_ns.response(200, "Success", console_ns.models[SimpleResultResponse.__name__])
|
||||
@with_current_tenant_id
|
||||
def delete(self, current_tenant_id: str, provider_id: str):
|
||||
|
||||
@@ -3,10 +3,10 @@ from typing import Any
|
||||
|
||||
from flask_restx import Resource
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.orm import Session
|
||||
from werkzeug.exceptions import Forbidden, NotFound
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
from werkzeug.exceptions import NotFound
|
||||
|
||||
from configs import dify_config
|
||||
from controllers.common.fields import SimpleDataResponse
|
||||
from controllers.common.schema import (
|
||||
JsonResponseWithStatus,
|
||||
@@ -16,15 +16,11 @@ from controllers.common.schema import (
|
||||
)
|
||||
from controllers.console import console_ns
|
||||
from controllers.console.app.wraps import with_session
|
||||
from controllers.console.datasets.wraps import get_rag_pipeline
|
||||
from controllers.console.wraps import (
|
||||
RBACPermission,
|
||||
RBACResourceScope,
|
||||
account_initialization_required,
|
||||
enterprise_license_required,
|
||||
knowledge_pipeline_publish_enabled,
|
||||
model_validate,
|
||||
rbac_permission_required,
|
||||
setup_required,
|
||||
with_current_tenant_id,
|
||||
with_current_user,
|
||||
@@ -34,11 +30,8 @@ from fields.base import ResponseModel
|
||||
from libs.helper import dump_response
|
||||
from libs.login import login_required
|
||||
from models.account import Account
|
||||
from models.dataset import Pipeline
|
||||
from services.dataset_service import DatasetService
|
||||
from models.dataset import PipelineCustomizedTemplate
|
||||
from services.entities.knowledge_entities.rag_pipeline_entities import IconInfo, PipelineTemplateInfoEntity
|
||||
from services.errors.account import NoPermissionError
|
||||
from services.errors.rag_pipeline import RagPipelineResourceNotFoundError
|
||||
from services.rag_pipeline.rag_pipeline import RagPipelineService
|
||||
|
||||
logger: logging.Logger = logging.getLogger(__name__)
|
||||
@@ -132,24 +125,15 @@ class PipelineTemplateListApi(Resource):
|
||||
class PipelineTemplateDetailApi(Resource):
|
||||
@console_ns.doc(params=query_params_from_model(PipelineTemplateDetailQuery))
|
||||
@console_ns.response(200, "Pipeline template", console_ns.models[PipelineTemplateDetailResponse.__name__])
|
||||
@console_ns.response(404, "Pipeline template not found")
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@enterprise_license_required
|
||||
@with_current_tenant_id
|
||||
@with_session(write=False)
|
||||
@with_session
|
||||
@model_validate(PipelineTemplateDetailQuery)
|
||||
def get(
|
||||
self,
|
||||
req_data: PipelineTemplateDetailQuery,
|
||||
session: Session,
|
||||
current_tenant_id: str,
|
||||
template_id: str,
|
||||
) -> JsonResponseWithStatus:
|
||||
def get(self, req_data: PipelineTemplateDetailQuery, session: Session, template_id: str) -> JsonResponseWithStatus:
|
||||
pipeline_template = RagPipelineService.get_pipeline_template_detail(
|
||||
template_id,
|
||||
current_tenant_id,
|
||||
type=req_data.type,
|
||||
session=session,
|
||||
)
|
||||
@@ -197,57 +181,38 @@ class CustomizedPipelineTemplateApi(Resource):
|
||||
@account_initialization_required
|
||||
@enterprise_license_required
|
||||
@console_ns.response(200, "Success", console_ns.models[SimpleDataResponse.__name__])
|
||||
@console_ns.response(404, "Customized pipeline template not found")
|
||||
@with_current_tenant_id
|
||||
@with_session(write=False)
|
||||
def post(self, session: Session, current_tenant_id: str, template_id: str) -> JsonResponseWithStatus:
|
||||
try:
|
||||
yaml_content = RagPipelineService.get_customized_pipeline_template_yaml(
|
||||
template_id, current_tenant_id, session=session
|
||||
def post(self, template_id: str) -> JsonResponseWithStatus:
|
||||
with sessionmaker(db.engine, expire_on_commit=False).begin() as session:
|
||||
template = session.scalar(
|
||||
select(PipelineCustomizedTemplate).where(PipelineCustomizedTemplate.id == template_id).limit(1)
|
||||
)
|
||||
except RagPipelineResourceNotFoundError as exc:
|
||||
raise NotFound(str(exc)) from exc
|
||||
if not template:
|
||||
raise ValueError("Customized pipeline template not found.")
|
||||
|
||||
return dump_response(SimpleDataResponse, {"data": yaml_content}), 200
|
||||
return dump_response(SimpleDataResponse, {"data": template.yaml_content}), 200
|
||||
|
||||
|
||||
@console_ns.route("/rag/pipelines/<string:pipeline_id>/customized/publish")
|
||||
class PublishCustomizedPipelineTemplateApi(Resource):
|
||||
@console_ns.expect(console_ns.models[CustomizedPipelineTemplatePayload.__name__])
|
||||
@console_ns.response(204, "Pipeline template published")
|
||||
@console_ns.response(404, "Pipeline, workflow, or dataset not found")
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@enterprise_license_required
|
||||
@knowledge_pipeline_publish_enabled
|
||||
@with_current_user
|
||||
@get_rag_pipeline
|
||||
@rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_PIPELINE_RELEASE)
|
||||
@with_current_tenant_id
|
||||
@model_validate(CustomizedPipelineTemplatePayload)
|
||||
def post(
|
||||
self,
|
||||
req_data: CustomizedPipelineTemplatePayload,
|
||||
current_tenant_id: str,
|
||||
current_user: Account,
|
||||
pipeline: Pipeline,
|
||||
pipeline_id: str,
|
||||
) -> tuple[str, int]:
|
||||
session = db.session()
|
||||
dataset = pipeline.retrieve_dataset(session=session)
|
||||
if dataset is None:
|
||||
raise NotFound("Dataset not found")
|
||||
|
||||
if not dify_config.RBAC_ENABLED:
|
||||
if not current_user.is_dataset_editor:
|
||||
raise Forbidden()
|
||||
try:
|
||||
DatasetService.check_dataset_permission(dataset, current_user, session)
|
||||
except NoPermissionError as exc:
|
||||
raise Forbidden(str(exc)) from exc
|
||||
|
||||
try:
|
||||
RagPipelineService.publish_customized_pipeline_template(
|
||||
pipeline, dataset, req_data.model_dump(), current_user, session=session
|
||||
)
|
||||
except RagPipelineResourceNotFoundError as exc:
|
||||
raise NotFound(str(exc)) from exc
|
||||
rag_pipeline_service = RagPipelineService(db.session())
|
||||
rag_pipeline_service.publish_customized_pipeline_template(
|
||||
pipeline_id, req_data.model_dump(), current_user, current_tenant_id, session=db.session()
|
||||
)
|
||||
return "", 204
|
||||
|
||||
@@ -8,6 +8,7 @@ from flask import Response
|
||||
from flask_restx import Resource, marshal, marshal_with
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from werkzeug.exceptions import Forbidden
|
||||
|
||||
from controllers.common.errors import InvalidArgumentError, NotFoundError
|
||||
from controllers.common.schema import query_params_from_model, register_schema_models
|
||||
@@ -18,22 +19,12 @@ from controllers.console.app.error import (
|
||||
from controllers.console.app.workflow_draft_variable import (
|
||||
_WORKFLOW_DRAFT_VARIABLE_FIELDS, # type: ignore[private-usage]
|
||||
EnvironmentVariableListResponse,
|
||||
ensure_variable_access,
|
||||
workflow_draft_variable_list_model,
|
||||
workflow_draft_variable_list_without_value_model,
|
||||
workflow_draft_variable_model,
|
||||
)
|
||||
from controllers.console.datasets.wraps import get_rag_pipeline
|
||||
from controllers.console.wraps import (
|
||||
RBACPermission,
|
||||
RBACResourceScope,
|
||||
account_initialization_required,
|
||||
edit_permission_required,
|
||||
model_validate,
|
||||
rbac_permission_required,
|
||||
setup_required,
|
||||
with_current_user,
|
||||
)
|
||||
from controllers.console.wraps import account_initialization_required, model_validate, setup_required, with_current_user
|
||||
from core.app.file_access import DatabaseFileAccessController
|
||||
from core.workflow.llm_environment_variable import LLMEnvironmentVariable, environment_variable_value_type
|
||||
from core.workflow.variable_prefixes import CONVERSATION_VARIABLE_NODE_ID, SYSTEM_VARIABLE_NODE_ID
|
||||
@@ -71,21 +62,21 @@ def _api_prerequisite[T, **P, R](
|
||||
|
||||
It ensures the following conditions are satisfied:
|
||||
|
||||
- Dify has been properly set up.
|
||||
- Dify has been property setup.
|
||||
- The request user has logged in and initialized.
|
||||
- The requested RAG pipeline belongs to the current tenant.
|
||||
- The request user has legacy edit permission or Dataset edit RBAC access.
|
||||
- The requested app is a workflow or a chat flow.
|
||||
- The request user has the edit permission for the app.
|
||||
"""
|
||||
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@get_rag_pipeline
|
||||
@edit_permission_required
|
||||
@rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_EDIT)
|
||||
@with_current_user
|
||||
@wraps(f)
|
||||
def wrapper(self: T, current_user: Account, *args: P.args, **kwargs: P.kwargs) -> R | Response:
|
||||
if not current_user.has_edit_permission:
|
||||
raise Forbidden()
|
||||
return f(self, current_user, *args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
@@ -189,17 +180,16 @@ class RagPipelineVariableApi(Resource):
|
||||
@console_ns.response(200, "Variable retrieved successfully", workflow_draft_variable_model)
|
||||
@_api_prerequisite
|
||||
@marshal_with(workflow_draft_variable_model)
|
||||
def get(self, current_user: Account, pipeline: Pipeline, variable_id: UUID):
|
||||
def get(self, _current_user: Account, pipeline: Pipeline, variable_id: UUID):
|
||||
draft_var_srv = WorkflowDraftVariableService(
|
||||
session=db.session(),
|
||||
)
|
||||
variable_id_str = str(variable_id)
|
||||
variable = ensure_variable_access(
|
||||
variable=draft_var_srv.get_variable(variable_id=variable_id_str),
|
||||
app_id=pipeline.id,
|
||||
variable_id=variable_id_str,
|
||||
current_user_id=current_user.id,
|
||||
)
|
||||
variable = draft_var_srv.get_variable(variable_id=variable_id_str)
|
||||
if variable is None:
|
||||
raise NotFoundError(description=f"variable not found, id={variable_id_str}")
|
||||
if variable.app_id != pipeline.id:
|
||||
raise NotFoundError(description=f"variable not found, id={variable_id_str}")
|
||||
return variable
|
||||
|
||||
@console_ns.response(200, "Variable updated successfully", workflow_draft_variable_model)
|
||||
@@ -210,7 +200,7 @@ class RagPipelineVariableApi(Resource):
|
||||
def patch(
|
||||
self,
|
||||
req_data: WorkflowDraftVariablePatchPayload,
|
||||
current_user: Account,
|
||||
_current_user: Account,
|
||||
pipeline: Pipeline,
|
||||
variable_id: UUID,
|
||||
):
|
||||
@@ -241,12 +231,11 @@ class RagPipelineVariableApi(Resource):
|
||||
args = req_data.model_dump(exclude_none=True)
|
||||
|
||||
variable_id_str = str(variable_id)
|
||||
variable = ensure_variable_access(
|
||||
variable=draft_var_srv.get_variable(variable_id=variable_id_str),
|
||||
app_id=pipeline.id,
|
||||
variable_id=variable_id_str,
|
||||
current_user_id=current_user.id,
|
||||
)
|
||||
variable = draft_var_srv.get_variable(variable_id=variable_id_str)
|
||||
if variable is None:
|
||||
raise NotFoundError(description=f"variable not found, id={variable_id_str}")
|
||||
if variable.app_id != pipeline.id:
|
||||
raise NotFoundError(description=f"variable not found, id={variable_id_str}")
|
||||
|
||||
new_name = args.get(self._PATCH_NAME_FIELD, None)
|
||||
raw_value = args.get(self._PATCH_VALUE_FIELD, None)
|
||||
@@ -283,17 +272,16 @@ class RagPipelineVariableApi(Resource):
|
||||
|
||||
@console_ns.response(204, "Variable deleted successfully")
|
||||
@_api_prerequisite
|
||||
def delete(self, current_user: Account, pipeline: Pipeline, variable_id: UUID):
|
||||
def delete(self, _current_user: Account, pipeline: Pipeline, variable_id: UUID):
|
||||
draft_var_srv = WorkflowDraftVariableService(
|
||||
session=db.session(),
|
||||
)
|
||||
variable_id_str = str(variable_id)
|
||||
variable = ensure_variable_access(
|
||||
variable=draft_var_srv.get_variable(variable_id=variable_id_str),
|
||||
app_id=pipeline.id,
|
||||
variable_id=variable_id_str,
|
||||
current_user_id=current_user.id,
|
||||
)
|
||||
variable = draft_var_srv.get_variable(variable_id=variable_id_str)
|
||||
if variable is None:
|
||||
raise NotFoundError(description=f"variable not found, id={variable_id_str}")
|
||||
if variable.app_id != pipeline.id:
|
||||
raise NotFoundError(description=f"variable not found, id={variable_id_str}")
|
||||
draft_var_srv.delete_variable(variable)
|
||||
db.session.commit()
|
||||
return Response("", 204)
|
||||
@@ -304,7 +292,7 @@ class RagPipelineVariableResetApi(Resource):
|
||||
@console_ns.response(200, "Variable reset successfully", workflow_draft_variable_model)
|
||||
@console_ns.response(204, "Variable reset (no content)")
|
||||
@_api_prerequisite
|
||||
def put(self, current_user: Account, pipeline: Pipeline, variable_id: UUID):
|
||||
def put(self, _current_user: Account, pipeline: Pipeline, variable_id: UUID):
|
||||
draft_var_srv = WorkflowDraftVariableService(
|
||||
session=db.session(),
|
||||
)
|
||||
@@ -316,12 +304,11 @@ class RagPipelineVariableResetApi(Resource):
|
||||
f"Draft workflow not found, pipeline_id={pipeline.id}",
|
||||
)
|
||||
variable_id_str = str(variable_id)
|
||||
variable = ensure_variable_access(
|
||||
variable=draft_var_srv.get_variable(variable_id=variable_id_str),
|
||||
app_id=pipeline.id,
|
||||
variable_id=variable_id_str,
|
||||
current_user_id=current_user.id,
|
||||
)
|
||||
variable = draft_var_srv.get_variable(variable_id=variable_id_str)
|
||||
if variable is None:
|
||||
raise NotFoundError(description=f"variable not found, id={variable_id_str}")
|
||||
if variable.app_id != pipeline.id:
|
||||
raise NotFoundError(description=f"variable not found, id={variable_id_str}")
|
||||
|
||||
resetted = draft_var_srv.reset_variable(draft_workflow, variable)
|
||||
db.session.commit()
|
||||
|
||||
@@ -10,7 +10,6 @@ from sqlalchemy.orm import Session, sessionmaker
|
||||
from werkzeug.exceptions import BadRequest, Forbidden, InternalServerError, NotFound
|
||||
|
||||
import services
|
||||
from configs import dify_config
|
||||
from controllers.common.controller_schemas import DefaultBlockConfigQuery, WorkflowListQuery, WorkflowUpdatePayload
|
||||
from controllers.common.fields import SimpleResultResponse
|
||||
from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models
|
||||
@@ -61,10 +60,8 @@ from models import Account
|
||||
from models.dataset import Pipeline
|
||||
from models.model import EndUser
|
||||
from models.workflow import Workflow
|
||||
from services.dataset_service import DatasetService
|
||||
from services.errors.app import IsDraftWorkflowError, WorkflowHashNotEqualError, WorkflowNotFoundError
|
||||
from services.errors.llm import InvokeRateLimitError
|
||||
from services.errors.rag_pipeline import RagPipelineResourceNotFoundError
|
||||
from services.rag_pipeline.pipeline_generate_service import PipelineGenerateService
|
||||
from services.rag_pipeline.rag_pipeline import RagPipelineService
|
||||
from services.rag_pipeline.rag_pipeline_manage_service import RagPipelineManageService
|
||||
@@ -1019,31 +1016,19 @@ class RagPipelineWorkflowLastRunApi(Resource):
|
||||
@console_ns.route("/rag/pipelines/transform/datasets/<uuid:dataset_id>")
|
||||
class RagPipelineTransformApi(Resource):
|
||||
@console_ns.response(200, "Success", console_ns.models[RagPipelineOpaqueResponse.__name__])
|
||||
@console_ns.response(404, "Dataset or pipeline not found")
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
@rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_EDIT)
|
||||
@with_session
|
||||
def post(self, session: Session, current_tenant_id: str, current_user: Account, dataset_id: UUID):
|
||||
dataset = DatasetService.get_dataset_for_tenant(str(dataset_id), current_tenant_id, session=session)
|
||||
if dataset is None:
|
||||
raise NotFound("Dataset not found.")
|
||||
def post(self, session: Session, current_user: Account, dataset_id: UUID):
|
||||
if not (current_user.has_edit_permission or current_user.is_dataset_operator):
|
||||
raise Forbidden()
|
||||
|
||||
if not dify_config.RBAC_ENABLED:
|
||||
if not (current_user.has_edit_permission or current_user.is_dataset_operator):
|
||||
raise Forbidden()
|
||||
try:
|
||||
DatasetService.check_dataset_permission(dataset, current_user, session)
|
||||
except services.errors.account.NoPermissionError as exc:
|
||||
raise Forbidden(str(exc)) from exc
|
||||
|
||||
try:
|
||||
return RagPipelineTransformService().transform_dataset(dataset, current_user.id, session)
|
||||
except RagPipelineResourceNotFoundError as exc:
|
||||
raise NotFound(str(exc)) from exc
|
||||
dataset_id_str = str(dataset_id)
|
||||
rag_pipeline_transform_service = RagPipelineTransformService()
|
||||
result = rag_pipeline_transform_service.transform_dataset(dataset_id_str, session)
|
||||
return result
|
||||
|
||||
|
||||
@console_ns.route("/rag/pipelines/<uuid:pipeline_id>/workflows/draft/datasource/variables-inspect")
|
||||
|
||||
@@ -3,14 +3,17 @@ from typing import cast
|
||||
|
||||
from flask_restx import Namespace, Resource
|
||||
from pydantic import BaseModel, Field, RootModel, field_validator
|
||||
from sqlalchemy import select
|
||||
|
||||
from controllers.common.schema import query_params_from_model, register_response_schema_models
|
||||
from controllers.console import api
|
||||
from controllers.console.wraps import model_validate
|
||||
from extensions.ext_application_services import application_services
|
||||
from extensions.ext_database import db
|
||||
from fields.base import ResponseModel
|
||||
from libs.helper import dump_response
|
||||
from models.enums import BannerStatus
|
||||
from models.model import ExporleBanner
|
||||
from services.feature_service import FeatureService
|
||||
|
||||
|
||||
class BannerListQuery(BaseModel):
|
||||
@@ -64,7 +67,23 @@ class BannerApi(Resource):
|
||||
@model_validate(BannerListQuery)
|
||||
def get(self, req_data: BannerListQuery):
|
||||
"""Get banner list."""
|
||||
banners = application_services().explore_banner_queries.list_for_language(req_data.language)
|
||||
if not FeatureService.is_explore_banner_enabled():
|
||||
return dump_response(BannerListResponse, [])
|
||||
|
||||
# Build base query for enabled banners
|
||||
base_query = select(ExporleBanner).where(ExporleBanner.status == BannerStatus.ENABLED)
|
||||
|
||||
# Try to get banners in the requested language
|
||||
banners = db.session.scalars(
|
||||
base_query.where(ExporleBanner.language == req_data.language).order_by(ExporleBanner.sort)
|
||||
).all()
|
||||
|
||||
# Fallback to en-US if no banners found and language is not en-US
|
||||
if not banners and req_data.language != "en-US":
|
||||
banners = db.session.scalars(
|
||||
base_query.where(ExporleBanner.language == "en-US").order_by(ExporleBanner.sort)
|
||||
).all()
|
||||
|
||||
return dump_response(BannerListResponse, banners)
|
||||
|
||||
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
from typing import Any
|
||||
from typing import Any, cast
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from controllers.common.fields import Parameters
|
||||
from controllers.common import fields
|
||||
from controllers.common.schema import register_response_schema_models
|
||||
from controllers.console import console_ns
|
||||
from controllers.console.app.error import AppUnavailableError
|
||||
from controllers.console.explore.wraps import InstalledAppResource
|
||||
from extensions.ext_application_services import application_services
|
||||
from libs.helper import dump_response
|
||||
from models.model import InstalledApp
|
||||
from services.app_definition_query_service import AppDefinitionUnavailableError
|
||||
from core.app.app_config.common.parameters_mapping import get_parameters_from_feature_dict
|
||||
from extensions.ext_database import db
|
||||
from models.model import AppMode, InstalledApp, load_annotation_reply_config
|
||||
from services.app_service import AppService
|
||||
|
||||
|
||||
class ExploreAppMetaResponse(BaseModel):
|
||||
@@ -22,22 +22,44 @@ class ExploreAppMetaResponse(BaseModel):
|
||||
tool_icons: dict[str, str | dict[str, Any]] = Field(default_factory=dict)
|
||||
|
||||
|
||||
register_response_schema_models(console_ns, Parameters, ExploreAppMetaResponse)
|
||||
register_response_schema_models(console_ns, fields.Parameters, ExploreAppMetaResponse)
|
||||
|
||||
|
||||
@console_ns.route("/installed-apps/<uuid:installed_app_id>/parameters", endpoint="installed_app_parameters")
|
||||
class AppParameterApi(InstalledAppResource):
|
||||
"""Resource for app variables."""
|
||||
|
||||
@console_ns.response(200, "Success", console_ns.models[Parameters.__name__])
|
||||
@console_ns.response(200, "Success", console_ns.models[fields.Parameters.__name__])
|
||||
def get(self, installed_app: InstalledApp):
|
||||
"""Retrieve app parameters."""
|
||||
try:
|
||||
parameters = application_services().app_definitions.get_parameters(installed_app.app_id)
|
||||
except AppDefinitionUnavailableError:
|
||||
raise AppUnavailableError() from None
|
||||
session = db.session()
|
||||
app_model = installed_app.app_with_session(session=session)
|
||||
|
||||
return dump_response(Parameters, parameters)
|
||||
if app_model is None:
|
||||
raise AppUnavailableError()
|
||||
|
||||
if app_model.mode in {AppMode.ADVANCED_CHAT, AppMode.WORKFLOW}:
|
||||
workflow = app_model.workflow_with_session(session=session)
|
||||
if workflow is None:
|
||||
raise AppUnavailableError()
|
||||
|
||||
features_dict: dict[str, Any] = workflow.features_dict
|
||||
user_input_form = workflow.user_input_form(to_old_structure=True)
|
||||
else:
|
||||
app_model_config = app_model.app_model_config_with_session(session=session)
|
||||
if app_model_config is None:
|
||||
raise AppUnavailableError()
|
||||
|
||||
annotation_reply = load_annotation_reply_config(session, app_model.id)
|
||||
features_dict = cast(
|
||||
dict[str, Any],
|
||||
app_model_config.to_dict(annotation_reply=annotation_reply),
|
||||
)
|
||||
|
||||
user_input_form = features_dict.get("user_input_form", [])
|
||||
|
||||
parameters = get_parameters_from_feature_dict(features_dict=features_dict, user_input_form=user_input_form)
|
||||
return fields.Parameters.model_validate(parameters).model_dump(mode="json")
|
||||
|
||||
|
||||
@console_ns.route("/installed-apps/<uuid:installed_app_id>/meta", endpoint="installed_app_meta")
|
||||
@@ -45,12 +67,7 @@ class ExploreAppMetaApi(InstalledAppResource):
|
||||
@console_ns.response(200, "Success", console_ns.models[ExploreAppMetaResponse.__name__])
|
||||
def get(self, installed_app: InstalledApp):
|
||||
"""Get app meta"""
|
||||
try:
|
||||
tool_icons = application_services().app_definitions.get_tool_icons(installed_app.app_id)
|
||||
except AppDefinitionUnavailableError:
|
||||
raise AppUnavailableError() from None
|
||||
|
||||
return dump_response(
|
||||
ExploreAppMetaResponse,
|
||||
{"tool_icons": tool_icons},
|
||||
)
|
||||
app_model = installed_app.app_with_session(session=db.session())
|
||||
if not app_model:
|
||||
raise ValueError("App not found")
|
||||
return AppService().get_app_meta(app_model, session=db.session())
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import logging
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Any, Literal
|
||||
@@ -50,6 +51,7 @@ from controllers.console.files import FILE_UPLOAD_PARAMS, upload_file_from_reque
|
||||
from controllers.console.remote_files import RemoteFileUploadPayload, upload_remote_file_from_request
|
||||
from controllers.console.wraps import cloud_edition_billing_resource_check, model_validate, with_current_user
|
||||
from controllers.web.error import InvokeRateLimitError as InvokeRateLimitHttpError
|
||||
from core.app.app_config.common.parameters_mapping import get_parameters_from_feature_dict
|
||||
from core.app.apps.base_app_queue_manager import AppQueueManager
|
||||
from core.app.entities.app_invoke_entities import InvokeFrom
|
||||
from core.errors.error import (
|
||||
@@ -59,7 +61,6 @@ from core.errors.error import (
|
||||
)
|
||||
from core.helper import encrypter
|
||||
from core.workflow.llm_environment_variable import LLMEnvironmentVariable, dump_environment_variable
|
||||
from extensions.ext_application_services import application_services
|
||||
from extensions.ext_database import db
|
||||
from extensions.ext_redis import redis_client
|
||||
from fields.base import ResponseModel
|
||||
@@ -73,10 +74,9 @@ from libs import helper
|
||||
from libs.helper import dump_response, to_timestamp, uuid_value
|
||||
from models import Account, App
|
||||
from models.account import TenantStatus
|
||||
from models.model import AppMode, Site
|
||||
from models.model import AppMode, Site, load_annotation_reply_config
|
||||
from models.workflow import Workflow
|
||||
from services.account_service import TenantService
|
||||
from services.app_definition_query_service import AppDefinitionUnavailableError
|
||||
from services.app_generate_service import AppGenerateService
|
||||
from services.app_ref_service import AppRefService
|
||||
from services.app_service import AppResponseView, AppService
|
||||
@@ -855,12 +855,26 @@ class TrialAppParameterApi(Resource):
|
||||
if app_model is None:
|
||||
raise AppUnavailableError()
|
||||
|
||||
try:
|
||||
parameters = application_services().app_definitions.get_parameters(app_model.id)
|
||||
except AppDefinitionUnavailableError:
|
||||
raise AppUnavailableError() from None
|
||||
features_dict: Mapping[str, Any]
|
||||
if app_model.mode in {AppMode.ADVANCED_CHAT, AppMode.WORKFLOW}:
|
||||
workflow = app_model.workflow_with_session(session=session)
|
||||
if workflow is None:
|
||||
raise AppUnavailableError()
|
||||
|
||||
return dump_response(ParametersResponse, parameters)
|
||||
features_dict = workflow.features_dict
|
||||
user_input_form = workflow.user_input_form(to_old_structure=True)
|
||||
else:
|
||||
app_model_config = app_model.app_model_config_with_session(session=session)
|
||||
if app_model_config is None:
|
||||
raise AppUnavailableError()
|
||||
|
||||
annotation_reply = load_annotation_reply_config(session, app_model_config.app_id)
|
||||
features_dict = app_model_config.to_dict(annotation_reply=annotation_reply)
|
||||
|
||||
user_input_form = features_dict.get("user_input_form", [])
|
||||
|
||||
parameters = get_parameters_from_feature_dict(features_dict=features_dict, user_input_form=user_input_form)
|
||||
return ParametersResponse.model_validate(parameters).model_dump(mode="json")
|
||||
|
||||
|
||||
class AppApi(Resource):
|
||||
|
||||
@@ -5,28 +5,19 @@ from functools import wraps
|
||||
from typing import Concatenate
|
||||
|
||||
from flask import Response, abort, request
|
||||
from werkzeug.exceptions import Forbidden
|
||||
|
||||
from configs import dify_config
|
||||
from controllers.common.wraps import enforce_rbac_access
|
||||
from controllers.console.wraps import account_initialization_required, enterprise_license_required, setup_required
|
||||
from core.logging.context import get_request_id, get_trace_id
|
||||
from core.rbac import RBACPermission, RBACResourceScope
|
||||
from enums import DeploymentEdition
|
||||
from enums.deployment_edition import DeploymentEdition
|
||||
from libs.login import current_account_with_tenant, login_required
|
||||
from machinery.context import RequestContext
|
||||
from machinery.errors import AdmissionConfigurationError
|
||||
from models.account import TenantAccountRole
|
||||
|
||||
|
||||
def console_account_admission[T, **P, R](
|
||||
*,
|
||||
editions: frozenset[DeploymentEdition] | None = None,
|
||||
require_valid_enterprise_license: bool = False,
|
||||
allowed_roles: frozenset[TenantAccountRole] | None = None,
|
||||
rbac_resource_scope: RBACResourceScope | None = None,
|
||||
rbac_permission: RBACPermission | None = None,
|
||||
rbac_resource_required: bool = True,
|
||||
) -> Callable[
|
||||
[Callable[Concatenate[T, RequestContext, P], R]],
|
||||
Callable[Concatenate[T, P], R | Response],
|
||||
@@ -35,35 +26,18 @@ def console_account_admission[T, **P, R](
|
||||
|
||||
All combinations use this decorator factory. Requirements are data, while
|
||||
the execution order stays fixed: edition, setup, login/CSRF, account
|
||||
initialization, optional enterprise license, role/RBAC checks, then context
|
||||
construction.
|
||||
initialization, optional enterprise license, then context construction.
|
||||
"""
|
||||
|
||||
if (rbac_resource_scope is None) != (rbac_permission is None):
|
||||
raise AdmissionConfigurationError("RBAC resource scope and permission must be configured together")
|
||||
|
||||
def decorator(
|
||||
view: Callable[Concatenate[T, RequestContext, P], R],
|
||||
) -> Callable[Concatenate[T, P], R | Response]:
|
||||
@wraps(view)
|
||||
def inject_request_context(self: T, /, *args: P.args, **kwargs: P.kwargs) -> R:
|
||||
account_with_tenant = current_account_with_tenant()
|
||||
account = account_with_tenant.account
|
||||
tenant_id = account_with_tenant.tenant_id
|
||||
if allowed_roles is not None and not dify_config.RBAC_ENABLED and account.role not in allowed_roles:
|
||||
raise Forbidden()
|
||||
if rbac_resource_scope is not None and rbac_permission is not None:
|
||||
enforce_rbac_access(
|
||||
tenant_id=tenant_id,
|
||||
account_id=account.id,
|
||||
resource_type=rbac_resource_scope,
|
||||
scene=rbac_permission,
|
||||
resource_required=rbac_resource_required,
|
||||
path_args=kwargs,
|
||||
)
|
||||
request_context = RequestContext(
|
||||
account_id=account.id,
|
||||
active_workspace_id=tenant_id,
|
||||
account_id=account_with_tenant.account.id,
|
||||
active_workspace_id=account_with_tenant.tenant_id,
|
||||
request_id=get_request_id(),
|
||||
trace_id=get_trace_id() or request.headers.get("X-Trace-Id"),
|
||||
)
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
import os
|
||||
from typing import Literal
|
||||
|
||||
from flask import session
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from configs import dify_config
|
||||
from controllers.fastopenapi import console_router
|
||||
from extensions.ext_application_services import application_services
|
||||
from services.init_validation_service import AlreadyInitializedError, InvalidInitializationPasswordError
|
||||
from enums.deployment_edition import DeploymentEdition
|
||||
from extensions.ext_database import db
|
||||
from models.model import DifySetup
|
||||
from services.account_service import TenantService
|
||||
|
||||
from .error import AlreadySetupError, InitValidateFailedError
|
||||
from .wraps import only_edition_self_hosted
|
||||
@@ -30,7 +36,7 @@ class InitValidateResponse(BaseModel):
|
||||
)
|
||||
def get_init_status() -> InitStatusResponse:
|
||||
"""Get initialization validation status."""
|
||||
init_status = is_init_validated()
|
||||
init_status = get_init_validate_status()
|
||||
if init_status:
|
||||
return InitStatusResponse(status="finished")
|
||||
return InitStatusResponse(status="not_started")
|
||||
@@ -45,19 +51,25 @@ def get_init_status() -> InitStatusResponse:
|
||||
@only_edition_self_hosted
|
||||
def validate_init_password(payload: InitValidatePayload) -> InitValidateResponse:
|
||||
"""Validate initialization password."""
|
||||
try:
|
||||
application_services().init_validation.validate_password(payload.password)
|
||||
except AlreadyInitializedError:
|
||||
raise AlreadySetupError() from None
|
||||
except InvalidInitializationPasswordError:
|
||||
tenant_count = TenantService.get_tenant_count(session=db.session())
|
||||
if tenant_count > 0:
|
||||
raise AlreadySetupError()
|
||||
|
||||
if payload.password != os.environ.get("INIT_PASSWORD"):
|
||||
session["is_init_validated"] = False
|
||||
raise InitValidateFailedError() from None
|
||||
raise InitValidateFailedError()
|
||||
|
||||
session["is_init_validated"] = True
|
||||
return InitValidateResponse(result="success")
|
||||
|
||||
|
||||
def is_init_validated() -> bool:
|
||||
return application_services().init_validation.is_validated(
|
||||
session_validated=bool(session.get("is_init_validated")),
|
||||
)
|
||||
def get_init_validate_status() -> bool:
|
||||
if dify_config.DEPLOYMENT_EDITION != DeploymentEdition.CLOUD:
|
||||
if os.environ.get("INIT_PASSWORD"):
|
||||
if session.get("is_init_validated"):
|
||||
return True
|
||||
|
||||
with Session(db.engine) as db_session:
|
||||
return db_session.execute(select(DifySetup)).scalar_one_or_none() is not None
|
||||
|
||||
return True
|
||||
|
||||
17
api/controllers/console/ping.py
Normal file
17
api/controllers/console/ping.py
Normal file
@@ -0,0 +1,17 @@
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from controllers.fastopenapi import console_router
|
||||
|
||||
|
||||
class PingResponse(BaseModel):
|
||||
result: str = Field(description="Health check result", examples=["pong"])
|
||||
|
||||
|
||||
@console_router.get(
|
||||
"/ping",
|
||||
response_model=PingResponse,
|
||||
tags=["console"],
|
||||
)
|
||||
def ping() -> PingResponse:
|
||||
"""Health check endpoint for connection testing."""
|
||||
return PingResponse(result="pong")
|
||||
@@ -14,7 +14,7 @@ from services.setup_service import (
|
||||
)
|
||||
|
||||
from .error import AlreadySetupError, NotInitValidateError
|
||||
from .init_validate import is_init_validated
|
||||
from .init_validate import get_init_validate_status
|
||||
from .wraps import mark_setup_completed, only_edition_self_hosted
|
||||
|
||||
|
||||
@@ -73,7 +73,7 @@ def setup_system(payload: SetupRequestPayload) -> SetupResponse:
|
||||
"""Initialize system setup with admin account.
|
||||
|
||||
NOTE: This endpoint is unauthenticated by design for first-time bootstrap.
|
||||
Access is restricted to self-hosted editions (`COMMUNITY` and `ENTERPRISE`), one-time setup guards,
|
||||
Access is restricted by deployment mode (`SELF_HOSTED`), one-time setup guards,
|
||||
and init-password validation rather than user session authentication.
|
||||
"""
|
||||
try:
|
||||
@@ -85,7 +85,7 @@ def setup_system(payload: SetupRequestPayload) -> SetupResponse:
|
||||
ip_address=extract_remote_ip(request),
|
||||
language=payload.language,
|
||||
),
|
||||
initialization_validated=is_init_validated(),
|
||||
initialization_validated=get_init_validate_status(),
|
||||
)
|
||||
except SetupAlreadyCompletedError:
|
||||
raise AlreadySetupError() from None
|
||||
|
||||
@@ -10,27 +10,21 @@ from controllers.fastopenapi import console_router
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PingResponse(BaseModel):
|
||||
result: str = Field(description="Health check result", examples=["pong"])
|
||||
|
||||
|
||||
class VersionQuery(BaseModel):
|
||||
current_version: str = Field(..., description="Current application version")
|
||||
|
||||
|
||||
class VersionFeatures(BaseModel):
|
||||
can_replace_logo: bool = Field(description="Whether logo replacement is supported")
|
||||
model_load_balancing_enabled: bool = Field(description="Whether model load balancing is enabled")
|
||||
|
||||
|
||||
class VersionResponse(BaseModel):
|
||||
version: str = Field(description="Latest version number")
|
||||
release_date: str = Field(description="Release date of latest version")
|
||||
release_notes: str = Field(description="Release notes for latest version")
|
||||
|
||||
|
||||
@console_router.get(
|
||||
"/ping",
|
||||
response_model=PingResponse,
|
||||
tags=["console"],
|
||||
)
|
||||
def ping() -> PingResponse:
|
||||
"""Health check endpoint for connection testing."""
|
||||
return PingResponse(result="pong")
|
||||
can_auto_update: bool = Field(description="Whether auto-update is supported")
|
||||
features: VersionFeatures = Field(description="Feature flags and capabilities")
|
||||
|
||||
|
||||
@console_router.get(
|
||||
@@ -44,7 +38,13 @@ def check_version_update(query: VersionQuery) -> VersionResponse:
|
||||
|
||||
result = VersionResponse(
|
||||
version=dify_config.project.version,
|
||||
release_date="",
|
||||
release_notes="",
|
||||
can_auto_update=False,
|
||||
features=VersionFeatures(
|
||||
can_replace_logo=dify_config.CAN_REPLACE_LOGO,
|
||||
model_load_balancing_enabled=dify_config.MODEL_LB_ENABLED,
|
||||
),
|
||||
)
|
||||
|
||||
if not check_update_url:
|
||||
@@ -62,9 +62,11 @@ def check_version_update(query: VersionQuery) -> VersionResponse:
|
||||
result.version = query.current_version
|
||||
return result
|
||||
latest_version = content.get("version", result.version)
|
||||
if _has_new_version(latest_version=latest_version, current_version=query.current_version):
|
||||
if _has_new_version(latest_version=latest_version, current_version=f"{query.current_version}"):
|
||||
result.version = latest_version
|
||||
result.release_date = content.get("releaseDate", "")
|
||||
result.release_notes = content.get("releaseNotes", "")
|
||||
result.can_auto_update = content.get("canAutoUpdate", False)
|
||||
return result
|
||||
|
||||
|
||||
@@ -73,6 +75,7 @@ def _has_new_version(*, latest_version: str, current_version: str) -> bool:
|
||||
latest = version.parse(latest_version)
|
||||
current = version.parse(current_version)
|
||||
|
||||
# Compare versions
|
||||
return latest > current
|
||||
except version.InvalidVersion:
|
||||
logger.warning("Invalid version format: latest=%s, current=%s", latest_version, current_version)
|
||||
@@ -11,6 +11,7 @@ from controllers.common.schema import register_response_schema_models, register_
|
||||
from controllers.console import console_ns
|
||||
from controllers.console.wraps import (
|
||||
account_initialization_required,
|
||||
cloud_edition_billing_enabled,
|
||||
cloud_edition_billing_paid_plan_required,
|
||||
model_validate,
|
||||
only_edition_cloud,
|
||||
@@ -121,6 +122,7 @@ class WorkflowRunArchivesApi(Resource):
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@only_edition_cloud
|
||||
@cloud_edition_billing_enabled
|
||||
@cloud_edition_billing_paid_plan_required
|
||||
def get(self):
|
||||
tenant_id, _ = _current_owner_or_admin_ids()
|
||||
@@ -141,6 +143,7 @@ class WorkflowRunArchiveDownloadsApi(Resource):
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@only_edition_cloud
|
||||
@cloud_edition_billing_enabled
|
||||
@cloud_edition_billing_paid_plan_required
|
||||
@model_validate(WorkflowRunArchiveDownloadPayload)
|
||||
def post(self, req_data: WorkflowRunArchiveDownloadPayload):
|
||||
@@ -167,6 +170,7 @@ class WorkflowRunArchiveDownloadApi(Resource):
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@only_edition_cloud
|
||||
@cloud_edition_billing_enabled
|
||||
@cloud_edition_billing_paid_plan_required
|
||||
def get(self, download_id: str):
|
||||
tenant_id, _ = _current_owner_or_admin_ids()
|
||||
@@ -191,6 +195,7 @@ class WorkflowRunArchiveDownloadFileApi(Resource):
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@only_edition_cloud
|
||||
@cloud_edition_billing_enabled
|
||||
@cloud_edition_billing_paid_plan_required
|
||||
def get(self, download_id: str):
|
||||
tenant_id, _ = _current_owner_or_admin_ids()
|
||||
|
||||
@@ -38,6 +38,7 @@ from controllers.console.workspace.error import (
|
||||
)
|
||||
from controllers.console.wraps import (
|
||||
account_initialization_required,
|
||||
cloud_edition_billing_enabled,
|
||||
enable_change_email,
|
||||
enterprise_license_required,
|
||||
model_validate,
|
||||
@@ -45,7 +46,7 @@ from controllers.console.wraps import (
|
||||
setup_required,
|
||||
with_current_user,
|
||||
)
|
||||
from enums import DeploymentEdition
|
||||
from enums.deployment_edition import DeploymentEdition
|
||||
from extensions.ext_database import db
|
||||
from fields.base import ResponseModel
|
||||
from fields.member_fields import AccountResponse
|
||||
@@ -535,6 +536,7 @@ class EducationVerifyApi(Resource):
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@only_edition_cloud
|
||||
@cloud_edition_billing_enabled
|
||||
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[EducationVerifyResponse.__name__])
|
||||
@with_current_user
|
||||
def get(self, account: Account):
|
||||
@@ -552,6 +554,7 @@ class EducationApi(Resource):
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@only_edition_cloud
|
||||
@cloud_edition_billing_enabled
|
||||
@with_current_user
|
||||
def post(self, account: Account):
|
||||
payload = console_ns.payload or {}
|
||||
@@ -564,6 +567,7 @@ class EducationApi(Resource):
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@only_edition_cloud
|
||||
@cloud_edition_billing_enabled
|
||||
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[EducationStatusResponse.__name__])
|
||||
@with_current_user
|
||||
def get(self, account: Account):
|
||||
@@ -581,6 +585,7 @@ class EducationAutoCompleteApi(Resource):
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@only_edition_cloud
|
||||
@cloud_edition_billing_enabled
|
||||
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[EducationAutocompleteResponse.__name__])
|
||||
def get(self):
|
||||
payload = request.args.to_dict(flat=True)
|
||||
|
||||
@@ -32,7 +32,6 @@ from controllers.console.wraps import (
|
||||
setup_required,
|
||||
with_current_user,
|
||||
)
|
||||
from enums import DeploymentEdition
|
||||
from extensions.ext_application_services import application_services
|
||||
from extensions.ext_database import db
|
||||
from extensions.ext_redis import redis_client
|
||||
@@ -180,7 +179,7 @@ def _check_member_invite_limits(tenant_id: str, new_member_count: int, new_accou
|
||||
|
||||
features = FeatureService.get_features(tenant_id=tenant_id, exclude_vector_space=True)
|
||||
|
||||
if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.ENTERPRISE:
|
||||
if dify_config.ENTERPRISE_ENABLED:
|
||||
workspace_members = features.workspace_members
|
||||
if workspace_members.enabled is True and not workspace_members.is_available(new_member_count):
|
||||
raise WorkspaceMembersLimitExceeded()
|
||||
@@ -190,7 +189,7 @@ def _check_member_invite_limits(tenant_id: str, new_member_count: int, new_accou
|
||||
raise SeatsLimitExceeded()
|
||||
return
|
||||
|
||||
if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD:
|
||||
if dify_config.BILLING_ENABLED and features.billing.enabled is True:
|
||||
members = features.members
|
||||
current_member_count = _count_current_members(tenant_id)
|
||||
if 0 < members.limit < current_member_count + new_member_count:
|
||||
@@ -266,10 +265,7 @@ class MemberInviteEmailApi(Resource):
|
||||
|
||||
tenant_id = inviter.current_tenant.id
|
||||
with redis_client.lock(f"workspace_member_invite:{tenant_id}", timeout=60):
|
||||
if dify_config.DEPLOYMENT_EDITION in {
|
||||
DeploymentEdition.CLOUD,
|
||||
DeploymentEdition.ENTERPRISE,
|
||||
}:
|
||||
if dify_config.ENTERPRISE_ENABLED is True or dify_config.BILLING_ENABLED is True:
|
||||
new_member_count, new_account_count = _count_new_member_invites(tenant_id, invitee_emails)
|
||||
_check_member_invite_limits(tenant_id, new_member_count, new_account_count)
|
||||
|
||||
|
||||
@@ -209,8 +209,6 @@ class ModelProviderCredentialApi(Resource):
|
||||
)
|
||||
@setup_required
|
||||
@login_required
|
||||
@is_admin_or_owner_required
|
||||
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.CREDENTIAL_MANAGE, resource_required=False)
|
||||
@account_initialization_required
|
||||
@with_current_tenant_id
|
||||
def get(self, tenant_id: str, provider: str):
|
||||
|
||||
@@ -349,8 +349,6 @@ class ModelProviderModelCredentialApi(Resource):
|
||||
)
|
||||
@setup_required
|
||||
@login_required
|
||||
@is_admin_or_owner_required
|
||||
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.CREDENTIAL_MANAGE, resource_required=False)
|
||||
@account_initialization_required
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
|
||||
@@ -14,7 +14,6 @@ from controllers.console import console_ns
|
||||
from controllers.console.wraps import RBACPermission, RBACResourceScope, model_validate, rbac_permission_required
|
||||
from core.db.session_factory import session_factory
|
||||
from core.rbac import RBACResourceWhitelistScope
|
||||
from enums import DeploymentEdition
|
||||
from extensions.ext_database import db
|
||||
from libs.login import current_account_with_tenant, login_required
|
||||
from models import Account
|
||||
@@ -317,16 +316,14 @@ class RBACRolesApi(Resource):
|
||||
options = req_data.to_inner_options()
|
||||
if not dify_config.RBAC_ENABLED:
|
||||
result = _legacy_workspace_roles(
|
||||
options,
|
||||
include_owner=req_data.include_owner,
|
||||
billing_enabled=dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD,
|
||||
options, include_owner=req_data.include_owner, billing_enabled=dify_config.BILLING_ENABLED
|
||||
)
|
||||
else:
|
||||
result = svc.RBACService.Roles.list(
|
||||
tenant_id,
|
||||
account_id,
|
||||
include_owner=req_data.include_owner,
|
||||
biiling_enabled=dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD,
|
||||
biiling_enabled=dify_config.BILLING_ENABLED,
|
||||
options=options,
|
||||
)
|
||||
|
||||
@@ -354,12 +351,7 @@ class RBACRoleItemApi(Resource):
|
||||
def get(self, role_id):
|
||||
tenant_id, account_id = _current_ids()
|
||||
return _dump(
|
||||
svc.RBACService.Roles.get(
|
||||
tenant_id,
|
||||
account_id,
|
||||
role_id,
|
||||
billing_enabled=dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD,
|
||||
)
|
||||
svc.RBACService.Roles.get(tenant_id, account_id, role_id, billing_enabled=dify_config.BILLING_ENABLED)
|
||||
)
|
||||
|
||||
@login_required
|
||||
@@ -727,17 +719,14 @@ class RBACDatasetWhitelistApi(Resource):
|
||||
def put(self, dataset_id):
|
||||
tenant_id, account_id = _current_ids()
|
||||
request = _payload(_ResourceAccessScopeRequest)
|
||||
result = svc.RBACService.DatasetAccess.replace_whitelist(
|
||||
tenant_id,
|
||||
account_id,
|
||||
str(dataset_id),
|
||||
svc.ReplaceMemberBindings(scope=request.scope.value),
|
||||
return _dump(
|
||||
svc.RBACService.DatasetAccess.replace_whitelist(
|
||||
tenant_id,
|
||||
account_id,
|
||||
str(dataset_id),
|
||||
svc.ReplaceMemberBindings(scope=request.scope.value),
|
||||
)
|
||||
)
|
||||
# Widening the scope only records it: the members still need the default access policy
|
||||
# before they can reach the dataset, same as the app whitelist route above.
|
||||
if dify_config.RBAC_ENABLED and request.scope is RBACResourceWhitelistScope.ALL:
|
||||
initialize_created_app_rbac_access_task.delay(tenant_id, account_id, dataset_id=str(dataset_id))
|
||||
return _dump(result)
|
||||
|
||||
|
||||
@console_ns.route("/workspaces/current/rbac/datasets/<uuid:dataset_id>/user-access-policies")
|
||||
|
||||
@@ -61,13 +61,11 @@ from core.tools.entities.tool_entities import (
|
||||
ToolProviderType,
|
||||
WorkflowToolParameterConfiguration,
|
||||
)
|
||||
from enums import DeploymentEdition
|
||||
from extensions.ext_database import db
|
||||
from fields.base import ResponseModel
|
||||
from libs.helper import alphanumeric, dump_response, uuid_value
|
||||
from libs.login import login_required
|
||||
from models import Account
|
||||
from models.enums import PermissionEnum
|
||||
from models.provider_ids import ToolProviderID
|
||||
|
||||
# from models.provider_ids import ToolProviderID
|
||||
@@ -98,13 +96,6 @@ class ToolProviderListQuery(BaseModel):
|
||||
type: Literal["builtin", "model", "api", "workflow", "mcp"] | None = None
|
||||
|
||||
|
||||
class ToolOAuthAuthorizationQuery(BaseModel):
|
||||
visibility: Literal["only_me", "all_team_members"] | None = Field(
|
||||
default=None,
|
||||
description="Visibility for the OAuth credential. Defaults to 'only_me'.",
|
||||
)
|
||||
|
||||
|
||||
class BuiltinToolCredentialDeletePayload(BaseModel):
|
||||
credential_id: str
|
||||
|
||||
@@ -291,10 +282,10 @@ def _resolve_identity_mode(requested: IdentityMode | None, *, current: IdentityM
|
||||
can never imply forwarding that the runtime won't perform. This gates the
|
||||
API surface to match the backend gate in
|
||||
``MCPTool._forwarding_requested`` — both the API and the backend
|
||||
invocation must be gated on the Enterprise deployment edition.
|
||||
invocation must be gated on ``dify_config.ENTERPRISE_ENABLED``.
|
||||
"""
|
||||
mode = current if requested is None else requested
|
||||
if mode != IdentityMode.OFF and dify_config.DEPLOYMENT_EDITION != DeploymentEdition.ENTERPRISE:
|
||||
if mode != IdentityMode.OFF and not dify_config.ENTERPRISE_ENABLED:
|
||||
return IdentityMode.OFF
|
||||
return mode
|
||||
|
||||
@@ -444,7 +435,6 @@ class WorkflowToolDetailResponse(ResponseModel):
|
||||
register_schema_models(
|
||||
console_ns,
|
||||
ToolProviderListQuery,
|
||||
ToolOAuthAuthorizationQuery,
|
||||
UrlQuery,
|
||||
ProviderQuery,
|
||||
BuiltinCredentialListQuery,
|
||||
@@ -589,8 +579,6 @@ class ToolBuiltinProviderAddApi(Resource):
|
||||
)
|
||||
@setup_required
|
||||
@login_required
|
||||
@is_admin_or_owner_required
|
||||
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.CREDENTIAL_CREATE, resource_required=False)
|
||||
@account_initialization_required
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
@@ -1116,7 +1104,6 @@ class ToolLabelsApi(Resource):
|
||||
|
||||
@console_ns.route("/oauth/plugin/<path:provider>/tool/authorization-url")
|
||||
class ToolPluginOAuthApi(Resource):
|
||||
@console_ns.doc(params=query_params_from_model(ToolOAuthAuthorizationQuery))
|
||||
@console_ns.response(
|
||||
200,
|
||||
"Tool OAuth authorization URL generated successfully",
|
||||
@@ -1138,25 +1125,9 @@ class ToolPluginOAuthApi(Resource):
|
||||
if oauth_client_params is None:
|
||||
raise Forbidden("no oauth available client config found for this tool provider")
|
||||
|
||||
# Visibility is chosen by the user in the frontend before the redirect,
|
||||
# then read back in the callback below when the credential is created.
|
||||
# Only ONLY_ME / ALL_TEAM are accepted; anything else falls back to
|
||||
# ONLY_ME (OAuth tokens are personal by nature).
|
||||
raw_visibility = request.args.get("visibility")
|
||||
try:
|
||||
requested_visibility = PermissionEnum(raw_visibility) if raw_visibility else PermissionEnum.ONLY_ME
|
||||
except ValueError:
|
||||
requested_visibility = PermissionEnum.ONLY_ME
|
||||
if requested_visibility not in (PermissionEnum.ONLY_ME, PermissionEnum.ALL_TEAM):
|
||||
requested_visibility = PermissionEnum.ONLY_ME
|
||||
|
||||
oauth_handler = OAuthHandler()
|
||||
context_id = OAuthProxyService.create_proxy_context(
|
||||
user_id=user.id,
|
||||
tenant_id=tenant_id,
|
||||
plugin_id=plugin_id,
|
||||
provider=provider_name,
|
||||
extra_data={"visibility": requested_visibility.value},
|
||||
user_id=user.id, tenant_id=tenant_id, plugin_id=plugin_id, provider=provider_name
|
||||
)
|
||||
redirect_uri = f"{dify_config.CONSOLE_API_URL}/console/api/oauth/plugin/{provider}/tool/callback"
|
||||
authorization_url_response = oauth_handler.get_authorization_url(
|
||||
@@ -1220,17 +1191,7 @@ class ToolOAuthCallback(Resource):
|
||||
if not credentials:
|
||||
raise Exception("the plugin credentials failed")
|
||||
|
||||
# Visibility was chosen by the user before the redirect and stashed in
|
||||
# the proxy context. Fall back to ONLY_ME for older cookies (or for
|
||||
# anything that somehow wrote an unexpected value) — OAuth tokens are
|
||||
# personal by default.
|
||||
stored_visibility = context.get("visibility")
|
||||
try:
|
||||
visibility = PermissionEnum(stored_visibility) if stored_visibility else PermissionEnum.ONLY_ME
|
||||
except ValueError:
|
||||
visibility = PermissionEnum.ONLY_ME
|
||||
if visibility not in (PermissionEnum.ONLY_ME, PermissionEnum.ALL_TEAM):
|
||||
visibility = PermissionEnum.ONLY_ME
|
||||
# add credentials to database — OAuth tokens default to only_me since they're personal
|
||||
BuiltinToolManageService.add_builtin_tool_provider(
|
||||
user_id=user_id,
|
||||
tenant_id=tenant_id,
|
||||
@@ -1238,7 +1199,7 @@ class ToolOAuthCallback(Resource):
|
||||
credentials=dict(credentials),
|
||||
expires_at=expires_at,
|
||||
api_type=CredentialType.OAUTH2,
|
||||
visibility=visibility.value,
|
||||
visibility="only_me",
|
||||
)
|
||||
# response-contract:ignore redirect response
|
||||
return redirect(f"{dify_config.CONSOLE_WEB_URL}/oauth-callback")
|
||||
@@ -1306,8 +1267,6 @@ class ToolOAuthCustomClient(Resource):
|
||||
)
|
||||
@setup_required
|
||||
@login_required
|
||||
@is_admin_or_owner_required
|
||||
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.CREDENTIAL_MANAGE, resource_required=False)
|
||||
@account_initialization_required
|
||||
@with_current_tenant_id
|
||||
def delete(self, current_tenant_id: str, provider: str):
|
||||
@@ -1458,7 +1417,7 @@ class ToolProviderMCPApi(Resource):
|
||||
with sessionmaker(db.engine).begin() as session:
|
||||
service = MCPToolManageService(session=session)
|
||||
# Resolve "leave unchanged" (None) against the stored value, and gate
|
||||
# the result on the Enterprise edition — both are API-layer concerns, so
|
||||
# the result on ENTERPRISE_ENABLED — both are API-layer concerns, so
|
||||
# the service receives a concrete IdentityMode.
|
||||
existing = service.get_provider(provider_id=req_data.provider_id, tenant_id=current_tenant_id)
|
||||
identity_mode = _resolve_identity_mode(req_data.identity_mode, current=IdentityMode(existing.identity_mode))
|
||||
|
||||
@@ -38,7 +38,6 @@ from controllers.console.wraps import (
|
||||
with_current_tenant_id,
|
||||
with_current_user,
|
||||
)
|
||||
from enums import CloudPlan
|
||||
from extensions.ext_application_services import application_services
|
||||
from extensions.ext_database import db
|
||||
from fields.base import ResponseModel
|
||||
@@ -81,7 +80,7 @@ class WorkspaceInfoPayload(BaseModel):
|
||||
class TenantInfoResponse(ResponseModel):
|
||||
id: str
|
||||
name: str | None = None
|
||||
plan: CloudPlan | None = None
|
||||
plan: str | None = None
|
||||
status: str | None = None
|
||||
created_at: int | None = None
|
||||
role: str | None = None
|
||||
@@ -93,7 +92,7 @@ class TenantInfoResponse(ResponseModel):
|
||||
trial_credits_exhausted_at: int | None = None
|
||||
next_credit_reset_date: int | None = None
|
||||
|
||||
@field_validator("status", "trial_end_reason", mode="before")
|
||||
@field_validator("plan", "status", "trial_end_reason", mode="before")
|
||||
@classmethod
|
||||
def _normalize_enum_like(cls, value):
|
||||
if value is None:
|
||||
@@ -112,20 +111,20 @@ class CurrentWorkspaceSummaryResponse(ResponseModel):
|
||||
id: str
|
||||
name: str
|
||||
role: TenantAccountRole
|
||||
plan: CloudPlan | None
|
||||
plan: str | None
|
||||
credits: int | None = Field(description="Remaining credits in the effective pool; -1 means unlimited.")
|
||||
|
||||
|
||||
class TenantListItemResponse(ResponseModel):
|
||||
id: str
|
||||
name: str | None = None
|
||||
plan: CloudPlan | None = None
|
||||
plan: str | None = None
|
||||
status: str | None = None
|
||||
created_at: int | None = None
|
||||
last_opened_at: int | None = None
|
||||
current: bool
|
||||
|
||||
@field_validator("status", mode="before")
|
||||
@field_validator("plan", "status", mode="before")
|
||||
@classmethod
|
||||
def _normalize_enum_like(cls, value):
|
||||
if value is None:
|
||||
@@ -302,8 +301,8 @@ class SwitchWorkspaceApi(Resource):
|
||||
# Check whether the tenant_id belongs to the current account.
|
||||
try:
|
||||
TenantService.switch_tenant(current_user, args.tenant_id, session=session)
|
||||
except Exception as e:
|
||||
raise AccountNotLinkTenantError("Account not link tenant") from e
|
||||
except Exception:
|
||||
raise AccountNotLinkTenantError("Account not link tenant")
|
||||
|
||||
new_tenant = TenantService.get_tenant_by_id(args.tenant_id, session=session)
|
||||
if new_tenant is None:
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import contextlib
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from functools import wraps
|
||||
@@ -18,7 +19,8 @@ from controllers.common.wraps import (
|
||||
)
|
||||
from controllers.console.auth.error import AuthenticationFailedError, EmailCodeError
|
||||
from controllers.console.workspace.error import AccountNotInitializedError
|
||||
from enums import CloudPlan, DeploymentEdition
|
||||
from enums.cloud_plan import CloudPlan
|
||||
from enums.deployment_edition import DeploymentEdition
|
||||
from extensions.ext_database import db
|
||||
from extensions.ext_redis import redis_client
|
||||
from libs.encryption import FieldEncryption
|
||||
@@ -140,7 +142,7 @@ def only_edition_cloud[**P, R](view: Callable[P, R]) -> Callable[P, R]:
|
||||
def only_edition_enterprise[**P, R](view: Callable[P, R]) -> Callable[P, R]:
|
||||
@wraps(view)
|
||||
def decorated(*args: P.args, **kwargs: P.kwargs):
|
||||
if dify_config.DEPLOYMENT_EDITION != DeploymentEdition.ENTERPRISE:
|
||||
if not dify_config.ENTERPRISE_ENABLED:
|
||||
abort(404)
|
||||
|
||||
return view(*args, **kwargs)
|
||||
@@ -159,6 +161,16 @@ def only_edition_self_hosted[**P, R](view: Callable[P, R]) -> Callable[P, R]:
|
||||
return decorated
|
||||
|
||||
|
||||
def cloud_edition_billing_enabled[**P, R](view: Callable[P, R]) -> Callable[P, R]:
|
||||
@wraps(view)
|
||||
def decorated(*args: P.args, **kwargs: P.kwargs):
|
||||
if not dify_config.BILLING_ENABLED:
|
||||
abort(403, "Billing feature is not enabled.")
|
||||
return view(*args, **kwargs)
|
||||
|
||||
return decorated
|
||||
|
||||
|
||||
def cloud_edition_billing_paid_plan_required[**P, R](view: Callable[P, R]) -> Callable[P, R]:
|
||||
@wraps(view)
|
||||
def decorated(*args: P.args, **kwargs: P.kwargs):
|
||||
@@ -180,7 +192,7 @@ def cloud_edition_billing_resource_check[**P, R](resource: str) -> Callable[[Cal
|
||||
def decorated(*args: P.args, **kwargs: P.kwargs):
|
||||
_, current_tenant_id = current_account_with_tenant()
|
||||
if resource == "vector_space":
|
||||
if dify_config.DEPLOYMENT_EDITION != DeploymentEdition.CLOUD:
|
||||
if not dify_config.BILLING_ENABLED:
|
||||
return view(*args, **kwargs)
|
||||
|
||||
vector_space = FeatureService.get_vector_space(current_tenant_id)
|
||||
@@ -248,35 +260,35 @@ def cloud_edition_billing_knowledge_limit_check[**P, R](
|
||||
return interceptor
|
||||
|
||||
|
||||
def check_knowledge_rate_limit() -> None:
|
||||
_, current_tenant_id = current_account_with_tenant()
|
||||
knowledge_rate_limit = FeatureService.get_knowledge_rate_limit(current_tenant_id)
|
||||
if not knowledge_rate_limit.enabled:
|
||||
return
|
||||
|
||||
current_time = int(time.time() * 1000)
|
||||
key = f"rate_limit_{current_tenant_id}"
|
||||
redis_client.zadd(key, {current_time: current_time})
|
||||
redis_client.zremrangebyscore(key, 0, current_time - 60000)
|
||||
|
||||
if redis_client.zcard(key) > knowledge_rate_limit.limit:
|
||||
db.session.add( # guard-ignore: no-new-controller-sqlalchemy -- existing decorator audit write
|
||||
RateLimitLog(
|
||||
tenant_id=current_tenant_id,
|
||||
subscription_plan=knowledge_rate_limit.subscription_plan,
|
||||
operation="knowledge",
|
||||
)
|
||||
)
|
||||
db.session.commit()
|
||||
abort(403, "Sorry, you have reached the knowledge base request rate limit of your subscription.")
|
||||
|
||||
|
||||
def cloud_edition_billing_rate_limit_check[**P, R](resource: str) -> Callable[[Callable[P, R]], Callable[P, R]]:
|
||||
def interceptor(view: Callable[P, R]):
|
||||
@wraps(view)
|
||||
def decorated(*args: P.args, **kwargs: P.kwargs):
|
||||
if resource == "knowledge":
|
||||
check_knowledge_rate_limit()
|
||||
_, current_tenant_id = current_account_with_tenant()
|
||||
knowledge_rate_limit = FeatureService.get_knowledge_rate_limit(current_tenant_id)
|
||||
if knowledge_rate_limit.enabled:
|
||||
current_time = int(time.time() * 1000)
|
||||
key = f"rate_limit_{current_tenant_id}"
|
||||
|
||||
redis_client.zadd(key, {current_time: current_time})
|
||||
|
||||
redis_client.zremrangebyscore(key, 0, current_time - 60000)
|
||||
|
||||
request_count = redis_client.zcard(key)
|
||||
|
||||
if request_count > knowledge_rate_limit.limit:
|
||||
# add ratelimit record
|
||||
rate_limit_log = RateLimitLog(
|
||||
tenant_id=current_tenant_id,
|
||||
subscription_plan=knowledge_rate_limit.subscription_plan,
|
||||
operation="knowledge",
|
||||
)
|
||||
db.session.add(rate_limit_log)
|
||||
db.session.commit()
|
||||
abort(
|
||||
403, "Sorry, you have reached the knowledge base request rate limit of your subscription."
|
||||
)
|
||||
return view(*args, **kwargs)
|
||||
|
||||
return decorated
|
||||
@@ -289,7 +301,7 @@ def cloud_utm_record[**P, R](view: Callable[P, R]) -> Callable[P, R]:
|
||||
def decorated(*args: P.args, **kwargs: P.kwargs):
|
||||
with contextlib.suppress(Exception):
|
||||
utm_info = request.cookies.get("utm_info")
|
||||
if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD and utm_info:
|
||||
if dify_config.BILLING_ENABLED and utm_info:
|
||||
_, current_tenant_id = current_account_with_tenant()
|
||||
utm_info_dict: UtmInfo = json.loads(utm_info)
|
||||
OperationService.record_utm(current_tenant_id, utm_info_dict)
|
||||
@@ -318,7 +330,7 @@ def setup_required[R](view: Callable[..., R]) -> Callable[..., R]:
|
||||
# preserving support for plain functions used in tests and utilities.
|
||||
# check setup
|
||||
if dify_config.DEPLOYMENT_EDITION != DeploymentEdition.CLOUD and not _is_setup_completed():
|
||||
if dify_config.INIT_PASSWORD:
|
||||
if os.environ.get("INIT_PASSWORD"):
|
||||
raise NotInitValidateError()
|
||||
raise NotSetupError()
|
||||
|
||||
|
||||
@@ -14,11 +14,12 @@ api = ExternalApi(
|
||||
|
||||
files_ns = Namespace("files", description="File operations", path="/")
|
||||
|
||||
from . import image_preview, tool_files, upload
|
||||
from . import agent_drive_archive, image_preview, tool_files, upload
|
||||
|
||||
api.add_namespace(files_ns)
|
||||
|
||||
__all__ = [
|
||||
"agent_drive_archive",
|
||||
"api",
|
||||
"bp",
|
||||
"files_ns",
|
||||
|
||||
69
api/controllers/files/agent_drive_archive.py
Normal file
69
api/controllers/files/agent_drive_archive.py
Normal file
@@ -0,0 +1,69 @@
|
||||
from urllib.parse import quote
|
||||
|
||||
from flask import Response, request
|
||||
from flask_restx import Resource
|
||||
from pydantic import BaseModel, Field
|
||||
from werkzeug.exceptions import Forbidden, NotFound
|
||||
|
||||
from controllers.common.file_response import enforce_download_for_html
|
||||
from controllers.common.schema import register_schema_models
|
||||
from controllers.files import files_ns
|
||||
from extensions.ext_database import db
|
||||
from models.agent import AgentDriveFileKind
|
||||
from services.agent_drive_service import AgentDriveError, AgentDriveService
|
||||
|
||||
|
||||
class AgentDriveArchiveMemberQuery(BaseModel):
|
||||
tenant_id: str = Field(..., description="Tenant ID")
|
||||
agent_id: str = Field(..., description="Agent ID")
|
||||
key: str = Field(..., description="Virtual drive key")
|
||||
archive_file_kind: AgentDriveFileKind = Field(..., description="Archive file kind")
|
||||
archive_file_id: str = Field(..., description="Archive file id")
|
||||
member_path: str = Field(..., description="Zip member path")
|
||||
timestamp: str = Field(..., description="Unix timestamp")
|
||||
nonce: str = Field(..., description="Random nonce")
|
||||
sign: str = Field(..., description="HMAC signature")
|
||||
as_attachment: bool = Field(default=False, description="Download as attachment")
|
||||
|
||||
|
||||
register_schema_models(files_ns, AgentDriveArchiveMemberQuery)
|
||||
|
||||
|
||||
@files_ns.route("/agent-drive/archive-member")
|
||||
class AgentDriveArchiveMemberApi(Resource):
|
||||
@files_ns.doc("get_agent_drive_archive_member")
|
||||
@files_ns.doc(description="Download a lazily resolved Agent Skill archive member by signed parameters")
|
||||
def get(self):
|
||||
args = AgentDriveArchiveMemberQuery.model_validate(request.args.to_dict(flat=True))
|
||||
if not AgentDriveService.verify_archive_member_signature(
|
||||
tenant_id=args.tenant_id,
|
||||
agent_id=args.agent_id,
|
||||
key=args.key,
|
||||
archive_file_kind=args.archive_file_kind,
|
||||
archive_file_id=args.archive_file_id,
|
||||
member_path=args.member_path,
|
||||
timestamp=args.timestamp,
|
||||
nonce=args.nonce,
|
||||
sign=args.sign,
|
||||
):
|
||||
raise Forbidden("Invalid request.")
|
||||
try:
|
||||
payload, mime_type, filename = AgentDriveService().load_archive_member_for_signed_request(
|
||||
tenant_id=args.tenant_id,
|
||||
agent_id=args.agent_id,
|
||||
key=args.key,
|
||||
archive_file_kind=args.archive_file_kind,
|
||||
archive_file_id=args.archive_file_id,
|
||||
member_path=args.member_path,
|
||||
session=db.session(),
|
||||
)
|
||||
except AgentDriveError as exc:
|
||||
raise NotFound(exc.message) from exc
|
||||
|
||||
response = Response(payload, mimetype=mime_type, direct_passthrough=True, headers={})
|
||||
response.headers["Content-Length"] = str(len(payload))
|
||||
if args.as_attachment and filename:
|
||||
encoded_filename = quote(filename)
|
||||
response.headers["Content-Disposition"] = f"attachment; filename*=UTF-8''{encoded_filename}"
|
||||
enforce_download_for_html(response, mime_type=mime_type, filename=filename, extension="")
|
||||
return response
|
||||
@@ -1,4 +1,3 @@
|
||||
import os
|
||||
from urllib.parse import quote
|
||||
from uuid import UUID
|
||||
|
||||
@@ -30,18 +29,6 @@ class FilePreviewQuery(FileSignatureQuery):
|
||||
register_schema_models(files_ns, FileSignatureQuery, FilePreviewQuery)
|
||||
|
||||
|
||||
def _is_svg_content(mime_type: str | None, filename: str | None, extension: str | None) -> bool:
|
||||
normalized_mime_type = mime_type.split(";", 1)[0].strip().lower() if mime_type else ""
|
||||
if normalized_mime_type == "image/svg+xml":
|
||||
return True
|
||||
|
||||
normalized_extension = extension.lstrip(".").lower() if extension else ""
|
||||
if normalized_extension == "svg":
|
||||
return True
|
||||
|
||||
return bool(filename and os.path.splitext(filename)[1].lstrip(".").lower() == "svg")
|
||||
|
||||
|
||||
@files_ns.route("/<uuid:file_id>/image-preview")
|
||||
class ImagePreviewApi(Resource):
|
||||
"""Deprecated endpoint for retrieving image previews."""
|
||||
@@ -142,13 +129,10 @@ class FilePreviewApi(Resource):
|
||||
response.headers["Accept-Ranges"] = "bytes"
|
||||
if upload_file.size > 0:
|
||||
response.headers["Content-Length"] = str(upload_file.size)
|
||||
is_svg = _is_svg_content(upload_file.mime_type, upload_file.name, upload_file.extension)
|
||||
if args.as_attachment or is_svg:
|
||||
if args.as_attachment:
|
||||
encoded_filename = quote(upload_file.name)
|
||||
response.headers["Content-Disposition"] = f"attachment; filename*=UTF-8''{encoded_filename}"
|
||||
response.headers["Content-Type"] = "application/octet-stream"
|
||||
if is_svg:
|
||||
response.headers["X-Content-Type-Options"] = "nosniff"
|
||||
response.headers["Content-Type"] = "application/octet-stream"
|
||||
|
||||
enforce_download_for_html(
|
||||
response,
|
||||
|
||||
@@ -67,8 +67,8 @@ class ToolFileApi(Resource):
|
||||
except NotFound:
|
||||
raise
|
||||
|
||||
except Exception as e:
|
||||
raise UnsupportedFileTypeError() from e
|
||||
except Exception:
|
||||
raise UnsupportedFileTypeError()
|
||||
|
||||
mime_type = tool_file.mime_type
|
||||
filename = tool_file.filename
|
||||
|
||||
@@ -32,7 +32,6 @@ class PluginUploadQuery(BaseModel):
|
||||
user_id: str | None = Field(default=None, description="User identifier")
|
||||
user_from: Literal["account", "end-user"] | None = Field(default=None, description="User identity type")
|
||||
conversation_id: str | None = Field(default=None, description="Conversation identifier")
|
||||
max_size: int | None = Field(default=None, ge=0, description="Signed maximum file size in bytes")
|
||||
|
||||
|
||||
register_schema_models(files_ns, PluginUploadQuery)
|
||||
@@ -114,22 +113,14 @@ class PluginUploadFileApi(Resource):
|
||||
timestamp=timestamp,
|
||||
nonce=nonce,
|
||||
sign=sign,
|
||||
max_size=args.max_size,
|
||||
):
|
||||
raise Forbidden("Invalid request.")
|
||||
|
||||
try:
|
||||
if args.max_size is None:
|
||||
file_binary = file.stream.read()
|
||||
else:
|
||||
file_binary = file.stream.read(args.max_size + 1)
|
||||
if len(file_binary) > args.max_size:
|
||||
raise FileTooLargeError("File size exceeds the signed upload limit.")
|
||||
|
||||
tool_file = ToolFileManager().create_file_by_raw(
|
||||
user_id=owner_id,
|
||||
tenant_id=tenant_id,
|
||||
file_binary=file_binary,
|
||||
file_binary=file.stream.read(),
|
||||
mimetype=mimetype,
|
||||
filename=filename,
|
||||
conversation_id=args.conversation_id,
|
||||
|
||||
@@ -18,11 +18,11 @@ inner_api_ns = Namespace("inner_api", description="Internal API operations", pat
|
||||
from . import mail as _mail
|
||||
from . import runtime_credentials as _runtime_credentials
|
||||
from .agent import files as _agent_files
|
||||
from .agent import llm as _agent_llm
|
||||
from .agent import tools as _agent_tools
|
||||
from .app import dsl as _app_dsl
|
||||
from .knowledge import retrieval as _knowledge_retrieval
|
||||
from .plugin import agent_config as _agent_config
|
||||
from .plugin import agent_drive as _agent_drive
|
||||
from .plugin import plugin as _plugin
|
||||
from .workspace import workspace as _workspace
|
||||
|
||||
@@ -30,8 +30,8 @@ api.add_namespace(inner_api_ns)
|
||||
|
||||
__all__ = [
|
||||
"_agent_config",
|
||||
"_agent_drive",
|
||||
"_agent_files",
|
||||
"_agent_llm",
|
||||
"_agent_tools",
|
||||
"_app_dsl",
|
||||
"_knowledge_retrieval",
|
||||
|
||||
@@ -5,7 +5,7 @@ from __future__ import annotations
|
||||
from typing import Literal
|
||||
|
||||
from flask_restx import Resource
|
||||
from pydantic import BaseModel, ConfigDict, Field, ValidationError
|
||||
from pydantic import BaseModel, ConfigDict, ValidationError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from configs import dify_config
|
||||
@@ -39,7 +39,6 @@ class AgentFileUploadRequestPayload(RequestRequestUploadFile):
|
||||
tenant_id: str
|
||||
user_id: str
|
||||
user_from: Literal["account", "end-user"] | None = None
|
||||
max_size: int = Field(ge=0, description="Maximum upload size in bytes")
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
@@ -128,7 +127,6 @@ class AgentFileUploadRequestApi(Resource):
|
||||
user_id=owner_id,
|
||||
conversation_id=payload.conversation_id,
|
||||
user_from=payload.user_from,
|
||||
max_size=payload.max_size,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise AgentFileRequestHttpError(
|
||||
|
||||
@@ -1,113 +0,0 @@
|
||||
"""Trusted streaming LLM gateway for dify-agent runs."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections.abc import Generator
|
||||
|
||||
from flask import Response, stream_with_context
|
||||
from flask_restx import Resource
|
||||
from pydantic import ValidationError
|
||||
|
||||
from controllers.common.schema import register_schema_models
|
||||
from controllers.inner_api import inner_api_ns
|
||||
from controllers.inner_api.wraps import agent_inner_api_only
|
||||
from core.errors.error import QuotaExceededError
|
||||
from libs.exception import BaseHTTPException
|
||||
from services.agent_llm_inner_service import AgentLLMInnerService, AgentLLMInnerServiceError
|
||||
from services.entities.agent_llm_inner import AgentLLMInvokeRequest
|
||||
|
||||
|
||||
class AgentLLMInvokeHttpError(BaseHTTPException):
|
||||
error_code = "agent_llm_invoke_failed"
|
||||
description = "Agent LLM invocation failed."
|
||||
code = 500
|
||||
|
||||
def __init__(self, *, error_code: str, description: str, status_code: int) -> None:
|
||||
self.error_code = error_code
|
||||
self.description = description
|
||||
self.code = status_code
|
||||
super().__init__(description)
|
||||
|
||||
|
||||
register_schema_models(inner_api_ns, AgentLLMInvokeRequest)
|
||||
|
||||
|
||||
@inner_api_ns.route("/agent/llm/invoke")
|
||||
class AgentLLMInvokeApi(Resource):
|
||||
"""Resolve one dify-agent model request before proxying it."""
|
||||
|
||||
@agent_inner_api_only
|
||||
@inner_api_ns.doc("inner_agent_llm_invoke")
|
||||
@inner_api_ns.expect(inner_api_ns.models[AgentLLMInvokeRequest.__name__])
|
||||
@inner_api_ns.produces(["text/event-stream"])
|
||||
def post(self) -> Response:
|
||||
try:
|
||||
payload = AgentLLMInvokeRequest.model_validate(inner_api_ns.payload or {})
|
||||
except ValidationError as exc:
|
||||
raise AgentLLMInvokeHttpError(
|
||||
error_code="invalid_request",
|
||||
description=str(exc),
|
||||
status_code=400,
|
||||
) from exc
|
||||
|
||||
service = AgentLLMInnerService()
|
||||
try:
|
||||
prepared = service.prepare(payload)
|
||||
except AgentLLMInnerServiceError as exc:
|
||||
raise AgentLLMInvokeHttpError(
|
||||
error_code=exc.error_code,
|
||||
description=exc.description,
|
||||
status_code=exc.status_code,
|
||||
) from exc
|
||||
except QuotaExceededError as exc:
|
||||
raise AgentLLMInvokeHttpError(
|
||||
error_code="agent_llm_quota_exceeded",
|
||||
description=str(exc) or "Insufficient Message Credits.",
|
||||
status_code=429,
|
||||
) from exc
|
||||
except ValueError as exc:
|
||||
raise AgentLLMInvokeHttpError(
|
||||
error_code="invalid_model_request",
|
||||
description=str(exc),
|
||||
status_code=400,
|
||||
) from exc
|
||||
|
||||
def generate() -> Generator[str, None, None]:
|
||||
try:
|
||||
for chunk in service.invoke(prepared):
|
||||
envelope = {"code": 0, "message": "", "data": chunk.model_dump(mode="json")}
|
||||
yield f"data: {json.dumps(envelope, ensure_ascii=False, separators=(',', ':'))}\n\n"
|
||||
except GeneratorExit:
|
||||
raise
|
||||
except QuotaExceededError as exc:
|
||||
error = {
|
||||
"error_type": "AgentLLMQuotaExceededError",
|
||||
"message": str(exc) or "Insufficient hosted model quota remaining.",
|
||||
}
|
||||
envelope = {
|
||||
"code": -429,
|
||||
"message": json.dumps(error, ensure_ascii=False, separators=(",", ":")),
|
||||
"data": None,
|
||||
}
|
||||
yield f"data: {json.dumps(envelope, ensure_ascii=False, separators=(',', ':'))}\n\n"
|
||||
except Exception as exc:
|
||||
error = {
|
||||
"error_type": type(exc).__name__,
|
||||
"message": str(exc) or "Agent LLM invocation failed.",
|
||||
}
|
||||
envelope = {
|
||||
"code": -500,
|
||||
"message": json.dumps(error, ensure_ascii=False, separators=(",", ":")),
|
||||
"data": None,
|
||||
}
|
||||
yield f"data: {json.dumps(envelope, ensure_ascii=False, separators=(',', ':'))}\n\n"
|
||||
|
||||
return Response(
|
||||
stream_with_context(generate()), # pyrefly: ignore[no-matching-overload]
|
||||
content_type="text/event-stream",
|
||||
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["AgentLLMInvokeApi", "AgentLLMInvokeHttpError"]
|
||||
@@ -5,15 +5,13 @@ to attribute the created app; workspace/membership validation is done by the
|
||||
Go admin-api caller.
|
||||
"""
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from flask import request
|
||||
from flask_restx import Resource
|
||||
from pydantic import BaseModel, Field, ValidationError, field_validator
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from controllers.common.schema import query_params_from_model, register_schema_model
|
||||
from controllers.common.schema import register_schema_model
|
||||
from controllers.console.wraps import setup_required
|
||||
from controllers.inner_api import inner_api_ns
|
||||
from controllers.inner_api.wraps import enterprise_inner_api_only
|
||||
@@ -22,7 +20,6 @@ from models import Account, App
|
||||
from models.account import AccountStatus
|
||||
from services.app_dsl_service import AppDslService
|
||||
from services.entities.dsl_entities import ImportMode, ImportStatus
|
||||
from services.errors.app import IsDraftWorkflowError, WorkflowNotFoundError
|
||||
|
||||
|
||||
class InnerAppDSLImportPayload(BaseModel):
|
||||
@@ -32,18 +29,6 @@ class InnerAppDSLImportPayload(BaseModel):
|
||||
description: str | None = Field(default=None, description="Override app description from DSL")
|
||||
|
||||
|
||||
class EnterpriseAppDSLExportQuery(BaseModel):
|
||||
include_secret: bool = Field(default=False, description="Whether to include secret values in the exported DSL")
|
||||
workflow_id: UUID | None = Field(default=None, description="Published workflow version ID to export")
|
||||
|
||||
@field_validator("include_secret", mode="before")
|
||||
@classmethod
|
||||
def parse_include_secret(cls, value: object) -> bool:
|
||||
if isinstance(value, str):
|
||||
return value.lower() == "true"
|
||||
return bool(value)
|
||||
|
||||
|
||||
register_schema_model(inner_api_ns, InnerAppDSLImportPayload)
|
||||
|
||||
|
||||
@@ -97,48 +82,24 @@ class EnterpriseAppDSLExport(Resource):
|
||||
@enterprise_inner_api_only
|
||||
@inner_api_ns.doc(
|
||||
"enterprise_app_dsl_export",
|
||||
params=query_params_from_model(EnterpriseAppDSLExportQuery),
|
||||
responses={
|
||||
200: "Export successful",
|
||||
400: "Invalid workflow ID or unpublished workflow version",
|
||||
404: "App or workflow version not found",
|
||||
404: "App not found",
|
||||
},
|
||||
)
|
||||
def get(self, app_id: str):
|
||||
"""Export an app's DSL as YAML."""
|
||||
try:
|
||||
query = EnterpriseAppDSLExportQuery.model_validate(request.args.to_dict(flat=True))
|
||||
except ValidationError:
|
||||
return {
|
||||
"code": "invalid_workflow_id",
|
||||
"message": "workflow_id must be a valid UUID",
|
||||
"status": 400,
|
||||
}, 400
|
||||
|
||||
workflow_id = str(query.workflow_id) if query.workflow_id else None
|
||||
include_secret = request.args.get("include_secret", "false").lower() == "true"
|
||||
|
||||
app_model = db.session.get(App, app_id)
|
||||
if not app_model:
|
||||
return {"message": "app not found"}, 404
|
||||
|
||||
if not workflow_id:
|
||||
data = AppDslService.export_dsl(
|
||||
app_model=app_model,
|
||||
session=db.session(),
|
||||
include_secret=query.include_secret,
|
||||
)
|
||||
else:
|
||||
try:
|
||||
data = AppDslService.export_dsl(
|
||||
app_model=app_model,
|
||||
session=db.session(),
|
||||
include_secret=query.include_secret,
|
||||
workflow_id=workflow_id,
|
||||
)
|
||||
except WorkflowNotFoundError as exc:
|
||||
return {"code": "workflow_version_not_found", "message": str(exc), "status": 404}, 404
|
||||
except IsDraftWorkflowError as exc:
|
||||
return {"code": "workflow_version_not_published", "message": str(exc), "status": 400}, 400
|
||||
data = AppDslService.export_dsl(
|
||||
app_model=app_model,
|
||||
session=db.session(),
|
||||
include_secret=include_secret,
|
||||
)
|
||||
|
||||
return {"data": data}, 200
|
||||
|
||||
|
||||
105
api/controllers/inner_api/plugin/agent_drive.py
Normal file
105
api/controllers/inner_api/plugin/agent_drive.py
Normal file
@@ -0,0 +1,105 @@
|
||||
"""Inner API for the agent drive (agent 网盘) control plane.
|
||||
|
||||
These endpoints are called by the dify-agent server (not the sandbox) with the
|
||||
inner API key. The drive ref is the URL segment ``agent-<agent_id>``; the
|
||||
path-like file key travels in the query/body, never as a URL path segment (so
|
||||
its ``/`` characters do not collide with routing). Drive-owned semantics:
|
||||
tenant scoped, no user-level FileAccessScope. Commit still canonicalizes the
|
||||
trusted execution-context user through the same EndUser lookup as plugin file
|
||||
upload before validating ToolFile ownership.
|
||||
"""
|
||||
|
||||
from flask import request
|
||||
from flask_restx import Resource
|
||||
from pydantic import BaseModel, ValidationError
|
||||
|
||||
from controllers.console.wraps import setup_required
|
||||
from controllers.inner_api import inner_api_ns
|
||||
from controllers.inner_api.plugin.wraps import get_user
|
||||
from controllers.inner_api.wraps import plugin_inner_api_only
|
||||
from extensions.ext_database import db
|
||||
from services.agent_drive_service import (
|
||||
AgentDriveError,
|
||||
AgentDriveService,
|
||||
DriveCommitItem,
|
||||
parse_agent_drive_ref,
|
||||
)
|
||||
|
||||
|
||||
class _CommitRequest(BaseModel):
|
||||
tenant_id: str
|
||||
user_id: str
|
||||
items: list[DriveCommitItem]
|
||||
|
||||
|
||||
def _error_response(exc: AgentDriveError) -> tuple[dict[str, str], int]:
|
||||
return {"code": exc.code, "message": exc.message}, exc.status_code
|
||||
|
||||
|
||||
@inner_api_ns.route("/drive/<string:drive_ref>/manifest")
|
||||
class AgentDriveManifestApi(Resource):
|
||||
@setup_required
|
||||
@plugin_inner_api_only
|
||||
@inner_api_ns.doc("agent_drive_manifest")
|
||||
@inner_api_ns.doc(description="List an agent drive (optionally with download URLs)")
|
||||
def get(self, drive_ref: str):
|
||||
try:
|
||||
agent_id = parse_agent_drive_ref(drive_ref)
|
||||
tenant_id = (request.args.get("tenant_id") or "").strip()
|
||||
if not tenant_id:
|
||||
raise AgentDriveError("missing_tenant_id", "tenant_id is required", status_code=400)
|
||||
include_download_url = (request.args.get("include_download_url") or "").lower() in ("1", "true", "yes")
|
||||
items = AgentDriveService().manifest(
|
||||
tenant_id=tenant_id,
|
||||
agent_id=agent_id,
|
||||
prefix=request.args.get("prefix", ""),
|
||||
include_download_url=include_download_url,
|
||||
session=db.session(),
|
||||
)
|
||||
except AgentDriveError as exc:
|
||||
return _error_response(exc)
|
||||
return {"items": items}
|
||||
|
||||
|
||||
@inner_api_ns.route("/drive/<string:drive_ref>/skills")
|
||||
class AgentDriveSkillsApi(Resource):
|
||||
@setup_required
|
||||
@plugin_inner_api_only
|
||||
@inner_api_ns.doc("agent_drive_skills")
|
||||
@inner_api_ns.doc(description="List the skill catalog of an agent drive")
|
||||
def get(self, drive_ref: str):
|
||||
try:
|
||||
agent_id = parse_agent_drive_ref(drive_ref)
|
||||
tenant_id = (request.args.get("tenant_id") or "").strip()
|
||||
if not tenant_id:
|
||||
raise AgentDriveError("missing_tenant_id", "tenant_id is required", status_code=400)
|
||||
items = AgentDriveService().list_skills(tenant_id=tenant_id, agent_id=agent_id, session=db.session())
|
||||
except AgentDriveError as exc:
|
||||
return _error_response(exc)
|
||||
return {"items": items}
|
||||
|
||||
|
||||
@inner_api_ns.route("/drive/<string:drive_ref>/commit")
|
||||
class AgentDriveCommitApi(Resource):
|
||||
@setup_required
|
||||
@plugin_inner_api_only
|
||||
@inner_api_ns.doc("agent_drive_commit")
|
||||
@inner_api_ns.doc(description="Commit a batch of file refs into an agent drive")
|
||||
def post(self, drive_ref: str):
|
||||
try:
|
||||
agent_id = parse_agent_drive_ref(drive_ref)
|
||||
try:
|
||||
body = _CommitRequest.model_validate(request.get_json(silent=True) or {})
|
||||
except ValidationError as exc:
|
||||
raise AgentDriveError("invalid_request", str(exc), status_code=400) from exc
|
||||
user = get_user(body.tenant_id, body.user_id)
|
||||
items = AgentDriveService().commit(
|
||||
tenant_id=body.tenant_id,
|
||||
user_id=user.id,
|
||||
agent_id=agent_id,
|
||||
items=body.items,
|
||||
session=db.session(),
|
||||
)
|
||||
except AgentDriveError as exc:
|
||||
return _error_response(exc)
|
||||
return {"items": items}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user