Compare commits
240 Commits
perf/lazy-
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e7d9c8897a | ||
|
|
519ef8b8c2 | ||
|
|
0cd3824608 | ||
|
|
869c1dbf07 | ||
|
|
2738bad1fe | ||
|
|
a4cefbe669 | ||
|
|
1f5afb5a48 | ||
|
|
9d285e38eb | ||
|
|
9ce90a4075 | ||
|
|
a5eea69efc | ||
|
|
8260acf04b | ||
|
|
5ea0b59bdd | ||
|
|
0753e6480b | ||
|
|
1aeba1c6bc | ||
|
|
642bfe40eb | ||
|
|
10c5da16f3 | ||
|
|
3b093f881f | ||
|
|
a212b1b0b1 | ||
|
|
8023189566 | ||
|
|
d73c93d2cf | ||
|
|
63d2af7d27 | ||
|
|
81f627cfa1 | ||
|
|
e160419159 | ||
|
|
22cce965f3 | ||
|
|
ea9a3dbb4b | ||
|
|
fd99ce64c9 | ||
|
|
64f028564e | ||
|
|
4214abf28f | ||
|
|
f4027ad6ef | ||
|
|
9d01361c91 | ||
|
|
7d13952e24 | ||
|
|
54486a14c1 | ||
|
|
3be01cbb7a | ||
|
|
de09a17ba9 | ||
|
|
cf957564d8 | ||
|
|
5c0c9dfff0 | ||
|
|
e92d13220f | ||
|
|
70ef9cbd5d | ||
|
|
f0266537cb | ||
|
|
16bbfc50bd | ||
|
|
1cbb6dc385 | ||
|
|
09e372b237 | ||
|
|
c687d0dc6f | ||
|
|
8222397ea9 | ||
|
|
01f632d059 | ||
|
|
98c9a6a23a | ||
|
|
c12c01b8be | ||
|
|
7f4e357418 | ||
|
|
43af5082d3 | ||
|
|
f2755a79c7 | ||
|
|
e6e4cd9406 | ||
|
|
b7bf3f8a29 | ||
|
|
0f611811c2 | ||
|
|
efcf73b4da | ||
|
|
daadf78c02 | ||
|
|
baf6888007 | ||
|
|
4b89972f55 | ||
|
|
a2427050e1 | ||
|
|
f3074ced5b | ||
|
|
1dbf027277 | ||
|
|
9f0e3a4c17 | ||
|
|
a54589a672 | ||
|
|
ba28e3ad82 | ||
|
|
49a4a13846 | ||
|
|
934427ce16 | ||
|
|
f100adf0ea | ||
|
|
63094c91ba | ||
|
|
9f617f9d3f | ||
|
|
30eadcc9e5 | ||
|
|
bf62165e22 | ||
|
|
c797b8e99e | ||
|
|
fa4f49df00 | ||
|
|
6cca398f1c | ||
|
|
b4ae43986c | ||
|
|
c05a1c4e9a | ||
|
|
84cc8ef77a | ||
|
|
f2e13873b5 | ||
|
|
a0c004d877 | ||
|
|
14cd89ded5 | ||
|
|
8055521b49 | ||
|
|
aff59ef19a | ||
|
|
f93bec4ea5 | ||
|
|
087dd1d1ae | ||
|
|
1b86126ab1 | ||
|
|
ca27376211 | ||
|
|
c0fa5c3bcd | ||
|
|
46f32a7d22 | ||
|
|
5c6f46c952 | ||
|
|
e23c02340a | ||
|
|
b3188a299b | ||
|
|
570552c6fa | ||
|
|
636e0f0ace | ||
|
|
fecc3af674 | ||
|
|
0b9b0db408 | ||
|
|
a2b0b2a143 | ||
|
|
0fa381f3e7 | ||
|
|
d28b99ef6d | ||
|
|
38f1ecdd3b | ||
|
|
a0330a756f | ||
|
|
723aeb8b10 | ||
|
|
cba5e5503a | ||
|
|
4c640ff898 | ||
|
|
0bd950fd01 | ||
|
|
f004f6b67d | ||
|
|
50695cbf92 | ||
|
|
23e13a5e93 | ||
|
|
6423a83b78 | ||
|
|
4534c37aa7 | ||
|
|
431c185706 | ||
|
|
5260f0eee6 | ||
|
|
9fa987a550 | ||
|
|
1beefc2b50 | ||
|
|
1a918ea407 | ||
|
|
fac6179309 | ||
|
|
3c17c90bea | ||
|
|
5e3065ec7d | ||
|
|
71e5b353b1 | ||
|
|
9988dcc720 | ||
|
|
95373bc4fd | ||
|
|
6decc78fd5 | ||
|
|
bf41e733ba | ||
|
|
d7600ad9a7 | ||
|
|
52d586a7b4 | ||
|
|
4433d56a88 | ||
|
|
db317bf9a1 | ||
|
|
1642281176 | ||
|
|
8edc40e2f1 | ||
|
|
fe49d123c0 | ||
|
|
5ab572e0a7 | ||
|
|
2cb6431582 | ||
|
|
e48680f32b | ||
|
|
a743a9150f | ||
|
|
2f6b14525e | ||
|
|
df016cd309 | ||
|
|
d5c3fb9ecd | ||
|
|
0effc36f45 | ||
|
|
28697d9587 | ||
|
|
ea38e484b8 | ||
|
|
5bae0bb010 | ||
|
|
51fa4a25f9 | ||
|
|
b4cb02ccb9 | ||
|
|
77ec1b2027 | ||
|
|
68726eb097 | ||
|
|
b4dd2c4886 | ||
|
|
cf02d52d0d | ||
|
|
a4524c215f | ||
|
|
96abb8ea22 | ||
|
|
216151ffa7 | ||
|
|
7b0660b45b | ||
|
|
a4f949f173 | ||
|
|
0ed392fb62 | ||
|
|
3f0032f20b | ||
|
|
bfd5636bf0 | ||
|
|
f79ed842fa | ||
|
|
a67cd87eb0 | ||
|
|
c005932db9 | ||
|
|
af7f6abb95 | ||
|
|
2a1211baa5 | ||
|
|
3813c4d552 | ||
|
|
8fc8178ff7 | ||
|
|
fb0e55f5e7 | ||
|
|
fb9b97b26d | ||
|
|
80b34b5ab6 | ||
|
|
8db1712ccd | ||
|
|
6dc9de24d8 | ||
|
|
9c11e36cc5 | ||
|
|
1d02154638 | ||
|
|
b4bc25cab6 | ||
|
|
948fd166dd | ||
|
|
38f42f357e | ||
|
|
f24586323b | ||
|
|
bc0279572e | ||
|
|
20d9f15933 | ||
|
|
d42276a5c0 | ||
|
|
5c4489911c | ||
|
|
657624154d | ||
|
|
be8744264f | ||
|
|
0fec636da5 | ||
|
|
ce27ef9dc3 | ||
|
|
f7e5946d81 | ||
|
|
90f7fbd468 | ||
|
|
1545517fe8 | ||
|
|
1b9ddf9ae5 | ||
|
|
9bb9935f44 | ||
|
|
1ff3d3db68 | ||
|
|
18fea7ff0d | ||
|
|
357f697b16 | ||
|
|
e6f1d77ed5 | ||
|
|
4f8bf32b72 | ||
|
|
d8187b6e51 | ||
|
|
51940eac9c | ||
|
|
005f019a80 | ||
|
|
5f6bb5e6d3 | ||
|
|
7020120197 | ||
|
|
fe5dfd756f | ||
|
|
4808976d0c | ||
|
|
1b21b8220d | ||
|
|
bf55d1f6a6 | ||
|
|
855842410a | ||
|
|
33b2f09325 | ||
|
|
4713c3853b | ||
|
|
89e6b71b2d | ||
|
|
27cdf50c7d | ||
|
|
2b88685fbd | ||
|
|
52dcf83de6 | ||
|
|
a4a18e0652 | ||
|
|
6441436b0c | ||
|
|
0c5f2507d0 | ||
|
|
2515cf5b48 | ||
|
|
5283462286 | ||
|
|
f95edc85f5 | ||
|
|
f3107fee3f | ||
|
|
c42ee2fbac | ||
|
|
fc5053edf0 | ||
|
|
d8ec14027e | ||
|
|
96f444e3c6 | ||
|
|
9d4629c946 | ||
|
|
11f6a350f1 | ||
|
|
1366cadbb4 | ||
|
|
174a908786 | ||
|
|
2062d5e892 | ||
|
|
fb7b00365c | ||
|
|
1c535791cb | ||
|
|
090f7d923c | ||
|
|
3dce05e09e | ||
|
|
a154a4d674 | ||
|
|
934662cc48 | ||
|
|
b470071c4c | ||
|
|
d6c96ce1a5 | ||
|
|
60744157d8 | ||
|
|
43f64090e1 | ||
|
|
12f65f887a | ||
|
|
725611b2e9 | ||
|
|
e6fa1a1dd0 | ||
|
|
414cce2f70 | ||
|
|
7a7dc6011a | ||
|
|
3960b57102 | ||
|
|
698ecd3e62 | ||
|
|
f4602cc1fe | ||
|
|
2d813b2a87 |
@@ -1,93 +1,15 @@
|
||||
# Rule Catalog — Architecture
|
||||
# Architecture review
|
||||
|
||||
## Scope
|
||||
- Covers: controller/service/core-domain/libs/model layering, dependency direction, responsibility placement, observability-friendly flow.
|
||||
The [API agent guide](../../../../api/AGENTS.md) owns package boundaries. In particular, `api/core/` is migration-only: do not propose new files or extracted implementations there.
|
||||
|
||||
## Rules
|
||||
## Transport and domain boundaries
|
||||
|
||||
### Keep business logic out of controllers
|
||||
- Category: maintainability
|
||||
- Description: Controllers should parse input, call services, and return serialized responses. Business decisions inside controllers make behavior hard to reuse and test.
|
||||
- Suggested fix: Move domain/business logic into the service or core/domain layer. Keep controller handlers thin and orchestration-focused.
|
||||
- Example:
|
||||
- Bad:
|
||||
```python
|
||||
@bp.post("/apps/<app_id>/publish")
|
||||
def publish_app(app_id: str):
|
||||
payload = request.get_json() or {}
|
||||
if payload.get("force") and current_user.role != "admin":
|
||||
raise ValueError("only admin can force publish")
|
||||
app = App.query.get(app_id)
|
||||
app.status = "published"
|
||||
db.session.commit()
|
||||
return {"result": "ok"}
|
||||
```
|
||||
- Good:
|
||||
```python
|
||||
@bp.post("/apps/<app_id>/publish")
|
||||
def publish_app(app_id: str):
|
||||
payload = PublishRequest.model_validate(request.get_json() or {})
|
||||
app_service.publish_app(app_id=app_id, force=payload.force, actor_id=current_user.id)
|
||||
return {"result": "ok"}
|
||||
```
|
||||
Controllers own request parsing, service calls, and response serialization. Trace business decisions to their service or domain owner; flag controller policy when it duplicates or bypasses that owner. Move extracted behavior to an appropriate owner outside `core/`.
|
||||
|
||||
### Preserve layer dependency direction
|
||||
- Category: best practices
|
||||
- Description: Controllers may depend on services, and services may depend on core/domain abstractions. Reversing this direction (for example, core importing controller/web modules) creates cycles and leaks transport concerns into domain code.
|
||||
- Suggested fix: Extract shared contracts into core/domain or service-level modules and make upper layers depend on lower, not the reverse.
|
||||
- Example:
|
||||
- Bad:
|
||||
```python
|
||||
# core/policy/publish_policy.py
|
||||
from controllers.console.app import request_context
|
||||
## Dependency direction
|
||||
|
||||
Transport layers may depend on services and domain contracts. Domain code must not import controller or request context. Pass validated actor, tenant, and resource data explicitly instead of reaching upward for it. Check existing owners and import-linter contracts before proposing a shared abstraction.
|
||||
|
||||
def can_publish() -> bool:
|
||||
return request_context.current_user.is_admin
|
||||
```
|
||||
- Good:
|
||||
```python
|
||||
# core/policy/publish_policy.py
|
||||
def can_publish(role: str) -> bool:
|
||||
return role == "admin"
|
||||
## Business-agnostic libraries
|
||||
|
||||
|
||||
# service layer adapts web/user context to domain input
|
||||
allowed = can_publish(role=current_user.role)
|
||||
```
|
||||
|
||||
### Keep libs business-agnostic
|
||||
- Category: maintainability
|
||||
- Description: Modules under `api/libs/` should remain reusable, business-agnostic building blocks. They must not encode product/domain-specific rules, workflow orchestration, or business decisions.
|
||||
- Suggested fix:
|
||||
- If business logic appears in `api/libs/`, extract it into the appropriate `services/` or `core/` module and keep `libs` focused on generic, cross-cutting helpers.
|
||||
- Keep `libs` dependencies clean: avoid importing service/controller/domain-specific modules into `api/libs/`.
|
||||
- Example:
|
||||
- Bad:
|
||||
```python
|
||||
# api/libs/conversation_filter.py
|
||||
from services.conversation_service import ConversationService
|
||||
|
||||
|
||||
def should_archive_conversation(conversation, tenant_id: str) -> bool:
|
||||
# Domain policy and service dependency are leaking into libs.
|
||||
service = ConversationService()
|
||||
if service.has_paid_plan(tenant_id):
|
||||
return conversation.idle_days > 90
|
||||
return conversation.idle_days > 30
|
||||
```
|
||||
- Good:
|
||||
```python
|
||||
# api/libs/datetime_utils.py (business-agnostic helper)
|
||||
def older_than_days(idle_days: int, threshold_days: int) -> bool:
|
||||
return idle_days > threshold_days
|
||||
|
||||
|
||||
# services/conversation_service.py (business logic stays in service/core)
|
||||
from libs.datetime_utils import older_than_days
|
||||
|
||||
|
||||
def should_archive_conversation(conversation, tenant_id: str) -> bool:
|
||||
threshold_days = 90 if has_paid_plan(tenant_id) else 30
|
||||
return older_than_days(conversation.idle_days, threshold_days)
|
||||
```
|
||||
`api/libs/` must not own product policy or orchestration. Flag service/controller dependencies and domain-specific decisions in library helpers. Move that behavior to its existing service or domain owner outside `core/`; retain only reusable infrastructure in `libs/`.
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
- Description: If a table/model already has a repository abstraction, all reads/writes/queries for that table should use the existing repository. If no repository exists, introduce one only when complexity justifies it, such as large/high-volume tables, repeated complex query logic, or likely storage-strategy variation.
|
||||
- Suggested fix:
|
||||
- First check `api/repositories`, `api/core/repositories`, and `api/extensions/*/repositories/` to verify whether the table/model already has a repository abstraction. If it exists, route all operations through it and add missing repository methods instead of bypassing it with ad-hoc SQLAlchemy access.
|
||||
- If no repository exists, add one only when complexity warrants it (for example, repeated complex queries, large data domains, or multiple storage strategies), while preserving dependency direction (service/core depends on abstraction; infra provides implementation).
|
||||
- If no repository exists, add one only when complexity warrants it (for example, repeated complex queries, large data domains, or multiple storage strategies). Follow `api/AGENTS.md`: put new abstractions and implementations outside migration-only `api/core/`, with services/domain code depending on abstractions and infrastructure providing implementations.
|
||||
- Example:
|
||||
- Bad:
|
||||
```python
|
||||
|
||||
@@ -10,13 +10,8 @@ Read this document when a change involves application hotkeys, focus, dialogs, m
|
||||
|
||||
## Keyboard Commands
|
||||
|
||||
- Distinguish application commands from widget-local keyboard semantics. Use `@tanstack/react-hotkeys` for application commands; keep menu navigation, dialog Escape handling, editor behavior, and ARIA widget keys in their local primitive or owner.
|
||||
- Use `useHotkey` or `useHotkeys` for registered commands. When an existing `onKeyDown` intentionally owns the command, use `matchesKeyboardEvent` rather than duplicating modifier parsing or adding another global listener.
|
||||
- Keep registration and keycap or menu display derived from one canonical command. Distinguish registered commands, held keys, and display-only accelerators.
|
||||
- Keep a single-owner command beside its component. Create a feature-local hotkey module only when several production files share it; tests alone do not justify extraction.
|
||||
- Make availability and scope explicit with `enabled`, `ignoreInputs`, and `target`. Put a target ref on the actual behavior owner rather than creating wrapper DOM solely for hotkey scope.
|
||||
- Preserve existing `preventDefault` and propagation behavior when migrating command APIs.
|
||||
- Test observable command behavior, disabled and input scope, target scope, and the registration/display contract at the owning feature boundary.
|
||||
- Keep widget navigation, dialog dismissal, and editor commands in their local primitive or owner.
|
||||
- For Web application commands, follow [Keyboard commands] for TanStack registration, typed bindings, targets, event consumption, display, and verification. Do not recreate that policy in a wrapper or a second command framework.
|
||||
|
||||
## Secondary Surfaces
|
||||
|
||||
@@ -28,4 +23,5 @@ Read this document when a change involves application hotkeys, focus, dialogs, m
|
||||
- Prefer primitive-owned open state unless another owner must observe or coordinate it. Analytics callbacks and local cleanup alone do not require a controlled root.
|
||||
|
||||
[overlay contract]: ../../../../packages/dify-ui/docs/overlays.md
|
||||
[Keyboard commands]: ../../../../web/docs/hotkeys.md
|
||||
[state ownership]: state.md
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Development with devcontainer
|
||||
|
||||
This project includes a devcontainer configuration that allows you to open the project in a container with a fully configured development environment.
|
||||
Both frontend and backend environments are initialized when the container is started.
|
||||
The create hook installs workspace JavaScript dependencies and `uv`; the start hook synchronizes backend dependencies. Follow the [backend setup](../api/README.md) and [frontend guide](../web/README.md) to configure environment files and start services.
|
||||
|
||||
## GitHub Codespaces
|
||||
|
||||
@@ -19,20 +19,6 @@ if you have VS Code installed, you can click the button above to open this proje
|
||||
|
||||
You can learn more in the [Dev Containers documentation](https://code.visualstudio.com/docs/devcontainers/containers).
|
||||
|
||||
## Pros of Devcontainer
|
||||
|
||||
Unified Development Environment: By using devcontainers, you can ensure that all developers are developing in the same environment, reducing the occurrence of "it works on my machine" type of issues.
|
||||
|
||||
Quick Start: New developers can set up their development environment in a few simple steps, without spending a lot of time on environment configuration.
|
||||
|
||||
Isolation: Devcontainers isolate your project from your host operating system, reducing the chance of OS updates or other application installations impacting the development environment.
|
||||
|
||||
## Cons of Devcontainer
|
||||
|
||||
Learning Curve: For developers unfamiliar with Docker and VS Code, using devcontainers may be somewhat complex.
|
||||
|
||||
Performance Impact: While usually minimal, programs running inside a devcontainer may be slightly slower than those running directly on the host.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
if you see such error message when you open this project in codespaces:
|
||||
|
||||
3
.gitattributes
vendored
3
.gitattributes
vendored
@@ -9,3 +9,6 @@
|
||||
# Codegen output must stay byte-identical across platforms so
|
||||
# `pnpm tree:check` in CI does not trip on CRLF rewrites.
|
||||
*.generated.ts text eol=lf
|
||||
|
||||
# Skill scripts run on every platform, so keep LF line endings.
|
||||
/skills/** text=auto eol=lf
|
||||
|
||||
5
.github/CODEOWNERS
vendored
5
.github/CODEOWNERS
vendored
@@ -57,13 +57,10 @@
|
||||
|
||||
# Backend - RAG (Retrieval Augmented Generation)
|
||||
/api/core/rag/ @JohnJyong
|
||||
/api/services/knowledge/ @JohnJyong
|
||||
/api/services/rag_pipeline/ @JohnJyong
|
||||
/api/services/dataset_service.py @JohnJyong
|
||||
/api/services/knowledge_service.py @JohnJyong
|
||||
/api/services/external_knowledge_service.py @JohnJyong
|
||||
/api/services/hit_testing_service.py @JohnJyong
|
||||
/api/services/metadata_service.py @JohnJyong
|
||||
/api/services/vector_service.py @JohnJyong
|
||||
/api/services/entities/knowledge_entities/ @JohnJyong
|
||||
/api/services/entities/external_knowledge_entities/ @JohnJyong
|
||||
/api/controllers/console/datasets/ @JohnJyong
|
||||
|
||||
2
.github/actions/setup-core-e2e/action.yml
vendored
2
.github/actions/setup-core-e2e/action.yml
vendored
@@ -15,7 +15,7 @@ runs:
|
||||
run-install: 'false'
|
||||
|
||||
- name: Setup UV and Python
|
||||
uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
|
||||
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
|
||||
with:
|
||||
enable-cache: true
|
||||
python-version: '3.12'
|
||||
|
||||
4
.github/actions/setup-web/action.yml
vendored
4
.github/actions/setup-web/action.yml
vendored
@@ -13,11 +13,11 @@ runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Setup pnpm and Node.js
|
||||
uses: pnpm/setup@703c52620218391530e48b9e8870d5c0082e1b9b # v2.1.0
|
||||
uses: pnpm/setup@fbda4c85fc2e1e08721cd8763afea8f48d60f024 # v3.0.0
|
||||
with:
|
||||
install: false
|
||||
- name: Setup Vite+
|
||||
uses: voidzero-dev/setup-vp@9fd26ff49f9b5e6276c2b493da4454ad4527f7f4 # v1.20.0
|
||||
uses: voidzero-dev/setup-vp@3754dd7dbdb32bd8f6d28b6043de13ad3a75f21f # v1.21.1
|
||||
with:
|
||||
node-manager: false
|
||||
package-manager: |
|
||||
|
||||
104
.github/actions/wait-e2e-web-build/download.cjs
vendored
Normal file
104
.github/actions/wait-e2e-web-build/download.cjs
vendored
Normal file
@@ -0,0 +1,104 @@
|
||||
const { execFile } = require('node:child_process')
|
||||
const { appendFile, mkdtemp, rm } = require('node:fs/promises')
|
||||
const os = require('node:os')
|
||||
const path = require('node:path')
|
||||
const { promisify } = require('node:util')
|
||||
const waitForBuild = require('./wait.cjs')
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
|
||||
async function downloadWebBuild({
|
||||
env = process.env,
|
||||
execute = execFileAsync,
|
||||
webDir = path.resolve(__dirname, '../../../web'),
|
||||
} = {}) {
|
||||
const repository = env.GITHUB_REPOSITORY
|
||||
const runId = env.GITHUB_RUN_ID
|
||||
const runAttempt = Number(env.GITHUB_RUN_ATTEMPT)
|
||||
if (
|
||||
!repository ||
|
||||
!/^\d+$/.test(runId || '') ||
|
||||
!Number.isInteger(runAttempt) ||
|
||||
runAttempt < 1 ||
|
||||
!env.GH_TOKEN
|
||||
) {
|
||||
throw new Error(
|
||||
'Downloading the Web build requires GITHUB_REPOSITORY, GITHUB_RUN_ID, GITHUB_RUN_ATTEMPT, and GH_TOKEN.',
|
||||
)
|
||||
}
|
||||
|
||||
const run = (command, args, timeout = 60_000) =>
|
||||
execute(command, args, {
|
||||
env,
|
||||
timeout,
|
||||
maxBuffer: 32 * 1024 * 1024,
|
||||
})
|
||||
const api = async (endpoint, paginate = false) => {
|
||||
const { stdout } = await run('gh', [
|
||||
'api',
|
||||
endpoint,
|
||||
...(paginate ? ['--paginate', '--slurp'] : []),
|
||||
])
|
||||
return JSON.parse(stdout)
|
||||
}
|
||||
const prefix = `repos/${repository}/actions`
|
||||
let summary = ''
|
||||
const artifactId = await waitForBuild({
|
||||
github: {
|
||||
rest: {
|
||||
actions: { listJobsForWorkflowRunAttempt: 'jobs', listWorkflowRunArtifacts: 'artifacts' },
|
||||
},
|
||||
paginate: async (kind) => {
|
||||
const endpoint =
|
||||
kind === 'jobs'
|
||||
? `${prefix}/runs/${runId}/attempts/${runAttempt}/jobs?per_page=100`
|
||||
: `${prefix}/runs/${runId}/artifacts?per_page=100`
|
||||
const pages = await api(endpoint, true)
|
||||
return pages.flatMap((page) => page[kind])
|
||||
},
|
||||
},
|
||||
context: { repo: {}, runId },
|
||||
core: {
|
||||
info: console.log,
|
||||
summary: {
|
||||
addHeading(text) {
|
||||
summary += `### ${text}\n\n`
|
||||
return this
|
||||
},
|
||||
addRaw(text) {
|
||||
summary += `${text}\n`
|
||||
return this
|
||||
},
|
||||
async write() {
|
||||
if (env.GITHUB_STEP_SUMMARY) await appendFile(env.GITHUB_STEP_SUMMARY, summary)
|
||||
},
|
||||
},
|
||||
},
|
||||
runAttempt,
|
||||
})
|
||||
|
||||
const artifact = await api(`${prefix}/artifacts/${artifactId}`)
|
||||
const directory = await mkdtemp(path.join(os.tmpdir(), 'dify-e2e-web-build-'))
|
||||
try {
|
||||
// Artifact names are immutable within a run. Use the name selected by wait.cjs,
|
||||
// including its matching upload timestamp when only consumer jobs are rerun.
|
||||
await run(
|
||||
'gh',
|
||||
['run', 'download', runId, '--repo', repository, '--name', artifact.name, '--dir', directory],
|
||||
300_000,
|
||||
)
|
||||
await run('tar', ['-xf', path.join(directory, 'e2e-web-build.tar'), '-C', webDir])
|
||||
console.log(`Web build artifact ${artifactId} extracted and ready.`)
|
||||
} finally {
|
||||
await rm(directory, { force: true, recursive: true })
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = downloadWebBuild
|
||||
|
||||
if (require.main === module) {
|
||||
downloadWebBuild().catch((error) => {
|
||||
console.error(error)
|
||||
process.exitCode = 1
|
||||
})
|
||||
}
|
||||
112
.github/actions/wait-e2e-web-build/download.test.cjs
vendored
Normal file
112
.github/actions/wait-e2e-web-build/download.test.cjs
vendored
Normal file
@@ -0,0 +1,112 @@
|
||||
const assert = require('node:assert/strict')
|
||||
const { access } = require('node:fs/promises')
|
||||
const { test } = require('node:test')
|
||||
const downloadWebBuild = require('./download.cjs')
|
||||
|
||||
function fixture({ downloadFails = false, producerFails = false } = {}) {
|
||||
const calls = []
|
||||
const env = {
|
||||
GITHUB_REPOSITORY: 'owner/repo',
|
||||
GITHUB_RUN_ID: '123',
|
||||
GITHUB_RUN_ATTEMPT: '4',
|
||||
GH_TOKEN: 'test-token',
|
||||
}
|
||||
const execute = async (command, args) => {
|
||||
calls.push({ command, args })
|
||||
if (command === 'tar') return { stdout: '' }
|
||||
if (args[0] === 'run') {
|
||||
if (downloadFails) throw new Error('artifact download failed')
|
||||
return { stdout: '' }
|
||||
}
|
||||
if (args[1].includes('/jobs?')) {
|
||||
return {
|
||||
stdout: JSON.stringify([
|
||||
{
|
||||
jobs: [
|
||||
{
|
||||
name: 'Run Web Full-Stack E2E / Prepare Core E2E Web Build',
|
||||
status: 'completed',
|
||||
conclusion: producerFails ? 'failure' : 'success',
|
||||
steps: [
|
||||
{
|
||||
name: 'Upload Web build',
|
||||
conclusion: 'success',
|
||||
started_at: '2026-09-22T04:08:08Z',
|
||||
completed_at: '2026-09-22T04:08:12Z',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
]),
|
||||
}
|
||||
}
|
||||
if (args[1].includes('/artifacts?')) {
|
||||
return {
|
||||
stdout: JSON.stringify([
|
||||
{ artifacts: [] },
|
||||
{
|
||||
artifacts: [
|
||||
{
|
||||
id: 42,
|
||||
name: 'core-e2e-web-build-2',
|
||||
created_at: '2026-09-22T04:08:11Z',
|
||||
expired: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
]),
|
||||
}
|
||||
}
|
||||
assert.equal(args[1], 'repos/owner/repo/actions/artifacts/42')
|
||||
return { stdout: JSON.stringify({ name: 'core-e2e-web-build-2' }) }
|
||||
}
|
||||
return { calls, options: { env, execute, webDir: '/test/web' } }
|
||||
}
|
||||
|
||||
test('downloads the producer-selected artifact on consumer-only reruns, including paginated results', async () => {
|
||||
const { calls, options } = fixture()
|
||||
await downloadWebBuild(options)
|
||||
assert.match(calls[0].args[1], /runs\/123\/attempts\/4\/jobs/)
|
||||
const download = calls.find(({ args }) => args[0] === 'run')
|
||||
assert.deepEqual(download.args.slice(0, 7), [
|
||||
'run',
|
||||
'download',
|
||||
'123',
|
||||
'--repo',
|
||||
'owner/repo',
|
||||
'--name',
|
||||
'core-e2e-web-build-2',
|
||||
])
|
||||
const extract = calls.at(-1)
|
||||
assert.equal(extract.command, 'tar')
|
||||
assert.deepEqual(extract.args.slice(-2), ['-C', '/test/web'])
|
||||
await assert.rejects(access(download.args.at(-1)), { code: 'ENOENT' })
|
||||
})
|
||||
|
||||
test('does not extract a failed download and removes its temporary directory', async () => {
|
||||
const { calls, options } = fixture({ downloadFails: true })
|
||||
await assert.rejects(downloadWebBuild(options), /artifact download failed/)
|
||||
assert.equal(
|
||||
calls.some(({ command }) => command === 'tar'),
|
||||
false,
|
||||
)
|
||||
await assert.rejects(access(calls.at(-1).args.at(-1)), { code: 'ENOENT' })
|
||||
})
|
||||
|
||||
test('does not download or extract when the producer fails', async () => {
|
||||
const { calls, options } = fixture({ producerFails: true })
|
||||
await assert.rejects(downloadWebBuild(options), /Web build finished with failure/)
|
||||
assert.equal(calls.length, 1)
|
||||
})
|
||||
|
||||
test('requires CI metadata and credentials before starting preparation', async () => {
|
||||
const { calls, options } = fixture()
|
||||
for (const key of Object.keys(options.env)) {
|
||||
await assert.rejects(
|
||||
downloadWebBuild({ ...options, env: { ...options.env, [key]: '' } }),
|
||||
/requires GITHUB/,
|
||||
)
|
||||
}
|
||||
assert.equal(calls.length, 0)
|
||||
})
|
||||
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@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
|
||||
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
|
||||
with:
|
||||
enable-cache: true
|
||||
python-version: '3.12'
|
||||
|
||||
16
.github/workflows/api-tests.yml
vendored
16
.github/workflows/api-tests.yml
vendored
@@ -23,7 +23,7 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Setup UV and Python
|
||||
uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
|
||||
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
|
||||
with:
|
||||
python-version: '3.12'
|
||||
enable-cache: false
|
||||
@@ -86,7 +86,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup UV and Python
|
||||
uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
|
||||
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
|
||||
with:
|
||||
enable-cache: true
|
||||
python-version: ${{ matrix.python-version }}
|
||||
@@ -117,7 +117,7 @@ jobs:
|
||||
-p no:benchmark \
|
||||
--timeout "${PYTEST_TIMEOUT:-20}" \
|
||||
-n auto \
|
||||
-o 'addopts=--cov=./api --cov-branch --import-mode=importlib' \
|
||||
-o 'addopts=-p no:opik -p no:langsmith_plugin --cov=./api --cov-branch --import-mode=importlib' \
|
||||
--cov-report= \
|
||||
--file-shard-plan unit-test-plan.json \
|
||||
--shard-index "${{ matrix.shardIndex }}" \
|
||||
@@ -164,7 +164,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup UV and Python
|
||||
uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
|
||||
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
|
||||
with:
|
||||
enable-cache: true
|
||||
python-version: '3.12'
|
||||
@@ -181,7 +181,7 @@ jobs:
|
||||
run: |
|
||||
uv run --project api pytest \
|
||||
--timeout "${PYTEST_TIMEOUT:-20}" \
|
||||
-o 'addopts=--cov=./api --cov-branch --import-mode=importlib' \
|
||||
-o 'addopts=-p no:opik -p no:langsmith_plugin --cov=./api --cov-branch --import-mode=importlib' \
|
||||
--cov-report= \
|
||||
--junitxml=test-results/controllers.xml \
|
||||
api/tests/unit_tests/controllers
|
||||
@@ -227,7 +227,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup UV and Python
|
||||
uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
|
||||
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
|
||||
with:
|
||||
enable-cache: true
|
||||
python-version: ${{ matrix.python-version }}
|
||||
@@ -246,7 +246,7 @@ jobs:
|
||||
--start-middleware \
|
||||
--middleware-stop-timeout 2 \
|
||||
-n auto \
|
||||
-o 'addopts=--cov=./api --cov-branch --import-mode=importlib' \
|
||||
-o 'addopts=-p no:opik -p no:langsmith_plugin --cov=./api --cov-branch --import-mode=importlib' \
|
||||
--cov-report= \
|
||||
--timeout "${PYTEST_TIMEOUT:-180}" \
|
||||
api/tests/integration_tests/workflow \
|
||||
@@ -282,7 +282,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup UV and Python
|
||||
uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
|
||||
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
|
||||
with:
|
||||
python-version: '3.12'
|
||||
|
||||
|
||||
2
.github/workflows/autofix.yml
vendored
2
.github/workflows/autofix.yml
vendored
@@ -64,7 +64,7 @@ jobs:
|
||||
python-version: '3.11'
|
||||
|
||||
- if: github.event_name != 'merge_group' && (steps.api-changes.outputs.any_changed == 'true' || steps.frontend-contract-changes.outputs.any_changed == 'true' || steps.dify-agent-changes.outputs.any_changed == 'true')
|
||||
uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
|
||||
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
|
||||
|
||||
- name: Generate Docker Compose
|
||||
if: github.event_name != 'merge_group' && steps.docker-compose-changes.outputs.any_changed == 'true'
|
||||
|
||||
18
.github/workflows/cli-e2e.yml
vendored
18
.github/workflows/cli-e2e.yml
vendored
@@ -78,7 +78,7 @@ jobs:
|
||||
ws2_app_id: ${{ steps.out.outputs.DIFY_E2E_WS2_APP_ID }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v4
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ inputs.cli_ref || github.ref }}
|
||||
persist-credentials: false
|
||||
@@ -87,7 +87,7 @@ jobs:
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- uses: pnpm/setup@703c52620218391530e48b9e8870d5c0082e1b9b # v2.1.0
|
||||
- uses: pnpm/setup@fbda4c85fc2e1e08721cd8763afea8f48d60f024 # v3.0.0
|
||||
with:
|
||||
install: false
|
||||
|
||||
@@ -121,7 +121,7 @@ jobs:
|
||||
shell: bash
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v4
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ inputs.cli_ref || github.ref }}
|
||||
persist-credentials: false
|
||||
@@ -166,7 +166,7 @@ jobs:
|
||||
shell: bash
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v4
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ inputs.cli_ref || github.ref }}
|
||||
persist-credentials: false
|
||||
@@ -227,7 +227,7 @@ jobs:
|
||||
shell: bash
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v4
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ inputs.cli_ref || github.ref }}
|
||||
persist-credentials: false
|
||||
@@ -266,7 +266,7 @@ jobs:
|
||||
|
||||
- name: Upload results on failure
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: e2e-run-${{ matrix.name }}-${{ github.run_id }}
|
||||
path: cli/test-results/
|
||||
@@ -287,7 +287,7 @@ jobs:
|
||||
shell: bash
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v4
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ inputs.cli_ref || github.ref }}
|
||||
persist-credentials: false
|
||||
@@ -341,7 +341,7 @@ jobs:
|
||||
shell: bash
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v4
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ inputs.cli_ref || github.ref }}
|
||||
persist-credentials: false
|
||||
@@ -396,7 +396,7 @@ jobs:
|
||||
|
||||
- name: Upload results on failure
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: e2e-last-${{ github.run_id }}
|
||||
path: cli/test-results/
|
||||
|
||||
1
.github/workflows/cli-edge.yml
vendored
1
.github/workflows/cli-edge.yml
vendored
@@ -5,6 +5,7 @@ on:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'cli/**'
|
||||
- 'skills/**'
|
||||
- 'packages/contracts/generated/api/openapi/**'
|
||||
workflow_dispatch:
|
||||
|
||||
|
||||
5
.github/workflows/cli-release.yml
vendored
5
.github/workflows/cli-release.yml
vendored
@@ -77,11 +77,6 @@ jobs:
|
||||
echo "dify_tag=${tag}" >> "$GITHUB_OUTPUT"
|
||||
echo "::notice::target Dify release ${tag}"
|
||||
|
||||
- name: Compatibility check
|
||||
env:
|
||||
DIFY_TAG: ${{ steps.resolve.outputs.dify_tag }}
|
||||
run: node scripts/release-naming.mjs compat-check "$DIFY_TAG"
|
||||
|
||||
release:
|
||||
name: build + attach standalone binaries (all targets)
|
||||
needs: validate
|
||||
|
||||
2
.github/workflows/cli-smoke.yml
vendored
2
.github/workflows/cli-smoke.yml
vendored
@@ -51,7 +51,7 @@ jobs:
|
||||
|
||||
- name: Run smoke against live dify
|
||||
working-directory: ./cli
|
||||
run: pnpm exec node scripts/run-smoke.ts --base-url http://localhost:5001
|
||||
run: DIFY_SERVER=http://localhost:5001 bun scripts/run-smoke.ts
|
||||
|
||||
- name: Dump dify logs on failure
|
||||
if: failure()
|
||||
|
||||
4
.github/workflows/db-migration-test.yml
vendored
4
.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@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
|
||||
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
|
||||
with:
|
||||
enable-cache: true
|
||||
python-version: '3.12'
|
||||
@@ -69,7 +69,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup UV and Python
|
||||
uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
|
||||
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
|
||||
with:
|
||||
enable-cache: true
|
||||
python-version: '3.12'
|
||||
|
||||
2
.github/workflows/dify-agent-tests.yml
vendored
2
.github/workflows/dify-agent-tests.yml
vendored
@@ -31,7 +31,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup UV and Python
|
||||
uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
|
||||
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
|
||||
with:
|
||||
enable-cache: true
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
225
.github/workflows/main-ci.yml
vendored
225
.github/workflows/main-ci.yml
vendored
@@ -21,9 +21,10 @@ concurrency:
|
||||
cancel-in-progress: ${{ github.event_name == 'merge_group' }}
|
||||
|
||||
jobs:
|
||||
# Keep orchestration independent of the Depot pool used for test execution.
|
||||
pre_job:
|
||||
name: Skip Duplicate Checks
|
||||
runs-on: depot-ubuntu-24.04
|
||||
runs-on: ubuntu-24.04
|
||||
outputs:
|
||||
should_skip: ${{ steps.skip_check.outputs.should_skip || 'false' }}
|
||||
steps:
|
||||
@@ -37,7 +38,7 @@ jobs:
|
||||
# Check which paths were changed to determine which tests to run
|
||||
check-changes:
|
||||
name: Check Changed Files
|
||||
runs-on: depot-ubuntu-24.04
|
||||
runs-on: ubuntu-24.04
|
||||
outputs:
|
||||
api-changed: ${{ steps.changes.outputs.api }}
|
||||
cli-changed: ${{ steps.changes.outputs.cli }}
|
||||
@@ -62,10 +63,13 @@ jobs:
|
||||
- 'api/**'
|
||||
- 'scripts/ast_grep_guard.py'
|
||||
- 'scripts/check_no_new_getattr.py'
|
||||
- 'scripts/check_no_spec_mock.py'
|
||||
- 'scripts/check_no_new_controller_sqlalchemy.py'
|
||||
- 'scripts/lint_controller_sqlalchemy.py'
|
||||
- 'scripts/ast_grep_rules/no_new_getattr.yml'
|
||||
- 'scripts/ast_grep_rules/no_spec_mock.yml'
|
||||
- 'scripts/ast_grep_rules/no_new_controller_sqlalchemy.yml'
|
||||
- 'scripts/ast_grep_rules/no_new_session_mock.yml'
|
||||
- '.github/workflows/style.yml'
|
||||
- '.github/workflows/main-ci.yml'
|
||||
- '.github/workflows/api-tests.yml'
|
||||
@@ -78,6 +82,7 @@ jobs:
|
||||
- 'docker/volumes/sandbox/conf/**'
|
||||
cli:
|
||||
- 'cli/**'
|
||||
- 'skills/**'
|
||||
- 'packages/contracts/**'
|
||||
- 'packages/tsconfig/**'
|
||||
- 'package.json'
|
||||
@@ -159,10 +164,14 @@ jobs:
|
||||
- 'api/**'
|
||||
- 'scripts/ast_grep_guard.py'
|
||||
- 'scripts/check_no_new_getattr.py'
|
||||
- 'scripts/check_no_spec_mock.py'
|
||||
- 'scripts/check_no_new_controller_sqlalchemy.py'
|
||||
- 'scripts/check_no_new_session_mock.py'
|
||||
- 'scripts/lint_controller_sqlalchemy.py'
|
||||
- 'scripts/ast_grep_rules/no_new_getattr.yml'
|
||||
- 'scripts/ast_grep_rules/no_spec_mock.yml'
|
||||
- 'scripts/ast_grep_rules/no_new_controller_sqlalchemy.yml'
|
||||
- 'scripts/ast_grep_rules/no_new_session_mock.yml'
|
||||
- '.github/workflows/style.yml'
|
||||
- '.github/workflows/main-ci.yml'
|
||||
dify-agent-style:
|
||||
@@ -187,6 +196,7 @@ jobs:
|
||||
ts-common-style:
|
||||
- 'web/**'
|
||||
- 'cli/**'
|
||||
- 'skills/**'
|
||||
- 'e2e/**'
|
||||
- 'sdks/nodejs-client/**'
|
||||
- 'packages/**'
|
||||
@@ -212,7 +222,8 @@ jobs:
|
||||
- '.editorconfig'
|
||||
- '.vite-hooks/**'
|
||||
|
||||
# Run tests in parallel while always emitting stable required checks.
|
||||
# Finalizers report unchanged paths directly, avoiding separate skip-only jobs.
|
||||
# Keep each required check name stable and fail closed if classification fails.
|
||||
api-tests-run:
|
||||
name: Run API Tests
|
||||
needs:
|
||||
@@ -222,17 +233,6 @@ jobs:
|
||||
uses: ./.github/workflows/api-tests.yml
|
||||
secrets: inherit
|
||||
|
||||
api-tests-skip:
|
||||
name: Skip API Tests
|
||||
needs:
|
||||
- pre_job
|
||||
- check-changes
|
||||
if: needs.pre_job.outputs.should_skip != 'true' && needs.check-changes.outputs.api-changed != 'true'
|
||||
runs-on: depot-ubuntu-24.04
|
||||
steps:
|
||||
- name: Report skipped API tests
|
||||
run: echo "No API-related changes detected; skipping API tests."
|
||||
|
||||
api-tests:
|
||||
name: API Tests
|
||||
if: ${{ always() }}
|
||||
@@ -240,21 +240,26 @@ jobs:
|
||||
- pre_job
|
||||
- check-changes
|
||||
- api-tests-run
|
||||
- api-tests-skip
|
||||
runs-on: depot-ubuntu-24.04
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: Finalize API Tests status
|
||||
env:
|
||||
SHOULD_SKIP_WORKFLOW: ${{ needs.pre_job.outputs.should_skip }}
|
||||
TESTS_CHANGED: ${{ needs.check-changes.outputs.api-changed }}
|
||||
RUN_RESULT: ${{ needs.api-tests-run.result }}
|
||||
SKIP_RESULT: ${{ needs.api-tests-skip.result }}
|
||||
PRE_JOB_RESULT: ${{ needs.pre_job.result }}
|
||||
CHANGES_RESULT: ${{ needs.check-changes.result }}
|
||||
run: |
|
||||
if [[ "$SHOULD_SKIP_WORKFLOW" == 'true' ]]; then
|
||||
echo "API tests were skipped because this workflow run duplicated a successful or newer run."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ "$PRE_JOB_RESULT" != 'success' || "$CHANGES_RESULT" != 'success' ]]; then
|
||||
echo "Cannot finalize tests: duplicate check=$PRE_JOB_RESULT, change detection=$CHANGES_RESULT" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$TESTS_CHANGED" == 'true' ]]; then
|
||||
if [[ "$RUN_RESULT" == 'success' ]]; then
|
||||
echo "API tests ran successfully."
|
||||
@@ -265,12 +270,12 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$SKIP_RESULT" == 'success' ]]; then
|
||||
if [[ "$TESTS_CHANGED" == 'false' && "$RUN_RESULT" == 'skipped' ]]; then
|
||||
echo "API tests were skipped because no API-related files changed."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "API tests were not required, but the skip job finished with result: $SKIP_RESULT" >&2
|
||||
echo "Unexpected test state: changed=$TESTS_CHANGED, result=$RUN_RESULT" >&2
|
||||
exit 1
|
||||
|
||||
cli-tests-run:
|
||||
@@ -282,17 +287,6 @@ jobs:
|
||||
uses: ./.github/workflows/cli-tests.yml
|
||||
secrets: inherit
|
||||
|
||||
cli-tests-skip:
|
||||
name: Skip CLI Tests
|
||||
needs:
|
||||
- pre_job
|
||||
- check-changes
|
||||
if: needs.pre_job.outputs.should_skip != 'true' && needs.check-changes.outputs.cli-changed != 'true'
|
||||
runs-on: depot-ubuntu-24.04
|
||||
steps:
|
||||
- name: Report skipped CLI tests
|
||||
run: echo "No CLI-related changes detected; skipping CLI tests."
|
||||
|
||||
cli-tests:
|
||||
name: CLI Tests
|
||||
if: ${{ always() }}
|
||||
@@ -300,21 +294,26 @@ jobs:
|
||||
- pre_job
|
||||
- check-changes
|
||||
- cli-tests-run
|
||||
- cli-tests-skip
|
||||
runs-on: depot-ubuntu-24.04
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: Finalize CLI Tests status
|
||||
env:
|
||||
SHOULD_SKIP_WORKFLOW: ${{ needs.pre_job.outputs.should_skip }}
|
||||
TESTS_CHANGED: ${{ needs.check-changes.outputs.cli-changed }}
|
||||
RUN_RESULT: ${{ needs.cli-tests-run.result }}
|
||||
SKIP_RESULT: ${{ needs.cli-tests-skip.result }}
|
||||
PRE_JOB_RESULT: ${{ needs.pre_job.result }}
|
||||
CHANGES_RESULT: ${{ needs.check-changes.result }}
|
||||
run: |
|
||||
if [[ "$SHOULD_SKIP_WORKFLOW" == 'true' ]]; then
|
||||
echo "CLI tests were skipped because this workflow run duplicated a successful or newer run."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ "$PRE_JOB_RESULT" != 'success' || "$CHANGES_RESULT" != 'success' ]]; then
|
||||
echo "Cannot finalize tests: duplicate check=$PRE_JOB_RESULT, change detection=$CHANGES_RESULT" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$TESTS_CHANGED" == 'true' ]]; then
|
||||
if [[ "$RUN_RESULT" == 'success' ]]; then
|
||||
echo "CLI tests ran successfully."
|
||||
@@ -325,12 +324,12 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$SKIP_RESULT" == 'success' ]]; then
|
||||
if [[ "$TESTS_CHANGED" == 'false' && "$RUN_RESULT" == 'skipped' ]]; then
|
||||
echo "CLI tests were skipped because no CLI-related files changed."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "CLI tests were not required, but the skip job finished with result: $SKIP_RESULT" >&2
|
||||
echo "Unexpected test state: changed=$TESTS_CHANGED, result=$RUN_RESULT" >&2
|
||||
exit 1
|
||||
|
||||
web-tests-run:
|
||||
@@ -342,17 +341,6 @@ jobs:
|
||||
uses: ./.github/workflows/web-tests.yml
|
||||
secrets: inherit
|
||||
|
||||
web-tests-skip:
|
||||
name: Skip Web Tests
|
||||
needs:
|
||||
- pre_job
|
||||
- check-changes
|
||||
if: needs.pre_job.outputs.should_skip != 'true' && needs.check-changes.outputs.web-changed != 'true'
|
||||
runs-on: depot-ubuntu-24.04
|
||||
steps:
|
||||
- name: Report skipped web tests
|
||||
run: echo "No web-related changes detected; skipping web tests."
|
||||
|
||||
web-tests:
|
||||
name: Web Tests
|
||||
if: ${{ always() }}
|
||||
@@ -360,21 +348,26 @@ jobs:
|
||||
- pre_job
|
||||
- check-changes
|
||||
- web-tests-run
|
||||
- web-tests-skip
|
||||
runs-on: depot-ubuntu-24.04
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: Finalize Web Tests status
|
||||
env:
|
||||
SHOULD_SKIP_WORKFLOW: ${{ needs.pre_job.outputs.should_skip }}
|
||||
TESTS_CHANGED: ${{ needs.check-changes.outputs.web-changed }}
|
||||
RUN_RESULT: ${{ needs.web-tests-run.result }}
|
||||
SKIP_RESULT: ${{ needs.web-tests-skip.result }}
|
||||
PRE_JOB_RESULT: ${{ needs.pre_job.result }}
|
||||
CHANGES_RESULT: ${{ needs.check-changes.result }}
|
||||
run: |
|
||||
if [[ "$SHOULD_SKIP_WORKFLOW" == 'true' ]]; then
|
||||
echo "Web tests were skipped because this workflow run duplicated a successful or newer run."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ "$PRE_JOB_RESULT" != 'success' || "$CHANGES_RESULT" != 'success' ]]; then
|
||||
echo "Cannot finalize tests: duplicate check=$PRE_JOB_RESULT, change detection=$CHANGES_RESULT" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$TESTS_CHANGED" == 'true' ]]; then
|
||||
if [[ "$RUN_RESULT" == 'success' ]]; then
|
||||
echo "Web tests ran successfully."
|
||||
@@ -385,12 +378,12 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$SKIP_RESULT" == 'success' ]]; then
|
||||
if [[ "$TESTS_CHANGED" == 'false' && "$RUN_RESULT" == 'skipped' ]]; then
|
||||
echo "Web tests were skipped because no web-related files changed."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Web tests were not required, but the skip job finished with result: $SKIP_RESULT" >&2
|
||||
echo "Unexpected test state: changed=$TESTS_CHANGED, result=$RUN_RESULT" >&2
|
||||
exit 1
|
||||
|
||||
web-e2e-run:
|
||||
@@ -404,17 +397,6 @@ jobs:
|
||||
run-external-runtime: false
|
||||
secrets: inherit
|
||||
|
||||
web-e2e-skip:
|
||||
name: Skip Web Full-Stack E2E
|
||||
needs:
|
||||
- pre_job
|
||||
- check-changes
|
||||
if: needs.pre_job.outputs.should_skip != 'true' && needs.check-changes.outputs.e2e-changed != 'true'
|
||||
runs-on: depot-ubuntu-24.04
|
||||
steps:
|
||||
- name: Report skipped web full-stack e2e
|
||||
run: echo "No E2E-related changes detected; skipping web full-stack E2E."
|
||||
|
||||
web-e2e:
|
||||
name: Web Full-Stack E2E
|
||||
if: ${{ always() }}
|
||||
@@ -422,21 +404,26 @@ jobs:
|
||||
- pre_job
|
||||
- check-changes
|
||||
- web-e2e-run
|
||||
- web-e2e-skip
|
||||
runs-on: depot-ubuntu-24.04
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: Finalize Web Full-Stack E2E status
|
||||
env:
|
||||
SHOULD_SKIP_WORKFLOW: ${{ needs.pre_job.outputs.should_skip }}
|
||||
TESTS_CHANGED: ${{ needs.check-changes.outputs.e2e-changed }}
|
||||
RUN_RESULT: ${{ needs.web-e2e-run.result }}
|
||||
SKIP_RESULT: ${{ needs.web-e2e-skip.result }}
|
||||
PRE_JOB_RESULT: ${{ needs.pre_job.result }}
|
||||
CHANGES_RESULT: ${{ needs.check-changes.result }}
|
||||
run: |
|
||||
if [[ "$SHOULD_SKIP_WORKFLOW" == 'true' ]]; then
|
||||
echo "Web full-stack E2E was skipped because this workflow run duplicated a successful or newer run."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ "$PRE_JOB_RESULT" != 'success' || "$CHANGES_RESULT" != 'success' ]]; then
|
||||
echo "Cannot finalize tests: duplicate check=$PRE_JOB_RESULT, change detection=$CHANGES_RESULT" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$TESTS_CHANGED" == 'true' ]]; then
|
||||
if [[ "$RUN_RESULT" == 'success' ]]; then
|
||||
echo "Web full-stack E2E ran successfully."
|
||||
@@ -447,12 +434,12 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$SKIP_RESULT" == 'success' ]]; then
|
||||
if [[ "$TESTS_CHANGED" == 'false' && "$RUN_RESULT" == 'skipped' ]]; then
|
||||
echo "Web full-stack E2E was skipped because no E2E-related files changed."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Web full-stack E2E was not required, but the skip job finished with result: $SKIP_RESULT" >&2
|
||||
echo "Unexpected test state: changed=$TESTS_CHANGED, result=$RUN_RESULT" >&2
|
||||
exit 1
|
||||
|
||||
style-check:
|
||||
@@ -480,17 +467,6 @@ jobs:
|
||||
if: needs.pre_job.outputs.should_skip != 'true' && needs.check-changes.outputs.vdb-changed == 'true'
|
||||
uses: ./.github/workflows/vdb-tests.yml
|
||||
|
||||
vdb-tests-skip:
|
||||
name: Skip VDB Tests
|
||||
needs:
|
||||
- pre_job
|
||||
- check-changes
|
||||
if: needs.pre_job.outputs.should_skip != 'true' && needs.check-changes.outputs.vdb-changed != 'true'
|
||||
runs-on: depot-ubuntu-24.04
|
||||
steps:
|
||||
- name: Report skipped VDB tests
|
||||
run: echo "No VDB-related changes detected; skipping VDB tests."
|
||||
|
||||
vdb-tests:
|
||||
name: VDB Tests
|
||||
if: ${{ always() }}
|
||||
@@ -498,21 +474,26 @@ jobs:
|
||||
- pre_job
|
||||
- check-changes
|
||||
- vdb-tests-run
|
||||
- vdb-tests-skip
|
||||
runs-on: depot-ubuntu-24.04
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: Finalize VDB Tests status
|
||||
env:
|
||||
SHOULD_SKIP_WORKFLOW: ${{ needs.pre_job.outputs.should_skip }}
|
||||
TESTS_CHANGED: ${{ needs.check-changes.outputs.vdb-changed }}
|
||||
RUN_RESULT: ${{ needs.vdb-tests-run.result }}
|
||||
SKIP_RESULT: ${{ needs.vdb-tests-skip.result }}
|
||||
PRE_JOB_RESULT: ${{ needs.pre_job.result }}
|
||||
CHANGES_RESULT: ${{ needs.check-changes.result }}
|
||||
run: |
|
||||
if [[ "$SHOULD_SKIP_WORKFLOW" == 'true' ]]; then
|
||||
echo "VDB tests were skipped because this workflow run duplicated a successful or newer run."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ "$PRE_JOB_RESULT" != 'success' || "$CHANGES_RESULT" != 'success' ]]; then
|
||||
echo "Cannot finalize tests: duplicate check=$PRE_JOB_RESULT, change detection=$CHANGES_RESULT" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$TESTS_CHANGED" == 'true' ]]; then
|
||||
if [[ "$RUN_RESULT" == 'success' ]]; then
|
||||
echo "VDB tests ran successfully."
|
||||
@@ -523,12 +504,12 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$SKIP_RESULT" == 'success' ]]; then
|
||||
if [[ "$TESTS_CHANGED" == 'false' && "$RUN_RESULT" == 'skipped' ]]; then
|
||||
echo "VDB tests were skipped because no VDB-related files changed."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "VDB tests were not required, but the skip job finished with result: $SKIP_RESULT" >&2
|
||||
echo "Unexpected test state: changed=$TESTS_CHANGED, result=$RUN_RESULT" >&2
|
||||
exit 1
|
||||
|
||||
db-migration-test-run:
|
||||
@@ -539,17 +520,6 @@ jobs:
|
||||
if: needs.pre_job.outputs.should_skip != 'true' && needs.check-changes.outputs.migration-changed == 'true'
|
||||
uses: ./.github/workflows/db-migration-test.yml
|
||||
|
||||
db-migration-test-skip:
|
||||
name: Skip DB Migration Test
|
||||
needs:
|
||||
- pre_job
|
||||
- check-changes
|
||||
if: needs.pre_job.outputs.should_skip != 'true' && needs.check-changes.outputs.migration-changed != 'true'
|
||||
runs-on: depot-ubuntu-24.04
|
||||
steps:
|
||||
- name: Report skipped DB migration tests
|
||||
run: echo "No migration-related changes detected; skipping DB migration tests."
|
||||
|
||||
db-migration-test:
|
||||
name: DB Migration Test
|
||||
if: ${{ always() }}
|
||||
@@ -557,21 +527,26 @@ jobs:
|
||||
- pre_job
|
||||
- check-changes
|
||||
- db-migration-test-run
|
||||
- db-migration-test-skip
|
||||
runs-on: depot-ubuntu-24.04
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: Finalize DB Migration Test status
|
||||
env:
|
||||
SHOULD_SKIP_WORKFLOW: ${{ needs.pre_job.outputs.should_skip }}
|
||||
TESTS_CHANGED: ${{ needs.check-changes.outputs.migration-changed }}
|
||||
RUN_RESULT: ${{ needs.db-migration-test-run.result }}
|
||||
SKIP_RESULT: ${{ needs.db-migration-test-skip.result }}
|
||||
PRE_JOB_RESULT: ${{ needs.pre_job.result }}
|
||||
CHANGES_RESULT: ${{ needs.check-changes.result }}
|
||||
run: |
|
||||
if [[ "$SHOULD_SKIP_WORKFLOW" == 'true' ]]; then
|
||||
echo "DB migration tests were skipped because this workflow run duplicated a successful or newer run."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ "$PRE_JOB_RESULT" != 'success' || "$CHANGES_RESULT" != 'success' ]]; then
|
||||
echo "Cannot finalize tests: duplicate check=$PRE_JOB_RESULT, change detection=$CHANGES_RESULT" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$TESTS_CHANGED" == 'true' ]]; then
|
||||
if [[ "$RUN_RESULT" == 'success' ]]; then
|
||||
echo "DB migration tests ran successfully."
|
||||
@@ -582,12 +557,12 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$SKIP_RESULT" == 'success' ]]; then
|
||||
if [[ "$TESTS_CHANGED" == 'false' && "$RUN_RESULT" == 'skipped' ]]; then
|
||||
echo "DB migration tests were skipped because no migration-related files changed."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "DB migration tests were not required, but the skip job finished with result: $SKIP_RESULT" >&2
|
||||
echo "Unexpected test state: changed=$TESTS_CHANGED, result=$RUN_RESULT" >&2
|
||||
exit 1
|
||||
|
||||
sandbox-runtime-tests-run:
|
||||
@@ -599,17 +574,6 @@ jobs:
|
||||
uses: ./.github/workflows/sandbox-runtime-tests.yml
|
||||
secrets: inherit
|
||||
|
||||
sandbox-runtime-tests-skip:
|
||||
name: Skip Sandbox Runtime Tests
|
||||
needs:
|
||||
- pre_job
|
||||
- check-changes
|
||||
if: needs.pre_job.outputs.should_skip != 'true' && needs.check-changes.outputs.sandbox-runtime-changed != 'true'
|
||||
runs-on: depot-ubuntu-24.04
|
||||
steps:
|
||||
- name: Report skipped sandbox runtime tests
|
||||
run: echo "No sandbox-runtime-related changes detected; skipping sandbox runtime tests."
|
||||
|
||||
sandbox-runtime-tests:
|
||||
name: Sandbox Runtime Tests
|
||||
if: ${{ always() }}
|
||||
@@ -617,21 +581,26 @@ jobs:
|
||||
- pre_job
|
||||
- check-changes
|
||||
- sandbox-runtime-tests-run
|
||||
- sandbox-runtime-tests-skip
|
||||
runs-on: depot-ubuntu-24.04
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: Finalize Sandbox Runtime Tests status
|
||||
env:
|
||||
SHOULD_SKIP_WORKFLOW: ${{ needs.pre_job.outputs.should_skip }}
|
||||
TESTS_CHANGED: ${{ needs.check-changes.outputs.sandbox-runtime-changed }}
|
||||
RUN_RESULT: ${{ needs.sandbox-runtime-tests-run.result }}
|
||||
SKIP_RESULT: ${{ needs.sandbox-runtime-tests-skip.result }}
|
||||
PRE_JOB_RESULT: ${{ needs.pre_job.result }}
|
||||
CHANGES_RESULT: ${{ needs.check-changes.result }}
|
||||
run: |
|
||||
if [[ "$SHOULD_SKIP_WORKFLOW" == 'true' ]]; then
|
||||
echo "Sandbox runtime tests were skipped because this workflow run duplicated a successful or newer run."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ "$PRE_JOB_RESULT" != 'success' || "$CHANGES_RESULT" != 'success' ]]; then
|
||||
echo "Cannot finalize tests: duplicate check=$PRE_JOB_RESULT, change detection=$CHANGES_RESULT" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$TESTS_CHANGED" == 'true' ]]; then
|
||||
if [[ "$RUN_RESULT" == 'success' ]]; then
|
||||
echo "Sandbox runtime tests ran successfully."
|
||||
@@ -642,12 +611,12 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$SKIP_RESULT" == 'success' ]]; then
|
||||
if [[ "$TESTS_CHANGED" == 'false' && "$RUN_RESULT" == 'skipped' ]]; then
|
||||
echo "Sandbox runtime tests were skipped because no sandbox-runtime-related files changed."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Sandbox runtime tests were not required, but the skip job finished with result: $SKIP_RESULT" >&2
|
||||
echo "Unexpected test state: changed=$TESTS_CHANGED, result=$RUN_RESULT" >&2
|
||||
exit 1
|
||||
|
||||
dify-agent-tests-run:
|
||||
@@ -659,17 +628,6 @@ jobs:
|
||||
uses: ./.github/workflows/dify-agent-tests.yml
|
||||
secrets: inherit
|
||||
|
||||
dify-agent-tests-skip:
|
||||
name: Skip Dify Agent Tests
|
||||
needs:
|
||||
- pre_job
|
||||
- check-changes
|
||||
if: needs.pre_job.outputs.should_skip != 'true' && needs.check-changes.outputs.dify-agent-changed != 'true'
|
||||
runs-on: depot-ubuntu-24.04
|
||||
steps:
|
||||
- name: Report skipped dify agent tests
|
||||
run: echo "No dify-agent-related changes detected; skipping dify agent tests."
|
||||
|
||||
dify-agent-tests:
|
||||
name: Dify Agent Tests
|
||||
if: ${{ always() }}
|
||||
@@ -677,21 +635,26 @@ jobs:
|
||||
- pre_job
|
||||
- check-changes
|
||||
- dify-agent-tests-run
|
||||
- dify-agent-tests-skip
|
||||
runs-on: depot-ubuntu-24.04
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: Finalize Dify Agent Tests status
|
||||
env:
|
||||
SHOULD_SKIP_WORKFLOW: ${{ needs.pre_job.outputs.should_skip }}
|
||||
TESTS_CHANGED: ${{ needs.check-changes.outputs.dify-agent-changed }}
|
||||
RUN_RESULT: ${{ needs.dify-agent-tests-run.result }}
|
||||
SKIP_RESULT: ${{ needs.dify-agent-tests-skip.result }}
|
||||
PRE_JOB_RESULT: ${{ needs.pre_job.result }}
|
||||
CHANGES_RESULT: ${{ needs.check-changes.result }}
|
||||
run: |
|
||||
if [[ "$SHOULD_SKIP_WORKFLOW" == 'true' ]]; then
|
||||
echo "Dify agent tests were skipped because this workflow run duplicated a successful or newer run."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ "$PRE_JOB_RESULT" != 'success' || "$CHANGES_RESULT" != 'success' ]]; then
|
||||
echo "Cannot finalize tests: duplicate check=$PRE_JOB_RESULT, change detection=$CHANGES_RESULT" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$TESTS_CHANGED" == 'true' ]]; then
|
||||
if [[ "$RUN_RESULT" == 'success' ]]; then
|
||||
echo "Dify agent tests ran successfully."
|
||||
@@ -702,10 +665,10 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$SKIP_RESULT" == 'success' ]]; then
|
||||
if [[ "$TESTS_CHANGED" == 'false' && "$RUN_RESULT" == 'skipped' ]]; then
|
||||
echo "Dify agent tests were skipped because no dify-agent-related files changed."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Dify agent tests were not required, but the skip job finished with result: $SKIP_RESULT" >&2
|
||||
echo "Unexpected test state: changed=$TESTS_CHANGED, result=$RUN_RESULT" >&2
|
||||
exit 1
|
||||
|
||||
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@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
|
||||
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.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@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
|
||||
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.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@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
|
||||
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
|
||||
with:
|
||||
enable-cache: true
|
||||
|
||||
|
||||
28
.github/workflows/style.yml
vendored
28
.github/workflows/style.yml
vendored
@@ -61,7 +61,7 @@ jobs:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup UV and Python
|
||||
uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
|
||||
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
|
||||
with:
|
||||
enable-cache: false
|
||||
python-version: '3.12'
|
||||
@@ -89,6 +89,14 @@ jobs:
|
||||
if: inputs.run-python-style
|
||||
run: uv run --project api python scripts/check_no_new_controller_sqlalchemy.py --base-rev "${{ inputs.base-rev }}"
|
||||
|
||||
- name: Run No New Session Mock Guard
|
||||
if: inputs.run-python-style
|
||||
run: uv run --project api python scripts/check_no_new_session_mock.py --base-rev "${{ inputs.base-rev }}"
|
||||
|
||||
- name: Run No Spec Mock Guard
|
||||
if: inputs.run-python-style
|
||||
run: uv run --project api python scripts/check_no_spec_mock.py
|
||||
|
||||
- name: Run Type Checks
|
||||
if: inputs.run-python-style
|
||||
env:
|
||||
@@ -205,9 +213,25 @@ jobs:
|
||||
- name: Setup web environment
|
||||
uses: ./.github/actions/setup-web
|
||||
|
||||
- name: Restore static check task cache
|
||||
id: static-check-cache
|
||||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: node_modules/.vite/task-cache
|
||||
key: vite-task-static-v1-${{ runner.os }}-${{ runner.arch }}-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
restore-keys: |
|
||||
vite-task-static-v1-${{ runner.os }}-${{ runner.arch }}-
|
||||
|
||||
- name: Static check
|
||||
run: vp run -w check
|
||||
|
||||
- name: Save static check task cache
|
||||
if: success() && github.event_name != 'merge_group'
|
||||
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: node_modules/.vite/task-cache
|
||||
key: ${{ steps.static-check-cache.outputs.cache-primary-key }}
|
||||
|
||||
superlinter:
|
||||
name: SuperLinter
|
||||
if: ${{ !inputs.skip-checks && (!inputs.classification-succeeded || inputs.run-superlinter) }}
|
||||
@@ -227,7 +251,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Super-linter
|
||||
uses: super-linter/super-linter/slim@4ce20838b8ab83717e78138c5b3a1407148e0918 # v8.7.0
|
||||
uses: super-linter/super-linter/slim@2da136927bd4a73596db63044b504547c62cb854 # v9.0.0
|
||||
env:
|
||||
BASH_SEVERITY: warning
|
||||
DEFAULT_BRANCH: origin/main
|
||||
|
||||
18
.github/workflows/tool-test-sdks.yaml
vendored
18
.github/workflows/tool-test-sdks.yaml
vendored
@@ -9,6 +9,8 @@ on:
|
||||
- package.json
|
||||
- pnpm-lock.yaml
|
||||
- pnpm-workspace.yaml
|
||||
- .github/actions/setup-web/**
|
||||
- .github/workflows/tool-test-sdks.yaml
|
||||
|
||||
concurrency:
|
||||
group: sdk-tests-${{ github.head_ref || github.run_id }}
|
||||
@@ -28,15 +30,13 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version-file: package.json
|
||||
cache: ''
|
||||
cache-dependency-path: 'pnpm-lock.yaml'
|
||||
|
||||
- name: Install Dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
- name: Setup web environment
|
||||
uses: ./.github/actions/setup-web
|
||||
|
||||
- name: Test
|
||||
run: pnpm test
|
||||
|
||||
- name: Build and verify package entry points
|
||||
run: |
|
||||
pnpm build
|
||||
node --input-type=module -e "import { access } from 'node:fs/promises'; import 'dify-client'; await access('dist/index.d.ts')"
|
||||
|
||||
2
.github/workflows/translate-i18n-claude.yml
vendored
2
.github/workflows/translate-i18n-claude.yml
vendored
@@ -163,7 +163,7 @@ jobs:
|
||||
|
||||
- name: Run Claude Code for Translation Sync
|
||||
if: steps.context.outputs.CHANGED_FILES != ''
|
||||
uses: anthropics/claude-code-action@4036a180cf690f49529f5d8c79c998855287f590 # v1.0.230
|
||||
uses: anthropics/claude-code-action@ed670b4cf9de2a5a570d130d2f6197b9e543cd64 # v1.0.240
|
||||
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@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
|
||||
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.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@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
|
||||
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
|
||||
with:
|
||||
enable-cache: true
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
4
.github/workflows/web-bundle-size.yml
vendored
4
.github/workflows/web-bundle-size.yml
vendored
@@ -58,7 +58,7 @@ jobs:
|
||||
run: |
|
||||
set -o pipefail
|
||||
started=$SECONDS
|
||||
pnpm exec vinext build 2>&1 | tee "$RUNNER_TEMP/vinext-build.log"
|
||||
pnpm run build:vinext 2>&1 | tee "$RUNNER_TEMP/vinext-build.log"
|
||||
printf 'Full Vinext build: **%s seconds**.\n' "$((SECONDS - started))" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Collect sizes
|
||||
@@ -101,7 +101,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: pnpm/setup@703c52620218391530e48b9e8870d5c0082e1b9b # v2.1.0
|
||||
uses: pnpm/setup@fbda4c85fc2e1e08721cd8763afea8f48d60f024 # v3.0.0
|
||||
with:
|
||||
install: false
|
||||
|
||||
|
||||
44
.github/workflows/web-e2e.yml
vendored
44
.github/workflows/web-e2e.yml
vendored
@@ -26,7 +26,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Test Web build synchronization
|
||||
run: node --test .github/actions/wait-e2e-web-build/wait.test.cjs
|
||||
run: node --test .github/actions/wait-e2e-web-build/*.test.cjs
|
||||
|
||||
- name: Compute build source hash
|
||||
id: build-source
|
||||
@@ -119,24 +119,12 @@ jobs:
|
||||
with:
|
||||
browser: chromium
|
||||
|
||||
# Install dependencies while core-build runs, then wait only for its artifact.
|
||||
- name: Wait for Web build
|
||||
id: web-build
|
||||
timeout-minutes: 35
|
||||
uses: ./.github/actions/wait-e2e-web-build
|
||||
|
||||
- name: Download Web build
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
artifact-ids: ${{ steps.web-build.outputs.artifact-id }}
|
||||
github-token: ${{ github.token }}
|
||||
|
||||
- name: Extract Web build
|
||||
run: tar -xf e2e-web-build.tar -C web
|
||||
|
||||
# The runner prepares the backend while waiting for and downloading the build.
|
||||
- name: Run Chromium full shard
|
||||
working-directory: ./e2e
|
||||
run: vp run e2e:full -- --shard ${{ matrix.shard }}/3
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: vp run e2e:full --download-web-build -- --shard ${{ matrix.shard }}/3
|
||||
|
||||
- name: Upload Cucumber report
|
||||
if: ${{ !cancelled() }}
|
||||
@@ -183,24 +171,12 @@ jobs:
|
||||
with:
|
||||
browser: webkit
|
||||
|
||||
# Install dependencies while core-build runs, then wait only for its artifact.
|
||||
- name: Wait for Web build
|
||||
id: web-build
|
||||
timeout-minutes: 35
|
||||
uses: ./.github/actions/wait-e2e-web-build
|
||||
|
||||
- name: Download Web build
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
artifact-ids: ${{ steps.web-build.outputs.artifact-id }}
|
||||
github-token: ${{ github.token }}
|
||||
|
||||
- name: Extract Web build
|
||||
run: tar -xf e2e-web-build.tar -C web
|
||||
|
||||
# Keep the same overlap and teardown ownership for the WebKit smoke lane.
|
||||
- name: Run WebKit browser smoke
|
||||
working-directory: ./e2e
|
||||
run: vp run e2e:full -- --tags '@browser-smoke'
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: vp run e2e:full --download-web-build -- --tags '@browser-smoke'
|
||||
|
||||
- name: Upload Cucumber report
|
||||
if: ${{ !cancelled() }}
|
||||
@@ -239,7 +215,7 @@ jobs:
|
||||
uses: ./.github/actions/setup-web
|
||||
|
||||
- name: Setup UV and Python
|
||||
uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
|
||||
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
|
||||
with:
|
||||
enable-cache: true
|
||||
python-version: '3.12'
|
||||
|
||||
9
.vscode/README.md
vendored
9
.vscode/README.md
vendored
@@ -1,14 +1,11 @@
|
||||
# Debugging with VS Code
|
||||
|
||||
This `launch.json.template` file provides various debug configurations for the Dify project within VS Code / Cursor. To use these configurations, you should copy the contents of this file into a new file named `launch.json` in the same `.vscode` directory.
|
||||
[`launch.json.template`](launch.json.template) provides API and Celery worker debug configurations for VS Code / Cursor. Both use `api/.venv/bin/python`; complete the [backend setup](../api/README.md) first.
|
||||
|
||||
## How to Use
|
||||
|
||||
1. **Create `launch.json`**: If you don't have one, create a file named `launch.json` inside the `.vscode` directory.
|
||||
1. **Copy Content**: Copy the entire content from `launch.json.template` into your newly created `launch.json` file.
|
||||
1. Copy `.vscode/launch.json.template` to `.vscode/launch.json`, or merge the configurations into an existing file.
|
||||
1. **Select Debug Configuration**: Go to the Run and Debug view in VS Code / Cursor (Ctrl+Shift+D or Cmd+Shift+D).
|
||||
1. **Start Debugging**: Select the desired configuration from the dropdown menu and click the green play button.
|
||||
|
||||
## Tips
|
||||
|
||||
- If you need to debug with Edge browser instead of Chrome, modify the `serverReadyAction` configuration in the "Next.js: debug full stack" section, change `"debugWithChrome"` to `"debugWithEdge"` to use Microsoft Edge for debugging.
|
||||
Start the frontend separately using the [frontend guide](../web/README.md).
|
||||
|
||||
@@ -81,24 +81,13 @@ AI tools are welcome, but you are responsible for understanding and verifying ev
|
||||
|
||||
#### Frontend
|
||||
|
||||
For setting up the frontend service, please refer to our comprehensive [guide](https://github.com/langgenius/dify/blob/main/web/README.md) in the `web/README.md` file. This document provides detailed instructions to help you set up the frontend environment properly.
|
||||
For setting up the frontend service, please refer to our comprehensive [guide](web/README.md) in the `web/README.md` file. This document provides detailed instructions to help you set up the frontend environment properly.
|
||||
|
||||
**Testing**: Add focused tests when a change affects observable behavior or carries meaningful regression risk. See [web/docs/test.md](https://github.com/langgenius/dify/blob/main/web/docs/test.md) for the canonical frontend testing guidelines.
|
||||
**Testing**: Add focused tests when a change affects observable behavior or carries meaningful regression risk. See [web/docs/test.md](web/docs/test.md) for the canonical frontend testing guidelines.
|
||||
|
||||
#### Backend
|
||||
|
||||
For setting up the backend service, kindly refer to our detailed [instructions](https://github.com/langgenius/dify/blob/main/api/README.md) in the `api/README.md` file. This document contains step-by-step guidance to help you get the backend up and running smoothly.
|
||||
|
||||
#### Other things to note
|
||||
|
||||
We recommend reviewing this document carefully before proceeding with the setup, as it contains essential information about:
|
||||
|
||||
- Prerequisites and dependencies
|
||||
- Installation steps
|
||||
- Configuration details
|
||||
- Common troubleshooting tips
|
||||
|
||||
Feel free to reach out if you encounter any issues during the setup process.
|
||||
For setting up the backend service, kindly refer to our detailed [instructions](api/README.md) in the `api/README.md` file. This document contains step-by-step guidance to help you get the backend up and running smoothly.
|
||||
|
||||
## Getting Help
|
||||
|
||||
|
||||
11
Makefile
11
Makefile
@@ -80,6 +80,7 @@ lint:
|
||||
@uv run --project api --dev ruff format ./api
|
||||
@uv run --project api --dev ruff check --fix ./api
|
||||
@$(MAKE) api-contract-lint
|
||||
@uv run --project api python scripts/check_no_spec_mock.py
|
||||
@uv run --directory api --dev lint-imports
|
||||
@uv run --project api --dev dotenv-linter ./api/.env.example ./web/.env.example
|
||||
@echo "✅ Linting complete"
|
||||
@@ -90,15 +91,13 @@ api-contract-lint:
|
||||
@echo "✅ Response contract lint complete"
|
||||
|
||||
type-check:
|
||||
@echo "📝 Running type checks (pyrefly + mypy)..."
|
||||
@echo "📝 Running type checks (pyrefly)..."
|
||||
@./dev/pyrefly-check-local $(PATH_TO_CHECK)
|
||||
@uv --directory api run mypy --exclude-gitignore --exclude '(^|/)conftest\.py$$' --exclude 'tests/' --exclude 'migrations/' --exclude 'dev/generate_swagger_specs.py' --exclude 'dev/generate_fastopenapi_specs.py' --check-untyped-defs --disable-error-code=import-untyped .
|
||||
@echo "✅ Type checks complete"
|
||||
|
||||
type-check-core:
|
||||
@echo "📝 Running core type checks (pyrefly + mypy)..."
|
||||
@echo "📝 Running core type checks (pyrefly)..."
|
||||
@./dev/pyrefly-check-local $(PATH_TO_CHECK)
|
||||
@uv --directory api run mypy --exclude-gitignore --exclude '(^|/)conftest\.py$$' --exclude 'tests/' --exclude 'migrations/' --exclude 'dev/generate_swagger_specs.py' --exclude 'dev/generate_fastopenapi_specs.py' --check-untyped-defs --disable-error-code=import-untyped .
|
||||
@echo "✅ Core type checks complete"
|
||||
|
||||
test:
|
||||
@@ -214,8 +213,8 @@ help:
|
||||
@echo " make check - Check code with ruff"
|
||||
@echo " make lint - Format, fix, and lint code (ruff, imports, dotenv)"
|
||||
@echo " make api-contract-lint - Check Flask response docs against returned schemas"
|
||||
@echo " make type-check - Run type checks (pyrefly, mypy)"
|
||||
@echo " make type-check-core - Run core type checks (pyrefly, mypy)"
|
||||
@echo " make type-check - Run type checks (pyrefly)"
|
||||
@echo " make type-check-core - Run core type checks (pyrefly)"
|
||||
@echo " make test - Run backend unit tests (or TARGET_TESTS=./api/tests/<target_tests>)"
|
||||
@echo " make test-all - Run full backend tests, including Docker-backed suites"
|
||||
@echo ""
|
||||
|
||||
@@ -83,7 +83,7 @@ docker compose up -d
|
||||
|
||||
After running, you can access the Dify dashboard in your browser at [http://localhost/install](http://localhost/install) and start the initialization process.
|
||||
|
||||
#### Seeking help
|
||||
### Seeking help
|
||||
|
||||
Please refer to our [FAQ](https://docs.dify.ai/getting-started/install-self-hosted/faqs) if you encounter problems setting up Dify. Reach out to [the community and us](#community--contact) if you are still having issues.
|
||||
|
||||
@@ -116,7 +116,7 @@ All of Dify's offerings come with corresponding APIs, so you could effortlessly
|
||||
## Using Dify
|
||||
|
||||
- **Cloud <br/>**
|
||||
We host a [Dify Cloud](https://dify.ai) service for anyone to try with zero setup. It provides all the capabilities of the self-deployed version, and includes 200 free GPT-4 calls in the sandbox plan. If you run into issues with Dify Cloud, [contact our Cloud support team](mailto:cloud@dify.ai?subject=%5BGitHub%5DDify%20Cloud%20Support).
|
||||
We host [Dify Cloud](https://cloud.dify.ai) so you can try Dify without managing a server. See the [pricing page](https://dify.ai/pricing) for current plans and usage allowances. If you run into issues, [contact our Cloud support team](mailto:cloud@dify.ai?subject=%5BGitHub%5DDify%20Cloud%20Support).
|
||||
|
||||
- **Self-hosting Dify Community Edition<br/>**
|
||||
Quickly get Dify running in your environment with this [starter guide](#quick-start).
|
||||
@@ -171,7 +171,7 @@ Choose the channel that best fits your question:
|
||||
|
||||
## Security disclosure
|
||||
|
||||
To protect your privacy, please avoid posting security issues on GitHub. Instead, report issues to security@dify.ai, and our team will respond with detailed answer.
|
||||
Report vulnerabilities privately through GitHub Security Advisories, following our [security policy](SECURITY.md). Do not disclose them in public issues, discussions, or pull requests.
|
||||
|
||||
## License
|
||||
|
||||
|
||||
@@ -156,6 +156,7 @@ ARCHIVE_STORAGE_EXPORT_BUCKET=
|
||||
ARCHIVE_STORAGE_ACCESS_KEY=
|
||||
ARCHIVE_STORAGE_SECRET_KEY=
|
||||
ARCHIVE_STORAGE_REGION=auto
|
||||
ARCHIVE_STORAGE_ADDRESS_STYLE=path
|
||||
|
||||
# Azure Blob Storage configuration
|
||||
AZURE_BLOB_ACCOUNT_NAME=your-account-name
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
[importlinter]
|
||||
root_packages =
|
||||
core
|
||||
core.rag.extractor.entity
|
||||
constants
|
||||
context
|
||||
configs
|
||||
controllers
|
||||
extensions
|
||||
factories
|
||||
fields
|
||||
libs
|
||||
machinery
|
||||
models
|
||||
@@ -16,7 +18,7 @@ root_packages =
|
||||
include_external_packages = True
|
||||
|
||||
[importlinter:contract:backend-layers]
|
||||
name = Backend modules follow the transport, application, domain, and shared-library layers
|
||||
name = Backend modules follow the transport, application, execution-kernel, and shared-library layers
|
||||
type = layers
|
||||
layers =
|
||||
controllers
|
||||
@@ -27,7 +29,7 @@ layers =
|
||||
# error makes this list shrink whenever a legacy dependency is removed.
|
||||
unmatched_ignore_imports_alerting = error
|
||||
ignore_imports =
|
||||
core.app.app_config.easy_ui_based_app.dataset.manager -> services.dataset_service
|
||||
core.app.app_config.easy_ui_based_app.dataset.manager -> services.knowledge.dataset_service
|
||||
core.app.apps.advanced_chat.app_generator -> controllers.console.app.workflow
|
||||
core.app.apps.advanced_chat.app_generator -> services.conversation_service
|
||||
core.app.apps.advanced_chat.app_generator -> services.errors.conversation
|
||||
@@ -48,45 +50,41 @@ ignore_imports =
|
||||
core.app.apps.message_based_app_generator -> services.errors.conversation
|
||||
core.app.apps.message_based_app_generator -> services.errors.message
|
||||
core.app.apps.pipeline.pipeline_generator -> services
|
||||
core.app.apps.pipeline.pipeline_generator -> services.dataset_service
|
||||
core.app.apps.pipeline.pipeline_generator -> services.datasource_provider_service
|
||||
core.app.apps.pipeline.pipeline_generator -> services.knowledge.dataset_service
|
||||
core.app.apps.pipeline.pipeline_generator -> services.data_source.provider_service
|
||||
core.app.apps.pipeline.pipeline_generator -> services.feature_service
|
||||
core.app.apps.pipeline.pipeline_generator -> services.workflow_draft_variable_service
|
||||
core.app.apps.pipeline.pipeline_runner -> services.dataset_ref_service
|
||||
core.app.apps.workflow.app_generator -> controllers.console.app.workflow
|
||||
core.app.apps.workflow.app_generator -> services.snippet_generate_service
|
||||
core.app.apps.workflow.app_generator -> services.workflow_draft_variable_service
|
||||
core.app.apps.workflow_app_runner -> tasks.mail_human_input_delivery_task
|
||||
core.app.features.annotation_reply.annotation_reply -> services.annotation_service
|
||||
core.app.features.annotation_reply.annotation_reply -> services.dataset_service
|
||||
core.app.features.annotation_reply.annotation_reply -> services.knowledge.dataset_service
|
||||
core.app.llm.quota -> services.credit_pool_service
|
||||
core.app.task_pipeline.message_cycle_manager -> services.account_service
|
||||
core.app.task_pipeline.message_cycle_manager -> services.annotation_service
|
||||
core.app.workflow.layers.persistence -> services.workflow.inspector_events
|
||||
core.datasource.datasource_manager -> services.datasource_provider_service
|
||||
core.helper.credential_utils -> services.enterprise.plugin_manager_service
|
||||
core.helper.credential_utils -> services.system_feature_service
|
||||
core.indexing_runner -> services.vector_space_admission_service
|
||||
core.mcp.auth_client -> services
|
||||
core.provider_manager -> services.credential_permission_service
|
||||
core.provider_manager -> services.credit_pool_service
|
||||
core.provider_manager -> services.feature_service
|
||||
core.rag.datasource.retrieval_service -> services.external_knowledge_service
|
||||
core.rag.datasource.retrieval_service -> services.knowledge.external.service
|
||||
core.rag.index_processor.index_processor -> services.vector_space_admission_service
|
||||
core.rag.index_processor.index_processor_base -> services.file_service
|
||||
core.rag.index_processor.processor.paragraph_index_processor -> services.account_service
|
||||
core.rag.index_processor.processor.paragraph_index_processor -> services.summary_index_service
|
||||
core.rag.index_processor.processor.parent_child_index_processor -> services.account_service
|
||||
core.rag.index_processor.processor.parent_child_index_processor -> services.summary_index_service
|
||||
core.rag.index_processor.processor.qa_index_processor -> services.summary_index_service
|
||||
core.rag.retrieval.dataset_retrieval -> services.external_knowledge_service
|
||||
core.rag.index_processor.processor.paragraph_index_processor -> services.knowledge.summaries.adapters
|
||||
core.rag.index_processor.processor.parent_child_index_processor -> services.knowledge.summaries.adapters
|
||||
core.rag.index_processor.processor.qa_index_processor -> services.knowledge.summaries.adapters
|
||||
core.rag.retrieval.dataset_retrieval -> services.knowledge.external.service
|
||||
core.rag.retrieval.dataset_retrieval -> services.knowledge.retrieval.attachments
|
||||
core.rag.retrieval.dataset_retrieval -> services.feature_service
|
||||
core.rag.summary_index.summary_index -> services.summary_index_service
|
||||
core.rag.summary_index.summary_index -> services.knowledge.summaries.adapters
|
||||
core.rag.summary_index.summary_index -> tasks.generate_summary_index_task
|
||||
core.repositories.sqlalchemy_workflow_node_execution_repository -> services.file_service
|
||||
core.repositories.sqlalchemy_workflow_node_execution_repository -> services.variable_truncator
|
||||
core.tools.tool_manager -> services
|
||||
core.workflow.node_runtime -> services
|
||||
# Node construction delegates datasource dependency assembly to the composition root.
|
||||
core.workflow.node_factory -> extensions.application_services.data_sources
|
||||
core.workflow.nodes.agent.message_transformer -> services
|
||||
core.workflow.nodes.agent_v2.agent_node -> services.agent.prompt_mentions
|
||||
core.workflow.nodes.agent_v2.agent_node -> services.agent.workspace_service
|
||||
@@ -99,7 +97,7 @@ ignore_imports =
|
||||
core.workflow.nodes.agent_v2.workspace_retirement_layer -> tasks.collect_agent_resources_task
|
||||
libs.email_i18n -> services.entities.feature_entities
|
||||
libs.email_i18n -> services.system_feature_service
|
||||
libs.external_api -> core
|
||||
libs.external_api -> core.plugin
|
||||
libs.external_api -> core.errors.error
|
||||
libs.external_api -> extensions.ext_logging
|
||||
libs.flask_utils -> models
|
||||
@@ -114,8 +112,6 @@ ignore_imports =
|
||||
libs.oauth_bearer -> models
|
||||
libs.rsa -> extensions.ext_storage
|
||||
libs.workspace_permission -> services.enterprise.enterprise_service
|
||||
services.account_service -> controllers
|
||||
services.account_service -> controllers.console.error
|
||||
services.app_generate_service -> controllers.console.app.workflow
|
||||
|
||||
[importlinter:contract:no-direct-rsa-imports]
|
||||
@@ -155,18 +151,159 @@ forbidden_modules =
|
||||
werkzeug
|
||||
|
||||
[importlinter:contract:workspace-query-service-boundary]
|
||||
name = Workspace query application service is framework and persistence neutral
|
||||
name = Workspace application services and contracts are framework and persistence neutral
|
||||
type = forbidden
|
||||
source_modules =
|
||||
services.workspace_query_service
|
||||
services.workspace.service
|
||||
services.workspace.provisioning_service
|
||||
services.workspace.contracts
|
||||
forbidden_modules =
|
||||
configs
|
||||
controllers
|
||||
extensions
|
||||
flask
|
||||
models
|
||||
repositories
|
||||
services.billing_service
|
||||
services.feature_service
|
||||
services.workspace.gateways
|
||||
sqlalchemy
|
||||
werkzeug
|
||||
|
||||
[importlinter:contract:dataset-application-boundary]
|
||||
name = Dataset application services and contracts are framework and persistence neutral
|
||||
type = forbidden
|
||||
source_modules =
|
||||
core.rag.extractor.entity.extract_setting
|
||||
services.knowledge.entities.segments
|
||||
services.knowledge.entities.datasets
|
||||
services.knowledge.entities.document_creation
|
||||
services.data_source.binding_application_service
|
||||
services.data_source.credential_gateway
|
||||
services.data_source.notion_import_application_service
|
||||
services.data_source.entities.credential
|
||||
services.data_source.entities.notion_import
|
||||
services.knowledge.entities.indexing_estimate
|
||||
services.knowledge.dataset_access
|
||||
services.knowledge.datasets.application
|
||||
services.knowledge.datasets.retrieval
|
||||
services.knowledge.document_sync
|
||||
services.knowledge.documents.application
|
||||
services.knowledge.external.application
|
||||
services.knowledge.indexing.errors
|
||||
services.knowledge.indexing.estimate
|
||||
services.knowledge.indexing.execution
|
||||
services.knowledge.resource_scope
|
||||
services.knowledge.segments.application
|
||||
services.knowledge.metadata.application
|
||||
services.knowledge.summaries.application
|
||||
forbidden_modules =
|
||||
fields
|
||||
configs
|
||||
controllers
|
||||
extensions
|
||||
flask
|
||||
models
|
||||
repositories
|
||||
sqlalchemy
|
||||
tasks
|
||||
werkzeug
|
||||
services.knowledge.dataset_service
|
||||
services.data_source.provider_service
|
||||
services.knowledge.indexing.adapters
|
||||
services.knowledge.segments.adapters
|
||||
services.knowledge.segments.indexing
|
||||
core.datasource.datasource_manager
|
||||
core.rag.extractor.notion_extractor
|
||||
|
||||
[importlinter:contract:knowledge-repository-boundary]
|
||||
name = Knowledge persistence adapters only depend on their consumer-owned application contracts
|
||||
type = forbidden
|
||||
source_modules =
|
||||
repositories.knowledge
|
||||
forbidden_modules =
|
||||
controllers
|
||||
extensions
|
||||
flask
|
||||
services
|
||||
tasks
|
||||
werkzeug
|
||||
allow_indirect_imports = True
|
||||
ignore_imports =
|
||||
repositories.knowledge.dataset_api_key_repository -> services.auth.api_key_contracts
|
||||
repositories.knowledge.dataset_api_key_repository -> services.knowledge.api_key_service
|
||||
repositories.knowledge.dataset_repository -> services.knowledge.segments.application
|
||||
repositories.knowledge.dataset_repository -> services.knowledge.dataset_access
|
||||
repositories.knowledge.dataset_repository -> services.knowledge.indexing.estimate
|
||||
repositories.knowledge.dataset_repository -> services.knowledge.resource_scope
|
||||
repositories.knowledge.document_repository -> services.knowledge.document_sync
|
||||
repositories.knowledge.document_repository -> services.knowledge.indexing.estimate
|
||||
repositories.knowledge.document_repository -> services.knowledge.indexing.errors
|
||||
repositories.knowledge.document_repository -> services.knowledge.indexing.execution
|
||||
repositories.knowledge.document_repository -> services.knowledge.resource_scope
|
||||
repositories.knowledge.segment_repository -> services.knowledge.resource_scope
|
||||
repositories.knowledge.segment_repository -> services.knowledge.indexing.execution
|
||||
repositories.knowledge.segment_repository -> services.knowledge.segments.application
|
||||
repositories.knowledge.segment_repository -> services.knowledge.entities.segments
|
||||
repositories.knowledge.metadata_repository -> services.errors.metadata
|
||||
repositories.knowledge.metadata_repository -> services.knowledge.metadata.application
|
||||
repositories.knowledge.metadata_repository -> services.knowledge.resource_scope
|
||||
repositories.knowledge.summary_repository -> services.knowledge.summaries.application
|
||||
|
||||
[importlinter:contract:datasource-credential-repository-boundary]
|
||||
name = Data source credential persistence does not depend on SQLAlchemy service helpers
|
||||
type = forbidden
|
||||
source_modules =
|
||||
repositories.credentials.query_repository
|
||||
repositories.data_source.credential_repository
|
||||
forbidden_modules =
|
||||
services.data_source.provider_service
|
||||
allow_indirect_imports = True
|
||||
|
||||
[importlinter:contract:datasource-provider-persistence-boundary]
|
||||
name = Datasource provider service consumes injected persistence contracts
|
||||
type = forbidden
|
||||
source_modules =
|
||||
services.data_source.provider_service
|
||||
forbidden_modules =
|
||||
repositories
|
||||
extensions.application_services
|
||||
extensions.ext_application_services
|
||||
allow_indirect_imports = True
|
||||
|
||||
[importlinter:contract:credential-permission-query-policy-boundary]
|
||||
name = Credential query contracts do not expose persistence implementations
|
||||
type = forbidden
|
||||
source_modules =
|
||||
services.credentials.query
|
||||
forbidden_modules =
|
||||
repositories
|
||||
sqlalchemy
|
||||
models
|
||||
extensions
|
||||
flask
|
||||
allow_indirect_imports = True
|
||||
|
||||
[importlinter:contract:credential-query-implementation-boundary]
|
||||
name = Credential query implementations are wired only by composition or other repositories
|
||||
type = forbidden
|
||||
source_modules =
|
||||
controllers
|
||||
services
|
||||
core
|
||||
forbidden_modules =
|
||||
repositories.credentials.query_repository
|
||||
allow_indirect_imports = True
|
||||
|
||||
[importlinter:contract:pipeline-document-lookup-boundary]
|
||||
name = Pipeline core depends on its document store port
|
||||
type = forbidden
|
||||
source_modules =
|
||||
core.app.apps.pipeline.pipeline_generator
|
||||
core.app.apps.pipeline.pipeline_runner
|
||||
forbidden_modules =
|
||||
repositories
|
||||
allow_indirect_imports = True
|
||||
|
||||
[importlinter:contract:init-validation-service-boundary]
|
||||
name = Initialization validation application service is framework and persistence neutral
|
||||
@@ -295,16 +432,16 @@ source_modules =
|
||||
services.account_avatar_service
|
||||
services.account_change_email_ports
|
||||
services.account_change_email_service
|
||||
services.account_email_registration_service
|
||||
services.account.email_registration_service
|
||||
services.account_deletion_service
|
||||
services.account_deletion_feedback_service
|
||||
services.account_education_service
|
||||
services.account_errors
|
||||
services.account_forgot_password_service
|
||||
services.account.forgot_password_service
|
||||
services.account_initialization_service
|
||||
services.account_integration_service
|
||||
services.account_login_service
|
||||
services.account_oauth_service
|
||||
services.account.login_service
|
||||
services.account.oauth_service
|
||||
services.account_password_service
|
||||
services.account_ports
|
||||
services.account_profile_service
|
||||
@@ -443,6 +580,35 @@ forbidden_modules =
|
||||
sqlalchemy
|
||||
werkzeug
|
||||
|
||||
[importlinter:contract:web-authentication-service-boundary]
|
||||
name = Web authentication application service is framework and persistence neutral
|
||||
type = forbidden
|
||||
source_modules =
|
||||
services.web_authentication_service
|
||||
services.entities.authentication_entities
|
||||
forbidden_modules =
|
||||
configs
|
||||
controllers
|
||||
extensions
|
||||
flask
|
||||
models
|
||||
repositories
|
||||
sqlalchemy
|
||||
werkzeug
|
||||
|
||||
[importlinter:contract:web-authentication-adapters-persistence-boundary]
|
||||
name = Web authentication adapters do not access persistence directly
|
||||
type = forbidden
|
||||
source_modules =
|
||||
services.web_authentication_adapters
|
||||
allow_indirect_imports = True
|
||||
forbidden_modules =
|
||||
configs
|
||||
extensions.ext_database
|
||||
models
|
||||
repositories
|
||||
sqlalchemy
|
||||
|
||||
[importlinter:contract:web-app-runtime-query-service-boundary]
|
||||
name = Web app runtime query application service does not directly depend on configuration, feature implementation, transport, or ORM modules
|
||||
type = forbidden
|
||||
@@ -479,7 +645,7 @@ forbidden_modules =
|
||||
name = Data source OAuth application service is framework and persistence neutral
|
||||
type = forbidden
|
||||
source_modules =
|
||||
services.data_source_oauth_service
|
||||
services.data_source.oauth_service
|
||||
forbidden_modules =
|
||||
configs
|
||||
controllers
|
||||
@@ -531,7 +697,7 @@ forbidden_modules =
|
||||
name = Workspace member query application service is framework and persistence neutral
|
||||
type = forbidden
|
||||
source_modules =
|
||||
services.workspace_member_query_service
|
||||
services.workspace.member_service
|
||||
forbidden_modules =
|
||||
configs
|
||||
controllers
|
||||
@@ -540,7 +706,7 @@ forbidden_modules =
|
||||
models
|
||||
repositories
|
||||
services.enterprise
|
||||
services.workspace_member_role_resolver
|
||||
services.workspace.gateways
|
||||
sqlalchemy
|
||||
werkzeug
|
||||
|
||||
@@ -666,6 +832,36 @@ forbidden_modules =
|
||||
sqlalchemy
|
||||
werkzeug
|
||||
|
||||
[importlinter:contract:api-based-extension-application-service-boundary]
|
||||
name = API-based extension application service is framework and persistence neutral
|
||||
type = forbidden
|
||||
source_modules =
|
||||
services.api_based_extension_application_service
|
||||
forbidden_modules =
|
||||
configs
|
||||
controllers
|
||||
core
|
||||
extensions
|
||||
flask
|
||||
models
|
||||
repositories
|
||||
sqlalchemy
|
||||
werkzeug
|
||||
|
||||
[importlinter:contract:console-extension-controller-boundary]
|
||||
name = Console extension controller reaches persistence only through the composition root
|
||||
type = forbidden
|
||||
source_modules =
|
||||
controllers.console.extension
|
||||
forbidden_modules =
|
||||
extensions.ext_database
|
||||
models
|
||||
repositories
|
||||
sqlalchemy
|
||||
# The composition root (extensions.ext_application_services) wires repositories,
|
||||
# so only direct imports are checked here.
|
||||
allow_indirect_imports = True
|
||||
|
||||
[importlinter:contract:tool-file-download-service-boundary]
|
||||
name = Tool file download application service is framework and persistence neutral
|
||||
type = forbidden
|
||||
@@ -697,6 +893,20 @@ forbidden_modules =
|
||||
sqlalchemy
|
||||
werkzeug
|
||||
|
||||
[importlinter:contract:recommended-app-package-service-boundary]
|
||||
name = Recommended package application service is framework and persistence neutral
|
||||
type = forbidden
|
||||
source_modules =
|
||||
services.recommended_app_package_service
|
||||
forbidden_modules =
|
||||
controllers
|
||||
extensions
|
||||
flask
|
||||
models
|
||||
repositories
|
||||
sqlalchemy
|
||||
werkzeug
|
||||
|
||||
[importlinter:contract:recommended-app-catalog-gateway-boundary]
|
||||
name = Recommended app catalog gateway does not depend on Flask
|
||||
type = forbidden
|
||||
@@ -786,6 +996,86 @@ forbidden_modules =
|
||||
tasks
|
||||
werkzeug
|
||||
|
||||
[importlinter:contract:knowledge-segment-presentation-boundary]
|
||||
name = Knowledge operations do not serialize HTTP responses
|
||||
type = forbidden
|
||||
allow_indirect_imports = True
|
||||
source_modules =
|
||||
services.knowledge.segments
|
||||
services.knowledge.documents
|
||||
services.knowledge.datasets
|
||||
services.knowledge.external
|
||||
forbidden_modules =
|
||||
controllers
|
||||
fields
|
||||
|
||||
[importlinter:contract:knowledge-response-persistence-boundary]
|
||||
name = Knowledge response schemas do not load database state
|
||||
type = forbidden
|
||||
allow_indirect_imports = True
|
||||
source_modules =
|
||||
fields.dataset_fields
|
||||
fields.document_fields
|
||||
fields.segment_fields
|
||||
forbidden_modules =
|
||||
sqlalchemy
|
||||
models
|
||||
repositories
|
||||
services.knowledge
|
||||
|
||||
[importlinter:contract:dataset-model-infrastructure-boundary]
|
||||
name = Dataset models do not execute infrastructure or application operations
|
||||
type = forbidden
|
||||
allow_indirect_imports = True
|
||||
source_modules =
|
||||
models.dataset
|
||||
forbidden_modules =
|
||||
repositories
|
||||
services
|
||||
extensions
|
||||
core.tools
|
||||
models.engine
|
||||
|
||||
[importlinter:contract:app-mcp-server-service-boundary]
|
||||
name = App MCP server application service is framework and persistence neutral
|
||||
type = forbidden
|
||||
source_modules =
|
||||
services.app.mcp_server_service
|
||||
forbidden_modules =
|
||||
configs
|
||||
controllers
|
||||
extensions
|
||||
flask
|
||||
models
|
||||
repositories
|
||||
sqlalchemy
|
||||
werkzeug
|
||||
|
||||
[importlinter:contract:console-app-mcp-server-controller-boundary]
|
||||
name = Console App MCP server controller reaches persistence only through the composition root
|
||||
type = forbidden
|
||||
source_modules =
|
||||
controllers.console.app.mcp_server
|
||||
forbidden_modules =
|
||||
extensions.ext_database
|
||||
models.model
|
||||
repositories
|
||||
sqlalchemy
|
||||
# The composition root (extensions.ext_application_services) wires repositories,
|
||||
# so only direct imports are checked here.
|
||||
allow_indirect_imports = True
|
||||
|
||||
[importlinter:contract:console-app-wraps-boundary]
|
||||
name = Console app decorators load App rows without reaching into repositories
|
||||
type = forbidden
|
||||
source_modules =
|
||||
controllers.console.app.wraps
|
||||
forbidden_modules =
|
||||
repositories
|
||||
# models.model / models.workflow lazily import repositories.factory (legacy debt),
|
||||
# so only direct imports are checked here.
|
||||
allow_indirect_imports = True
|
||||
|
||||
[importlinter:contract:app-audio-boundary]
|
||||
name = App audio operations and values are framework and persistence neutral
|
||||
type = forbidden
|
||||
@@ -801,3 +1091,20 @@ forbidden_modules =
|
||||
services.audio_service
|
||||
sqlalchemy
|
||||
werkzeug
|
||||
|
||||
[importlinter:contract:account-lifecycle-boundary]
|
||||
name = Account lifecycle use cases and contracts are framework and persistence neutral
|
||||
type = forbidden
|
||||
source_modules =
|
||||
services.account.service
|
||||
services.account.contracts
|
||||
forbidden_modules =
|
||||
configs
|
||||
controllers
|
||||
extensions
|
||||
flask
|
||||
models
|
||||
repositories
|
||||
services.account.adapters
|
||||
sqlalchemy
|
||||
werkzeug
|
||||
|
||||
@@ -15,7 +15,8 @@ Run direct Python commands through `uv run --project api`. Docker-backed integra
|
||||
|
||||
## Architecture And Boundaries
|
||||
|
||||
- Keep transport parsing and serialization in controllers, orchestration in services, and domain policy in `core/` or its domain owner. Keep `libs/` business-agnostic and reuse existing owners before adding abstractions.
|
||||
- Treat `api/core/` as a migration-only area: do not add files or move implementations into it. Move responsibilities out; limit changes to existing callers to the adaptations needed for that migration.
|
||||
- Keep transport parsing and serialization in controllers, orchestration in services, and domain policy in its domain owner. New or extracted implementations must stay outside `core/`. Keep `libs/` business-agnostic and reuse existing owners before adding abstractions.
|
||||
- Before changing controller schemas, generated API contracts, or `SystemFeatureModel`, read `controllers/API_SCHEMA_GUIDE.md`. Treat `/system-features` as a minimal unauthenticated bootstrap allowlist, not a general configuration registry.
|
||||
- Scope tenant-owned reads and writes by the complete owner chain, and propagate `tenant_id` across every affected layer. Reconstruct trusted internal references from validated database state after payload or async boundaries.
|
||||
- Keep write transactions explicit and bounded. Do not perform external I/O inside an open transaction unless a documented consistency contract requires it.
|
||||
|
||||
@@ -2,19 +2,11 @@
|
||||
|
||||
## Setup and Run
|
||||
|
||||
> [!IMPORTANT]
|
||||
>
|
||||
> In the v1.3.0 release, `poetry` has been replaced with
|
||||
> [`uv`](https://docs.astral.sh/uv/) as the package manager
|
||||
> for Dify API backend service.
|
||||
|
||||
`uv` and `pnpm` are required to run the setup and development commands below.
|
||||
[`uv`](https://docs.astral.sh/uv/), Docker Compose, and the Node.js and pnpm versions pinned in the root `package.json` are required. Run the commands below from the repository root, with each long-running service in its own terminal.
|
||||
|
||||
### Using scripts (recommended)
|
||||
|
||||
The scripts resolve paths relative to their location, so you can run them from anywhere.
|
||||
|
||||
1. Run setup (copies env files and installs dependencies).
|
||||
1. Run setup for a new checkout (copies env files and installs dependencies). This overwrites `api/.env`, `web/.env.local`, and `docker/middleware.env`; preserve existing configuration before rerunning it.
|
||||
|
||||
```bash
|
||||
./dev/setup
|
||||
@@ -44,7 +36,7 @@ The scripts resolve paths relative to their location, so you can run them from a
|
||||
|
||||
1. Set up your application by visiting `http://localhost:3000`.
|
||||
|
||||
1. Start the worker service (async and scheduler tasks, runs from `api`).
|
||||
1. Start the worker service (executes queued async tasks).
|
||||
|
||||
```bash
|
||||
./dev/start-worker
|
||||
@@ -62,51 +54,35 @@ The scripts resolve paths relative to their location, so you can run them from a
|
||||
>
|
||||
> When the frontend and backend run on different subdomains, set COOKIE_DOMAIN to the site’s top-level domain (e.g., `example.com`). The frontend and backend must be under the same top-level domain in order to share authentication cookies.
|
||||
|
||||
- Generate a `SECRET_KEY` in the `.env` file.
|
||||
|
||||
bash for Linux
|
||||
|
||||
```bash
|
||||
sed -i "/^SECRET_KEY=/c\\SECRET_KEY=$(openssl rand -base64 42)" .env
|
||||
```
|
||||
|
||||
bash for Mac
|
||||
|
||||
```bash
|
||||
secret_key=$(openssl rand -base64 42)
|
||||
sed -i '' "/^SECRET_KEY=/c\\
|
||||
SECRET_KEY=${secret_key}" .env
|
||||
```
|
||||
Leave `SECRET_KEY` empty to let Dify generate a persistent key in the storage directory. To manage it explicitly, generate a value with `openssl rand -base64 42` and set `SECRET_KEY` in `api/.env`.
|
||||
|
||||
## Testing
|
||||
|
||||
1. Install dependencies for both the backend and the test environment
|
||||
Run from the repository root:
|
||||
|
||||
```bash
|
||||
cd api
|
||||
uv sync --group dev
|
||||
```
|
||||
|
||||
1. Run the tests locally with mocked system environment variables in `tool.pytest_env` section in `pyproject.toml`, more can check [AGENTS.md](../AGENTS.md)
|
||||
|
||||
Continue in the `api` directory from the previous step.
|
||||
|
||||
```bash
|
||||
uv run pytest # Run all tests
|
||||
uv run pytest tests/unit_tests/ # Unit tests only
|
||||
uv run pytest tests/integration_tests/ # Integration tests
|
||||
|
||||
# Code quality
|
||||
../dev/reformat # Run all formatters and linters
|
||||
uv run ruff check --fix ./ # Fix linting issues
|
||||
uv run ruff format ./ # Format code
|
||||
uv run pyrefly check # Type checking
|
||||
```
|
||||
|
||||
## Generate TS stub
|
||||
|
||||
```
|
||||
uv run dev/generate_swagger_specs.py --output-dir openapi
|
||||
```bash
|
||||
uv sync --project api --group dev
|
||||
make test
|
||||
make test TARGET_TESTS=./api/tests/unit_tests/<path>
|
||||
make lint
|
||||
make type-check
|
||||
```
|
||||
|
||||
use https://jsontotable.org/openapi-to-typescript to convert to typescript
|
||||
`make test` includes provider unit tests and runs controller tests separately. Integration suites are CI-only and are not expected to run locally. Test environment defaults live in `api/pyproject.toml` under `tool.pytest_env`; see the backend [agent guide](AGENTS.md) for package conventions.
|
||||
|
||||
## API contracts
|
||||
|
||||
The [API schema guide](controllers/API_SCHEMA_GUIDE.md) owns schema changes and verification. Generate the OpenAPI specifications and TypeScript/Zod contracts through the workspace script:
|
||||
|
||||
```bash
|
||||
pnpm -C packages/contracts gen-api-contract
|
||||
```
|
||||
|
||||
Refresh the checked-in Markdown reference with:
|
||||
|
||||
```bash
|
||||
uv run --project api python api/dev/generate_swagger_markdown_docs.py \
|
||||
--swagger-dir packages/contracts/openapi --markdown-dir api/openapi/markdown --keep-swagger-json
|
||||
```
|
||||
|
||||
Update the backend schema owner and regenerate contracts instead of converting types manually or editing generated files.
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
import base64
|
||||
import secrets
|
||||
|
||||
import click
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from constants.languages import languages
|
||||
from extensions.ext_database import db
|
||||
from extensions.ext_application_services import application_services
|
||||
from libs.helper import email as email_validate
|
||||
from libs.password import hash_password, password_pattern, valid_password
|
||||
from services.account_email import normalize_email
|
||||
from services.account_service import AccountService, RegisterService, TenantService
|
||||
from libs.password import password_pattern, valid_password
|
||||
|
||||
|
||||
@click.command("reset-password", help="Reset the account password.")
|
||||
@@ -26,7 +22,7 @@ def reset_password(email, new_password, password_confirm):
|
||||
return
|
||||
normalized_email = email.strip().lower()
|
||||
|
||||
account = AccountService.get_account_by_email_with_case_fallback(email.strip(), session=db.session())
|
||||
account = application_services().accounts.lifecycle.get_account_by_email_with_case_fallback(email.strip())
|
||||
|
||||
if not account:
|
||||
click.echo(click.style(f"Account not found for email: {email}", fg="red"))
|
||||
@@ -38,19 +34,7 @@ def reset_password(email, new_password, password_confirm):
|
||||
click.echo(click.style(f"Invalid password. Must match {password_pattern}", fg="red"))
|
||||
return
|
||||
|
||||
# generate password salt
|
||||
salt = secrets.token_bytes(16)
|
||||
base64_salt = base64.b64encode(salt).decode()
|
||||
|
||||
# encrypt password with salt
|
||||
password_hashed = hash_password(new_password, salt)
|
||||
base64_password_hashed = base64.b64encode(password_hashed).decode()
|
||||
with Session(db.engine) as session:
|
||||
account = session.merge(account)
|
||||
account.password = base64_password_hashed
|
||||
account.password_salt = base64_salt
|
||||
session.commit()
|
||||
AccountService.reset_login_error_rate_limit(normalized_email)
|
||||
application_services().accounts.lifecycle.reset_password(account.id, new_password, email=normalized_email)
|
||||
click.echo(click.style("Password reset successfully.", fg="green"))
|
||||
|
||||
|
||||
@@ -68,7 +52,7 @@ def reset_email(email, new_email, email_confirm):
|
||||
return
|
||||
normalized_new_email = new_email.strip().lower()
|
||||
|
||||
account = AccountService.get_account_by_email_with_case_fallback(email.strip(), session=db.session())
|
||||
account = application_services().accounts.lifecycle.get_account_by_email_with_case_fallback(email.strip())
|
||||
|
||||
if not account:
|
||||
click.echo(click.style(f"Account not found for email: {email}", fg="red"))
|
||||
@@ -80,11 +64,7 @@ def reset_email(email, new_email, email_confirm):
|
||||
click.echo(click.style(f"Invalid email: {new_email}", fg="red"))
|
||||
return
|
||||
|
||||
with Session(db.engine) as session:
|
||||
account = session.merge(account)
|
||||
account.email = normalized_new_email
|
||||
account.normalized_email = normalize_email(normalized_new_email)
|
||||
session.commit()
|
||||
application_services().accounts.lifecycle.set_email(account.id, normalized_new_email)
|
||||
click.echo(click.style("Email updated successfully.", fg="green"))
|
||||
|
||||
|
||||
@@ -129,15 +109,14 @@ def create_tenant(email: str, language: str | None = None, name: str | None = No
|
||||
return
|
||||
|
||||
# register account
|
||||
account = RegisterService.register(
|
||||
account = application_services().accounts.lifecycle.register(
|
||||
email=email,
|
||||
name=account_name,
|
||||
password=new_password,
|
||||
language=language,
|
||||
create_workspace_required=False,
|
||||
session=db.session(),
|
||||
)
|
||||
TenantService.create_owner_tenant_if_not_exist(account, name, session=db.session())
|
||||
application_services().workspaces.provisioning.create_owner_workspace(account.id, name=name, if_missing=True)
|
||||
|
||||
click.echo(
|
||||
click.style(
|
||||
|
||||
@@ -463,6 +463,36 @@ def _workspace_member_account_id_batches(tenant_id: str, batch_size: int) -> Ite
|
||||
last_join_id = str(rows[-1].id)
|
||||
|
||||
|
||||
def _workspace_non_admin_account_id_batches(
|
||||
tenant_id: str,
|
||||
operator_account_id: str,
|
||||
batch_size: int,
|
||||
) -> Iterator[list[str]]:
|
||||
"""Yield workspace members except accounts bound to RBAC's builtin Owner/Admin roles.
|
||||
|
||||
Resource administrators receive access implicitly through RBAC and their
|
||||
resource access-policy bindings are locked. Resolve their current RBAC
|
||||
bindings in batches instead of relying on the legacy workspace role column.
|
||||
"""
|
||||
for batch in _workspace_member_account_id_batches(tenant_id, batch_size):
|
||||
member_roles = RBACService.MemberRoles.batch_get(
|
||||
tenant_id=tenant_id,
|
||||
account_id=operator_account_id,
|
||||
member_account_ids=batch,
|
||||
)
|
||||
protected_account_ids = {
|
||||
member.account_id
|
||||
for member in member_roles
|
||||
if any(
|
||||
role.is_builtin and role.category == "global_system_default" and role.role_tag in {"owner", "admin"}
|
||||
for role in member.roles
|
||||
)
|
||||
}
|
||||
writable_batch = [account_id for account_id in batch if account_id not in protected_account_ids]
|
||||
if writable_batch:
|
||||
yield writable_batch
|
||||
|
||||
|
||||
def _replace_resource_whitelist(
|
||||
resource_type: str,
|
||||
*,
|
||||
@@ -725,7 +755,7 @@ def migrate_resource_whitelist_scopes_to_automatic_include(
|
||||
migrated_count += 1
|
||||
|
||||
if scope is RBACResourceWhitelistScope.ALL:
|
||||
for batch in _workspace_member_account_id_batches(workspace_id, member_batch_size):
|
||||
for batch in _workspace_non_admin_account_id_batches(workspace_id, operator_account_id, member_batch_size):
|
||||
_replace_resource_default_access_policies(
|
||||
current_resource_type,
|
||||
tenant_id=workspace_id,
|
||||
@@ -877,7 +907,7 @@ def migrate_only_me_resource_whitelist_scopes_to_automatic_include(
|
||||
)
|
||||
migrated_count += 1
|
||||
|
||||
for batch in _workspace_member_account_id_batches(workspace_id, member_batch_size):
|
||||
for batch in _workspace_non_admin_account_id_batches(workspace_id, operator_account_id, member_batch_size):
|
||||
_replace_resource_default_access_policies(
|
||||
current_resource_type,
|
||||
tenant_id=workspace_id,
|
||||
@@ -1187,7 +1217,11 @@ def _report_backing_app_specific_whitelist(options: _AgentAccessBootstrapOptions
|
||||
|
||||
|
||||
def _write_agent_access_rows(options: _AgentAccessBootstrapOptions) -> None:
|
||||
for batch in _workspace_member_account_id_batches(options.tenant_id, options.member_batch_size):
|
||||
for batch in _workspace_non_admin_account_id_batches(
|
||||
options.tenant_id,
|
||||
options.operator_account_id,
|
||||
options.member_batch_size,
|
||||
):
|
||||
RBACService.AgentAccess.replace_user_access_policies(
|
||||
tenant_id=options.tenant_id,
|
||||
account_id=options.operator_account_id,
|
||||
|
||||
@@ -19,6 +19,7 @@ from models.dataset import Dataset, DatasetCollectionBinding, DatasetMetadata, D
|
||||
from models.dataset import Document as DatasetDocument
|
||||
from models.enums import DatasetMetadataType, IndexingStatus, SegmentStatus
|
||||
from models.model import App, AppAnnotationSetting, MessageAnnotation
|
||||
from repositories.knowledge.dataset_read_repository import get_segment_child_chunks
|
||||
|
||||
|
||||
@click.command("vdb-migrate", help="Migrate vector db.")
|
||||
@@ -278,7 +279,7 @@ def migrate_knowledge_vector_database():
|
||||
},
|
||||
)
|
||||
if dataset_document.doc_form == IndexStructureType.PARENT_CHILD_INDEX:
|
||||
child_chunks = segment.get_child_chunks(session=db_session)
|
||||
child_chunks = get_segment_child_chunks(segment, session=db_session)
|
||||
if child_chunks:
|
||||
child_documents = []
|
||||
for child_chunk in child_chunks:
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import Field
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
@@ -41,3 +43,8 @@ class ArchiveStorageConfig(BaseSettings):
|
||||
description="Region for storage (use 'auto' if the provider supports it)",
|
||||
default="auto",
|
||||
)
|
||||
|
||||
ARCHIVE_STORAGE_ADDRESS_STYLE: Literal["path", "virtual", "auto"] = Field(
|
||||
description="S3 addressing style for archive storage: 'path' (default), 'virtual' (virtual-hosted), or 'auto'",
|
||||
default="path",
|
||||
)
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
from collections import OrderedDict
|
||||
from http import HTTPStatus
|
||||
from typing import cast
|
||||
|
||||
from flask import current_app, got_request_exception
|
||||
from flask_restx import Api
|
||||
|
||||
from libs.exception import BaseHTTPException
|
||||
from services.errors.base import NoPermissionError
|
||||
|
||||
|
||||
class FilenameNotExistsError(BaseHTTPException):
|
||||
@@ -84,6 +92,39 @@ class NotFoundError(BaseHTTPException):
|
||||
code = 404
|
||||
|
||||
|
||||
class UnauthorizedError(BaseHTTPException):
|
||||
error_code = "unauthorized"
|
||||
code = HTTPStatus.UNAUTHORIZED
|
||||
description = "Authentication is required."
|
||||
|
||||
|
||||
class InternalServerError(BaseHTTPException):
|
||||
"""Expose a safe response while retaining the original exception in server logs."""
|
||||
|
||||
error_code = "internal_server_error"
|
||||
code = HTTPStatus.INTERNAL_SERVER_ERROR
|
||||
description = (
|
||||
"The server encountered an internal error and was unable to complete your request. "
|
||||
"Either the server is overloaded or there is an error in the application."
|
||||
)
|
||||
|
||||
|
||||
class InvalidArgumentError(BaseHTTPException):
|
||||
error_code = "invalid_param"
|
||||
code = 400
|
||||
|
||||
|
||||
def register_permission_error_handler(api: Api) -> None:
|
||||
"""Preserve the default permission-error response for Console and Service API."""
|
||||
|
||||
@api.errorhandler(NoPermissionError)
|
||||
def handle_permission_error(error: NoPermissionError) -> tuple[dict[str, str | int], int]:
|
||||
got_request_exception.send(current_app, exception=error)
|
||||
return {
|
||||
"code": InvalidArgumentError.error_code,
|
||||
"message": str(error),
|
||||
"status": HTTPStatus.BAD_REQUEST,
|
||||
}, HTTPStatus.BAD_REQUEST
|
||||
|
||||
# Flask-RESTX picks the first matching handler, including the generic Exception handler.
|
||||
cast(OrderedDict[type[Exception], object], api.error_handlers).move_to_end(NoPermissionError, last=False)
|
||||
|
||||
@@ -4,6 +4,7 @@ from .checks import RBAC_CHECKS_ATTR, RBACCheck, enforce_rbac_checks
|
||||
from .locators import (
|
||||
AgentBehindApp,
|
||||
AgentId,
|
||||
DatasetByDocument,
|
||||
DatasetByPipeline,
|
||||
DatasetId,
|
||||
PlainApp,
|
||||
@@ -16,6 +17,7 @@ __all__ = [
|
||||
"RBAC_CHECKS_ATTR",
|
||||
"AgentBehindApp",
|
||||
"AgentId",
|
||||
"DatasetByDocument",
|
||||
"DatasetByPipeline",
|
||||
"DatasetId",
|
||||
"PlainApp",
|
||||
|
||||
@@ -15,6 +15,7 @@ if TYPE_CHECKING:
|
||||
__all__ = [
|
||||
"AgentBehindApp",
|
||||
"AgentId",
|
||||
"DatasetByDocument",
|
||||
"DatasetByPipeline",
|
||||
"DatasetId",
|
||||
"PlainApp",
|
||||
@@ -116,7 +117,7 @@ class AgentBehindApp(_ParamLocator):
|
||||
binding = agent_binding(tenant_id, _required(path_args, self.param))
|
||||
if binding is None or binding.scope == AgentScope.WORKFLOW_ONLY:
|
||||
return None
|
||||
return ResourceIdentity(self.scope, str(binding.id))
|
||||
return ResourceIdentity(self.scope, binding.id)
|
||||
|
||||
def owner_id(self, tenant_id: str, identity: ResourceIdentity) -> str | None:
|
||||
return None
|
||||
@@ -133,6 +134,18 @@ class DatasetId(_ParamLocator):
|
||||
return RBACResourceService.get_dataset_maintainer(db.session, tenant_id, identity.id)
|
||||
|
||||
|
||||
class DatasetByDocument(DatasetId):
|
||||
default_param = "document_id"
|
||||
|
||||
@override
|
||||
def locate(self, tenant_id: str, path_args: Mapping[str, object]) -> ResourceIdentity | None:
|
||||
document_id = _required(path_args, self.param)
|
||||
dataset_id = RBACResourceService.get_dataset_id_by_document(db.session, tenant_id, document_id)
|
||||
if dataset_id is None:
|
||||
raise NotFound("Document not found")
|
||||
return ResourceIdentity(self.scope, dataset_id)
|
||||
|
||||
|
||||
class DatasetByPipeline(DatasetId):
|
||||
default_param = "pipeline_id"
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ from typing import cast
|
||||
from flask import Blueprint, current_app, got_request_exception
|
||||
from flask_restx import Namespace
|
||||
|
||||
from controllers.common.errors import register_permission_error_handler
|
||||
from libs.external_api import ExternalApi
|
||||
from machinery.errors import ActiveWorkspaceRequiredError
|
||||
|
||||
@@ -17,6 +18,7 @@ api = ExternalApi(
|
||||
title="Console API",
|
||||
description="Console management APIs for app configuration, monitoring, and administration",
|
||||
)
|
||||
register_permission_error_handler(api)
|
||||
|
||||
|
||||
@api.errorhandler(ActiveWorkspaceRequiredError)
|
||||
|
||||
@@ -24,7 +24,7 @@ from services.auth.api_key_contracts import (
|
||||
ApiKeyNotFoundError,
|
||||
ApiKeyResourceNotFoundError,
|
||||
)
|
||||
from services.errors.account import NoPermissionError
|
||||
from services.errors.base import NoPermissionError
|
||||
from services.knowledge.api_key_service import UnknownDatasetIdsError
|
||||
|
||||
API_KEY_EDIT_ROLES = frozenset({TenantAccountRole.OWNER, TenantAccountRole.ADMIN, TenantAccountRole.EDITOR})
|
||||
|
||||
@@ -7,14 +7,16 @@ import io
|
||||
import json
|
||||
from collections.abc import Callable, Mapping
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Literal
|
||||
from functools import wraps
|
||||
from typing import Any, Concatenate, Literal
|
||||
from uuid import UUID
|
||||
|
||||
from flask import Response, request, send_file, url_for
|
||||
from flask import Response, make_response, request, send_file, url_for
|
||||
from flask_restx import Resource
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from controllers.common.fields import BinaryFileResponse
|
||||
from controllers.common.rbac import AgentId, PlainApp, RBACCheck
|
||||
from controllers.common.schema import (
|
||||
query_params_from_model,
|
||||
@@ -25,6 +27,8 @@ from controllers.common.schema import (
|
||||
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.error import AppUnavailableError
|
||||
from controllers.console.app.preview_admission import get_preview_app
|
||||
from controllers.console.app.wraps import get_app_model
|
||||
from controllers.console.wraps import (
|
||||
RBACPermission,
|
||||
@@ -36,7 +40,11 @@ from controllers.console.wraps import (
|
||||
with_current_tenant_id,
|
||||
with_current_user,
|
||||
)
|
||||
from extensions.ext_application_services import application_services
|
||||
from fields.agent_fields import AgentAppComposerResponse
|
||||
from fields.base import ResponseModel
|
||||
from libs.flask_restx_compat import BINARY_RESPONSE_MEDIA_TYPES_VENDOR_KEY
|
||||
from libs.helper import dump_response
|
||||
from libs.login import login_required
|
||||
from models.account import Account
|
||||
from models.model import App, AppMode
|
||||
@@ -50,6 +58,8 @@ from services.agent_config_service import (
|
||||
ConfigPushPayload,
|
||||
ConfigPushSkillItem,
|
||||
)
|
||||
from services.app_definition_query_service import AppDefinitionUnavailableError
|
||||
from services.app_preview_query_service import AppPreviewRef
|
||||
|
||||
|
||||
class AgentConfigQuery(BaseModel):
|
||||
@@ -215,6 +225,7 @@ class AgentConfigDeleteResponse(ResponseModel):
|
||||
register_schema_models(console_ns, AgentConfigFileUploadPayload)
|
||||
register_response_schema_models(
|
||||
console_ns,
|
||||
BinaryFileResponse,
|
||||
AgentConfigDeleteResponse,
|
||||
AgentConfigDownloadResponse,
|
||||
AgentConfigFileItemResponse,
|
||||
@@ -239,7 +250,7 @@ register_response_schema_models(
|
||||
class _ResolvedConsoleTarget:
|
||||
tenant_id: str
|
||||
agent_id: str
|
||||
account_id: str
|
||||
account_id: str | None
|
||||
version_id: str
|
||||
version_kind: AgentConfigVersionKind
|
||||
|
||||
@@ -247,6 +258,51 @@ class _ResolvedConsoleTarget:
|
||||
_WORKFLOW_APP_MODES = [AppMode.WORKFLOW, AppMode.ADVANCED_CHAT]
|
||||
|
||||
|
||||
class TrialAgentConfigQuery(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
version_id: UUID | None = Field(default=None, description="Must match the current published template snapshot")
|
||||
|
||||
|
||||
class TrialAgentSkillFileQuery(TrialAgentConfigQuery):
|
||||
path: str = Field(description="Normalized member path inside the published Skill package")
|
||||
|
||||
|
||||
register_schema_models(console_ns, TrialAgentConfigQuery, TrialAgentSkillFileQuery)
|
||||
|
||||
|
||||
def _with_trial_config[T, Q: TrialAgentConfigQuery, **P](
|
||||
view: Callable[Concatenate[T, _ResolvedConsoleTarget, Q, P], Response | dict[str, object]],
|
||||
) -> Callable[Concatenate[T, Q, AppPreviewRef, P], Response]:
|
||||
@wraps(view)
|
||||
def decorated(self: T, query: Q, app: AppPreviewRef, /, *args: P.args, **kwargs: P.kwargs) -> Response:
|
||||
try:
|
||||
state = AgentAppComposerResponse.model_validate(
|
||||
application_services().app_previews.get_agent_composer(app=app)
|
||||
)
|
||||
snapshot = state.active_config_snapshot
|
||||
if snapshot is None or (query.version_id is not None and str(query.version_id) != snapshot.id):
|
||||
raise AgentConfigServiceError(
|
||||
"config_version_not_found", "published template version is unavailable", status_code=404
|
||||
)
|
||||
target = _ResolvedConsoleTarget(
|
||||
tenant_id=app.tenant_id,
|
||||
agent_id=state.agent.id,
|
||||
account_id=None,
|
||||
version_id=snapshot.id,
|
||||
version_kind=AgentConfigVersionKind.SNAPSHOT,
|
||||
)
|
||||
response = make_response(view(self, target, query, *args, **kwargs))
|
||||
except AppDefinitionUnavailableError:
|
||||
raise AppUnavailableError() from None
|
||||
except AgentConfigServiceError as exc:
|
||||
response = make_response(_handle(exc))
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
return response
|
||||
|
||||
return decorated
|
||||
|
||||
|
||||
def _service() -> AgentConfigService:
|
||||
return AgentConfigService()
|
||||
|
||||
@@ -481,6 +537,7 @@ def _read_single_upload() -> tuple[bytes, str]:
|
||||
|
||||
|
||||
def _skill_upload_response(target: _ResolvedConsoleTarget) -> tuple[dict[str, object], int]:
|
||||
assert target.account_id is not None
|
||||
return _upload_skill_for_target(
|
||||
tenant_id=target.tenant_id,
|
||||
agent_id=target.agent_id,
|
||||
@@ -517,6 +574,7 @@ def _upload_skill_for_target(
|
||||
def _file_upload_response(
|
||||
target: _ResolvedConsoleTarget, payload: AgentConfigFileUploadPayload
|
||||
) -> tuple[dict[str, object], int]:
|
||||
assert target.account_id is not None
|
||||
manifest = _service().push_file_for_console(
|
||||
tenant_id=target.tenant_id,
|
||||
agent_id=target.agent_id,
|
||||
@@ -616,6 +674,7 @@ def _skill_file_raw_download_response(target: _ResolvedConsoleTarget, name: str,
|
||||
|
||||
|
||||
def _skill_delete_response(target: _ResolvedConsoleTarget, name: str) -> dict[str, object]:
|
||||
assert target.account_id is not None
|
||||
_service().push_for_console(
|
||||
tenant_id=target.tenant_id,
|
||||
agent_id=target.agent_id,
|
||||
@@ -654,6 +713,7 @@ def _file_download_response(target: _ResolvedConsoleTarget, name: str) -> Respon
|
||||
|
||||
|
||||
def _file_delete_response(target: _ResolvedConsoleTarget, name: str) -> dict[str, object]:
|
||||
assert target.account_id is not None
|
||||
_service().push_for_console(
|
||||
tenant_id=target.tenant_id,
|
||||
agent_id=target.agent_id,
|
||||
@@ -1360,5 +1420,124 @@ class AgentConfigFileApi(Resource):
|
||||
)
|
||||
|
||||
|
||||
@console_ns.route("/trial-apps/<uuid:app_id>/agent/config/skills")
|
||||
class TrialAgentConfigSkillsApi(Resource):
|
||||
@console_ns.doc(params=query_params_from_model(TrialAgentConfigQuery))
|
||||
@console_ns.response(200, "Published template resource", console_ns.models[AgentConfigSkillListResponse.__name__])
|
||||
@get_preview_app
|
||||
@model_validate(TrialAgentConfigQuery)
|
||||
@_with_trial_config
|
||||
def get(self, target: _ResolvedConsoleTarget, _query: TrialAgentConfigQuery) -> dict[str, object]:
|
||||
return dump_response(AgentConfigSkillListResponse, _skill_list_response(target))
|
||||
|
||||
|
||||
@console_ns.route("/trial-apps/<uuid:app_id>/agent/config/files")
|
||||
class TrialAgentConfigFilesApi(Resource):
|
||||
@console_ns.doc(params=query_params_from_model(TrialAgentConfigQuery))
|
||||
@console_ns.response(200, "Published template resource", console_ns.models[AgentConfigFileListResponse.__name__])
|
||||
@get_preview_app
|
||||
@model_validate(TrialAgentConfigQuery)
|
||||
@_with_trial_config
|
||||
def get(self, target: _ResolvedConsoleTarget, _query: TrialAgentConfigQuery) -> dict[str, object]:
|
||||
return dump_response(AgentConfigFileListResponse, _file_list_response(target))
|
||||
|
||||
|
||||
@console_ns.route("/trial-apps/<uuid:app_id>/agent/config/skills/<string:name>/inspect")
|
||||
class TrialAgentConfigSkillInspectApi(Resource):
|
||||
@console_ns.doc(params=query_params_from_model(TrialAgentConfigQuery))
|
||||
@console_ns.response(
|
||||
200, "Published template resource", console_ns.models[AgentConfigSkillInspectResponse.__name__]
|
||||
)
|
||||
@get_preview_app
|
||||
@model_validate(TrialAgentConfigQuery)
|
||||
@_with_trial_config
|
||||
def get(self, target: _ResolvedConsoleTarget, _query: TrialAgentConfigQuery, name: str) -> Response:
|
||||
return _skill_inspect_response(target, name)
|
||||
|
||||
|
||||
@console_ns.route("/trial-apps/<uuid:app_id>/agent/config/skills/<string:name>/download")
|
||||
class TrialAgentConfigSkillDownloadApi(Resource):
|
||||
@console_ns.doc(params=query_params_from_model(TrialAgentConfigQuery))
|
||||
@console_ns.response(200, "Published template resource", console_ns.models[AgentConfigDownloadResponse.__name__])
|
||||
@get_preview_app
|
||||
@model_validate(TrialAgentConfigQuery)
|
||||
@_with_trial_config
|
||||
def get(self, target: _ResolvedConsoleTarget, _query: TrialAgentConfigQuery, name: str) -> Response:
|
||||
return _skill_download_response(target, name)
|
||||
|
||||
|
||||
@console_ns.route("/trial-apps/<uuid:app_id>/agent/config/skills/<string:name>/files/preview")
|
||||
class TrialAgentConfigSkillFilePreviewApi(Resource):
|
||||
@console_ns.doc(params=query_params_from_model(TrialAgentSkillFileQuery))
|
||||
@console_ns.response(
|
||||
200, "Published template resource", console_ns.models[AgentConfigSkillFilePreviewResponse.__name__]
|
||||
)
|
||||
@get_preview_app
|
||||
@model_validate(TrialAgentSkillFileQuery)
|
||||
@_with_trial_config
|
||||
def get(self, target: _ResolvedConsoleTarget, query: TrialAgentSkillFileQuery, name: str) -> dict[str, object]:
|
||||
return dump_response(
|
||||
AgentConfigSkillFilePreviewResponse, _skill_file_preview_response(target, name, query.path)
|
||||
)
|
||||
|
||||
|
||||
@console_ns.route("/trial-apps/<uuid:app_id>/agent/config/skills/<string:name>/files/download")
|
||||
class TrialAgentConfigSkillFileDownloadApi(Resource):
|
||||
@console_ns.doc(params=query_params_from_model(TrialAgentSkillFileQuery))
|
||||
@console_ns.response(200, "Published template resource", console_ns.models[AgentConfigDownloadResponse.__name__])
|
||||
@get_preview_app
|
||||
@model_validate(TrialAgentSkillFileQuery)
|
||||
@_with_trial_config
|
||||
def get(self, target: _ResolvedConsoleTarget, query: TrialAgentSkillFileQuery, name: str) -> Response:
|
||||
assert request.view_args is not None
|
||||
return _skill_file_download_response(
|
||||
target,
|
||||
name=name,
|
||||
path=query.path,
|
||||
raw_endpoint="console.trial_agent_config_skill_file_content",
|
||||
route_params={"app_id": str(request.view_args["app_id"])},
|
||||
)
|
||||
|
||||
|
||||
@console_ns.route(
|
||||
"/trial-apps/<uuid:app_id>/agent/config/skills/<string:name>/files/content",
|
||||
endpoint="trial_agent_config_skill_file_content",
|
||||
)
|
||||
class TrialAgentConfigSkillFileContentApi(Resource):
|
||||
@console_ns.doc(params=query_params_from_model(TrialAgentSkillFileQuery))
|
||||
@console_ns.doc(
|
||||
produces=["application/octet-stream"],
|
||||
vendor={BINARY_RESPONSE_MEDIA_TYPES_VENDOR_KEY: ["application/octet-stream"]},
|
||||
)
|
||||
@console_ns.response(200, "Published template resource", console_ns.models[BinaryFileResponse.__name__])
|
||||
@get_preview_app
|
||||
@model_validate(TrialAgentSkillFileQuery)
|
||||
@_with_trial_config
|
||||
def get(self, target: _ResolvedConsoleTarget, query: TrialAgentSkillFileQuery, name: str) -> Response:
|
||||
return _skill_file_raw_download_response(target, name, query.path)
|
||||
|
||||
|
||||
@console_ns.route("/trial-apps/<uuid:app_id>/agent/config/files/<string:name>/preview")
|
||||
class TrialAgentConfigFilePreviewApi(Resource):
|
||||
@console_ns.doc(params=query_params_from_model(TrialAgentConfigQuery))
|
||||
@console_ns.response(200, "Published template resource", console_ns.models[AgentConfigFilePreviewResponse.__name__])
|
||||
@get_preview_app
|
||||
@model_validate(TrialAgentConfigQuery)
|
||||
@_with_trial_config
|
||||
def get(self, target: _ResolvedConsoleTarget, _query: TrialAgentConfigQuery, name: str) -> dict[str, object]:
|
||||
return dump_response(AgentConfigFilePreviewResponse, _file_preview_response(target, name))
|
||||
|
||||
|
||||
@console_ns.route("/trial-apps/<uuid:app_id>/agent/config/files/<string:name>/download")
|
||||
class TrialAgentConfigFileDownloadApi(Resource):
|
||||
@console_ns.doc(params=query_params_from_model(TrialAgentConfigQuery))
|
||||
@console_ns.response(200, "Published template resource", console_ns.models[AgentConfigDownloadResponse.__name__])
|
||||
@get_preview_app
|
||||
@model_validate(TrialAgentConfigQuery)
|
||||
@_with_trial_config
|
||||
def get(self, target: _ResolvedConsoleTarget, _query: TrialAgentConfigQuery, name: str) -> Response:
|
||||
return _file_download_response(target, name)
|
||||
|
||||
|
||||
# pyrefly: ignore [unresolvable-dunder-all]
|
||||
__all__ = [name for name, value in globals().items() if inspect.isclass(value) and issubclass(value, Resource)]
|
||||
|
||||
@@ -79,7 +79,8 @@ from services.entities.app_entities import (
|
||||
UpdateAppParams,
|
||||
)
|
||||
from services.entities.dsl_entities import ImportStatus
|
||||
from services.entities.knowledge_entities.knowledge_entities import (
|
||||
from services.errors.base import NoPermissionError
|
||||
from services.knowledge.entities.knowledge_entities import (
|
||||
DataSource,
|
||||
InfoList,
|
||||
NotionIcon,
|
||||
@@ -91,7 +92,6 @@ from services.entities.knowledge_entities.knowledge_entities import (
|
||||
WeightModel,
|
||||
WeightVectorSetting,
|
||||
)
|
||||
from services.errors.account import NoPermissionError
|
||||
|
||||
register_enum_models(console_ns, IconType)
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ from werkzeug.exceptions import Forbidden
|
||||
from controllers.common.rbac import PlainApp, RBACCheck, Workspace
|
||||
from controllers.common.schema import register_enum_models, register_schema_models
|
||||
from controllers.console.app.error import AppNotFoundError
|
||||
from controllers.console.explore.error import RecommendedAppNotFoundError as RecommendedAppNotFoundHttpError
|
||||
from controllers.console.flask_admission import console_account_admission
|
||||
from controllers.console.wraps import RBACPermission, validate_request
|
||||
from extensions.ext_application_services import application_services
|
||||
@@ -16,7 +17,8 @@ from models.account import TenantAccountRole
|
||||
from services.agent.errors import InvalidRosterAgentPackageError
|
||||
from services.app.console_service import ConsoleAppNotFoundError
|
||||
from services.entities.dsl_entities import AppImportParams, CheckDependenciesResult, Import, ImportStatus
|
||||
from services.errors.account import NoPermissionError
|
||||
from services.errors.base import NoPermissionError
|
||||
from services.recommended_app_query_service import RecommendedAppNotFoundError
|
||||
|
||||
from .. import console_ns
|
||||
|
||||
@@ -86,6 +88,8 @@ class AppImportApi(Resource):
|
||||
result = application_services().apps.console.import_app(context, payload, source=source)
|
||||
except NoPermissionError as exc:
|
||||
raise Forbidden(str(exc)) from exc
|
||||
except RecommendedAppNotFoundError:
|
||||
raise RecommendedAppNotFoundHttpError() from None
|
||||
status_code = {ImportStatus.FAILED: HTTPStatus.BAD_REQUEST, ImportStatus.PENDING: HTTPStatus.ACCEPTED}.get(
|
||||
result.status, HTTPStatus.OK
|
||||
)
|
||||
|
||||
@@ -455,14 +455,15 @@ class AgentTextToSpeechVoicesApi(Resource):
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
@with_session(write=False)
|
||||
@model_validate(TextToSpeechVoiceQuery)
|
||||
def get(
|
||||
self,
|
||||
req_data: TextToSpeechVoiceQuery,
|
||||
session: Session,
|
||||
current_tenant_id: str,
|
||||
current_user: Account,
|
||||
agent_id: UUID,
|
||||
):
|
||||
query = TextToSpeechVoiceQuery.model_validate(request.args.to_dict(flat=True))
|
||||
enforce_rbac_checks(
|
||||
tenant_id=current_tenant_id,
|
||||
account_id=current_user.id,
|
||||
@@ -474,4 +475,4 @@ class AgentTextToSpeechVoicesApi(Resource):
|
||||
tenant_id=current_tenant_id,
|
||||
agent_id=agent_id,
|
||||
)
|
||||
return _get_text_to_speech_voices(tenant_id=app_model.tenant_id, language=query.language)
|
||||
return _get_text_to_speech_voices(tenant_id=app_model.tenant_id, language=req_data.language)
|
||||
|
||||
@@ -401,6 +401,7 @@ class InstructionGenerateApi(Resource):
|
||||
instruction=req_data.instruction,
|
||||
model_config=req_data.model_config_data,
|
||||
ideal_output=req_data.ideal_output,
|
||||
session=session,
|
||||
)
|
||||
if req_data.node_id != "" and req_data.current != "": # For workflow node
|
||||
return LLMGenerator.instruction_modify_workflow(
|
||||
|
||||
@@ -1,32 +1,31 @@
|
||||
import json
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from flask_restx import Resource
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from sqlalchemy import select
|
||||
from werkzeug.exceptions import NotFound
|
||||
from werkzeug.exceptions import Conflict, NotFound
|
||||
|
||||
from controllers.common.rbac import PlainApp, RBACCheck
|
||||
from controllers.common.schema import register_schema_models
|
||||
from controllers.console import console_ns
|
||||
from controllers.console.app.wraps import get_app_model
|
||||
from controllers.console.wraps import (
|
||||
RBACPermission,
|
||||
account_initialization_required,
|
||||
edit_permission_required,
|
||||
model_validate,
|
||||
rbac_permission_required,
|
||||
setup_required,
|
||||
with_current_tenant_id,
|
||||
)
|
||||
from extensions.ext_database import db
|
||||
from controllers.console.app.error import AppNotFoundError
|
||||
from controllers.console.flask_admission import console_account_admission
|
||||
from controllers.console.wraps import RBACPermission, validate_request
|
||||
from extensions.ext_application_services import application_services
|
||||
from fields.base import ResponseModel
|
||||
from libs.helper import dump_response, to_timestamp
|
||||
from libs.login import login_required
|
||||
from models.enums import AppMCPServerStatus
|
||||
from models.model import App, AppMCPServer
|
||||
from services.app_ref_service import AppRefService
|
||||
from machinery.context import RequestContext
|
||||
from models.account import TenantAccountRole
|
||||
from services.app.mcp_server_service import (
|
||||
AppMCPServerAlreadyExistsError,
|
||||
AppMCPServerAppNotFoundError,
|
||||
AppMCPServerNotFoundError,
|
||||
AppMCPServerStatus,
|
||||
)
|
||||
|
||||
_EDIT_ROLES = frozenset({TenantAccountRole.OWNER, TenantAccountRole.ADMIN, TenantAccountRole.EDITOR})
|
||||
|
||||
|
||||
class MCPServerCreatePayload(BaseModel):
|
||||
@@ -38,13 +37,13 @@ class MCPServerCreatePayload(BaseModel):
|
||||
|
||||
|
||||
class MCPServerUpdatePayload(BaseModel):
|
||||
id: str = Field(..., description="Server ID")
|
||||
id: UUID = Field(..., description="Server ID")
|
||||
description: str | None = Field(default=None, description="Server description")
|
||||
parameters: dict[str, Any] = Field(
|
||||
...,
|
||||
description="Server parameters configuration",
|
||||
)
|
||||
status: str | None = Field(default=None, description="Server status")
|
||||
status: AppMCPServerStatus | None = Field(default=None, description="Server status")
|
||||
|
||||
|
||||
class AppMCPServerResponse(ResponseModel):
|
||||
@@ -84,13 +83,12 @@ class AppMCPServerController(Resource):
|
||||
@console_ns.response(
|
||||
200, "MCP server configuration retrieved successfully", console_ns.models[AppMCPServerResponse.__name__]
|
||||
)
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@setup_required
|
||||
@rbac_permission_required(RBACCheck(RBACPermission.APP_VIEW_LAYOUT, PlainApp()))
|
||||
@get_app_model
|
||||
def get(self, app_model: App):
|
||||
server = db.session.scalar(select(AppMCPServer).where(AppMCPServer.app_id == app_model.id).limit(1))
|
||||
@console_account_admission(rbac_checks=[RBACCheck(RBACPermission.APP_VIEW_LAYOUT, PlainApp())])
|
||||
def get(self, request_context: RequestContext, app_id: UUID):
|
||||
try:
|
||||
server = application_services().app_mcp_servers.get(request_context, str(app_id))
|
||||
except AppMCPServerAppNotFoundError as error:
|
||||
raise AppNotFoundError() from error
|
||||
if server is None:
|
||||
return {}
|
||||
return dump_response(AppMCPServerResponse, server)
|
||||
@@ -103,30 +101,18 @@ class AppMCPServerController(Resource):
|
||||
201, "MCP server configuration created successfully", console_ns.models[AppMCPServerResponse.__name__]
|
||||
)
|
||||
@console_ns.response(403, "Insufficient permissions")
|
||||
@account_initialization_required
|
||||
@login_required
|
||||
@setup_required
|
||||
@edit_permission_required
|
||||
@rbac_permission_required(RBACCheck(RBACPermission.APP_EDIT, PlainApp()))
|
||||
@with_current_tenant_id
|
||||
@get_app_model
|
||||
@model_validate(MCPServerCreatePayload)
|
||||
def post(self, req_data: MCPServerCreatePayload, current_tenant_id: str, app_model: App):
|
||||
description = req_data.description
|
||||
if not description:
|
||||
description = app_model.description or ""
|
||||
|
||||
server = AppMCPServer(
|
||||
name=app_model.name,
|
||||
description=description,
|
||||
parameters=json.dumps(req_data.parameters, ensure_ascii=False),
|
||||
status=AppMCPServerStatus.ACTIVE,
|
||||
app_id=app_model.id,
|
||||
tenant_id=current_tenant_id,
|
||||
server_code=AppMCPServer.generate_server_code(16, session=db.session()),
|
||||
)
|
||||
db.session.add(server)
|
||||
db.session.commit()
|
||||
@console_ns.response(409, "MCP server already exists for this app")
|
||||
@console_account_admission(allowed_roles=_EDIT_ROLES, rbac_checks=[RBACCheck(RBACPermission.APP_EDIT, PlainApp())])
|
||||
def post(self, request_context: RequestContext, app_id: UUID):
|
||||
payload = validate_request(MCPServerCreatePayload)
|
||||
try:
|
||||
server = application_services().app_mcp_servers.create(
|
||||
request_context, str(app_id), description=payload.description, parameters=payload.parameters
|
||||
)
|
||||
except AppMCPServerAppNotFoundError as error:
|
||||
raise AppNotFoundError() from error
|
||||
except AppMCPServerAlreadyExistsError as error:
|
||||
raise Conflict(str(error)) from error
|
||||
return dump_response(AppMCPServerResponse, server), 201
|
||||
|
||||
@console_ns.doc("update_app_mcp_server")
|
||||
@@ -138,43 +124,22 @@ class AppMCPServerController(Resource):
|
||||
)
|
||||
@console_ns.response(403, "Insufficient permissions")
|
||||
@console_ns.response(404, "Server not found")
|
||||
@login_required
|
||||
@setup_required
|
||||
@account_initialization_required
|
||||
@edit_permission_required
|
||||
@rbac_permission_required(RBACCheck(RBACPermission.APP_EDIT, PlainApp()))
|
||||
@get_app_model
|
||||
@model_validate(MCPServerUpdatePayload)
|
||||
def put(self, req_data: MCPServerUpdatePayload, app_model: App):
|
||||
app_ref = AppRefService.create_app_ref(app_model)
|
||||
server_ref = AppRefService.create_mcp_server_ref(app_ref, req_data.id)
|
||||
server = db.session.scalar(
|
||||
select(AppMCPServer)
|
||||
.where(
|
||||
AppMCPServer.id == server_ref.server_id,
|
||||
AppMCPServer.tenant_id == server_ref.app.tenant_id,
|
||||
AppMCPServer.app_id == server_ref.app.app_id,
|
||||
@console_account_admission(allowed_roles=_EDIT_ROLES, rbac_checks=[RBACCheck(RBACPermission.APP_EDIT, PlainApp())])
|
||||
def put(self, request_context: RequestContext, app_id: UUID):
|
||||
payload = validate_request(MCPServerUpdatePayload)
|
||||
try:
|
||||
server = application_services().app_mcp_servers.update(
|
||||
request_context,
|
||||
str(app_id),
|
||||
server_id=str(payload.id),
|
||||
description=payload.description,
|
||||
parameters=payload.parameters,
|
||||
status=payload.status,
|
||||
)
|
||||
.limit(1)
|
||||
)
|
||||
if not server:
|
||||
raise NotFound()
|
||||
|
||||
description = req_data.description
|
||||
if description is None or not description:
|
||||
server.description = app_model.description or ""
|
||||
else:
|
||||
server.description = description
|
||||
|
||||
server.name = app_model.name
|
||||
|
||||
server.parameters = json.dumps(req_data.parameters, ensure_ascii=False)
|
||||
if req_data.status:
|
||||
try:
|
||||
server.status = AppMCPServerStatus(req_data.status)
|
||||
except ValueError:
|
||||
raise ValueError("Invalid status")
|
||||
db.session.commit()
|
||||
except AppMCPServerAppNotFoundError as error:
|
||||
raise AppNotFoundError() from error
|
||||
except AppMCPServerNotFoundError as error:
|
||||
raise NotFound from error
|
||||
return dump_response(AppMCPServerResponse, server)
|
||||
|
||||
|
||||
@@ -186,21 +151,12 @@ class AppMCPServerRefreshController(Resource):
|
||||
@console_ns.response(200, "MCP server refreshed successfully", console_ns.models[AppMCPServerResponse.__name__])
|
||||
@console_ns.response(403, "Insufficient permissions")
|
||||
@console_ns.response(404, "Server not found")
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@edit_permission_required
|
||||
@rbac_permission_required(RBACCheck(RBACPermission.APP_EDIT, PlainApp()))
|
||||
@with_current_tenant_id
|
||||
@get_app_model
|
||||
def post(self, current_tenant_id: str, app_model: App):
|
||||
server = db.session.scalar(
|
||||
select(AppMCPServer)
|
||||
.where(AppMCPServer.app_id == app_model.id, AppMCPServer.tenant_id == current_tenant_id)
|
||||
.limit(1)
|
||||
)
|
||||
if not server:
|
||||
raise NotFound()
|
||||
server.server_code = AppMCPServer.generate_server_code(16, session=db.session())
|
||||
db.session.commit()
|
||||
@console_account_admission(allowed_roles=_EDIT_ROLES, rbac_checks=[RBACCheck(RBACPermission.APP_EDIT, PlainApp())])
|
||||
def post(self, request_context: RequestContext, app_id: UUID):
|
||||
try:
|
||||
server = application_services().app_mcp_servers.refresh(request_context, str(app_id))
|
||||
except AppMCPServerAppNotFoundError as error:
|
||||
raise AppNotFoundError() from error
|
||||
except AppMCPServerNotFoundError as error:
|
||||
raise NotFound from error
|
||||
return dump_response(AppMCPServerResponse, server)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import logging
|
||||
from http import HTTPStatus
|
||||
from typing import Literal
|
||||
from uuid import UUID
|
||||
|
||||
@@ -6,7 +7,7 @@ from flask_restx import Resource
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from sqlalchemy import exists, func, select
|
||||
from sqlalchemy.orm import Session
|
||||
from werkzeug.exceptions import InternalServerError, NotFound
|
||||
from werkzeug.exceptions import HTTPException, InternalServerError, NotFound, Unauthorized
|
||||
|
||||
from controllers.common.controller_schemas import MessageFeedbackPayload as _MessageFeedbackPayloadBase
|
||||
from controllers.common.fields import SimpleResultResponse, TextFileResponse
|
||||
@@ -16,6 +17,8 @@ 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.error import (
|
||||
AppNotFoundError,
|
||||
AppUnavailableError,
|
||||
CompletionRequestError,
|
||||
ProviderModelCurrentlyNotSupportError,
|
||||
ProviderNotInitializeError,
|
||||
@@ -23,6 +26,7 @@ from controllers.console.app.error import (
|
||||
)
|
||||
from controllers.console.app.wraps import get_app_model
|
||||
from controllers.console.explore.error import AppSuggestedQuestionsAfterAnswerDisabledError
|
||||
from controllers.console.flask_admission import console_account_admission
|
||||
from controllers.console.wraps import (
|
||||
RBACPermission,
|
||||
account_initialization_required,
|
||||
@@ -33,9 +37,9 @@ from controllers.console.wraps import (
|
||||
with_current_tenant_id,
|
||||
with_current_user,
|
||||
)
|
||||
from core.app.entities.app_invoke_entities import InvokeFrom
|
||||
from core.entities.execution_extra_content import ExecutionExtraContentDomainModel
|
||||
from core.errors.error import ModelCurrentlyNotSupportError, ProviderTokenNotInitError, QuotaExceededError
|
||||
from extensions.ext_application_services import application_services
|
||||
from extensions.ext_database import db
|
||||
from fields.base import ResponseModel
|
||||
from fields.conversation_fields import (
|
||||
@@ -46,13 +50,19 @@ from graphon.model_runtime.errors.invoke import InvokeError
|
||||
from libs.helper import dump_response, uuid_value
|
||||
from libs.infinite_scroll_pagination import InfiniteScrollPagination
|
||||
from libs.login import login_required
|
||||
from machinery.context import RequestContext
|
||||
from models.account import Account
|
||||
from models.enums import FeedbackFromSource, FeedbackRating
|
||||
from models.model import App, AppMode, Conversation, Message, MessageAnnotation, MessageFeedback
|
||||
from services.agent.errors import AgentNotFoundError
|
||||
from services.app.agent_app_contracts import AgentAppNotFoundError
|
||||
from services.app.console_service import ConsoleAppNotFoundError
|
||||
from services.app_definition_query_service import AppDefinitionUnavailableError
|
||||
from services.conversation_service import ConversationService
|
||||
from services.errors.conversation import ConversationNotExistsError
|
||||
from services.errors.message import MessageNotExistsError, SuggestedQuestionsAfterAnswerDisabledError
|
||||
from services.message_service import MessageService, attach_message_extra_contents
|
||||
from services.message_service import attach_message_extra_contents
|
||||
from services.message_suggested_questions_service import SuggestedQuestionsAccount, SuggestedQuestionsActorNotFoundError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -296,21 +306,25 @@ class MessageSuggestedQuestionApi(Resource):
|
||||
@console_ns.doc(description="Get suggested questions for a message")
|
||||
@console_ns.doc(params={"app_id": "Application ID", "message_id": "Message ID"})
|
||||
@console_ns.response(
|
||||
200,
|
||||
HTTPStatus.OK,
|
||||
"Suggested questions retrieved successfully",
|
||||
console_ns.models[SuggestedQuestionsResponse.__name__],
|
||||
)
|
||||
@console_ns.response(404, "Message or conversation not found")
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_current_user
|
||||
@rbac_permission_required(RBACCheck(RBACPermission.APP_VIEW_LAYOUT, PlainApp()))
|
||||
@with_session(write=False)
|
||||
@get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT, AppMode.AGENT])
|
||||
def get(self, session: Session, current_user: Account, app_model: App, message_id: UUID):
|
||||
@console_ns.response(HTTPStatus.BAD_REQUEST, "App or model provider unavailable, or generation failed")
|
||||
@console_ns.response(HTTPStatus.UNAUTHORIZED, "Account authentication required")
|
||||
@console_ns.response(HTTPStatus.FORBIDDEN, "Insufficient permissions or suggested questions disabled")
|
||||
@console_ns.response(HTTPStatus.NOT_FOUND, "App, message, or conversation not found")
|
||||
@console_ns.response(HTTPStatus.INTERNAL_SERVER_ERROR, "Unexpected server error")
|
||||
@console_account_admission(rbac_checks=(RBACCheck(RBACPermission.APP_VIEW_LAYOUT, PlainApp()),))
|
||||
def get(self, context: RequestContext, app_id: UUID, message_id: UUID) -> dict[str, object]:
|
||||
try:
|
||||
app = application_services().apps.console.get_reference(context, str(app_id))
|
||||
except ConsoleAppNotFoundError as exc:
|
||||
raise AppNotFoundError() from exc
|
||||
if app.mode not in (AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT, AppMode.AGENT):
|
||||
raise AppNotFoundError("App mode does not support suggested questions")
|
||||
return _get_message_suggested_questions(
|
||||
session=session, current_user=current_user, app_model=app_model, message_id=message_id
|
||||
context=context, app_id=app.id, app_mode=app.mode, message_id=message_id
|
||||
)
|
||||
|
||||
|
||||
@@ -320,26 +334,23 @@ class AgentMessageSuggestedQuestionApi(Resource):
|
||||
@console_ns.doc(description="Get suggested questions for an Agent App message")
|
||||
@console_ns.doc(params={"agent_id": "Agent ID", "message_id": "Message ID"})
|
||||
@console_ns.response(
|
||||
200,
|
||||
HTTPStatus.OK,
|
||||
"Suggested questions retrieved successfully",
|
||||
console_ns.models[SuggestedQuestionsResponse.__name__],
|
||||
)
|
||||
@console_ns.response(404, "Agent, message, or conversation not found")
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@rbac_permission_required(RBACCheck(RBACPermission.AGENT_TEST_AND_RUN, AgentId()))
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
@with_session(write=False)
|
||||
def get(self, session: Session, current_tenant_id: str, current_user: Account, agent_id: UUID, message_id: UUID):
|
||||
app_model = resolve_agent_runtime_app_model(
|
||||
session=session,
|
||||
tenant_id=current_tenant_id,
|
||||
agent_id=agent_id,
|
||||
)
|
||||
@console_ns.response(HTTPStatus.BAD_REQUEST, "App or model provider unavailable, or generation failed")
|
||||
@console_ns.response(HTTPStatus.UNAUTHORIZED, "Account authentication required")
|
||||
@console_ns.response(HTTPStatus.FORBIDDEN, "Insufficient permissions or suggested questions disabled")
|
||||
@console_ns.response(HTTPStatus.NOT_FOUND, "Agent, message, or conversation not found")
|
||||
@console_ns.response(HTTPStatus.INTERNAL_SERVER_ERROR, "Unexpected server error")
|
||||
@console_account_admission(rbac_checks=(RBACCheck(RBACPermission.AGENT_TEST_AND_RUN, AgentId()),))
|
||||
def get(self, context: RequestContext, agent_id: UUID, message_id: UUID) -> dict[str, object]:
|
||||
try:
|
||||
app_id = application_services().agent_apps.access.resolve_existing_runtime_app_id(context, str(agent_id))
|
||||
except AgentAppNotFoundError as exc:
|
||||
raise AgentNotFoundError() from exc
|
||||
return _get_message_suggested_questions(
|
||||
session=session, current_user=current_user, app_model=app_model, message_id=message_id
|
||||
context=context, app_id=app_id, app_mode=AppMode.AGENT, message_id=message_id
|
||||
)
|
||||
|
||||
|
||||
@@ -552,17 +563,22 @@ def _update_message_feedback(
|
||||
return SimpleResultResponse(result="success").model_dump(mode="json")
|
||||
|
||||
|
||||
def _get_message_suggested_questions(*, session: Session, current_user: Account, app_model: App, message_id: UUID):
|
||||
message_id_str = str(message_id)
|
||||
def _get_message_suggested_questions(
|
||||
*, context: RequestContext, app_id: str, app_mode: str, message_id: UUID
|
||||
) -> dict[str, object]:
|
||||
|
||||
try:
|
||||
questions = MessageService.get_suggested_questions_after_answer(
|
||||
app_model=app_model,
|
||||
message_id=message_id_str,
|
||||
user=current_user,
|
||||
invoke_from=InvokeFrom.DEBUGGER,
|
||||
session=session,
|
||||
questions = application_services().message_suggested_questions.get_suggested_questions(
|
||||
app_id=app_id,
|
||||
app_owner_tenant_id=context.active_workspace_id,
|
||||
expected_app_mode=app_mode,
|
||||
actor=SuggestedQuestionsAccount(account_id=context.account_id, invoke_from="debugger"),
|
||||
message_id=str(message_id),
|
||||
)
|
||||
except AppDefinitionUnavailableError as exc:
|
||||
raise AppUnavailableError() from exc
|
||||
except SuggestedQuestionsActorNotFoundError as exc:
|
||||
raise Unauthorized("Account no longer exists") from exc
|
||||
except MessageNotExistsError:
|
||||
raise NotFound("Message not found")
|
||||
except ConversationNotExistsError:
|
||||
@@ -577,6 +593,8 @@ def _get_message_suggested_questions(*, session: Session, current_user: Account,
|
||||
raise CompletionRequestError(e.description)
|
||||
except SuggestedQuestionsAfterAnswerDisabledError:
|
||||
raise AppSuggestedQuestionsAfterAnswerDisabledError()
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
logger.exception("internal server error.")
|
||||
raise InternalServerError()
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import json
|
||||
from typing import Any, cast
|
||||
|
||||
from flask import request
|
||||
from flask_restx import Resource
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from controllers.common.fields import SimpleResultResponse
|
||||
@@ -11,6 +8,7 @@ from controllers.common.rbac import PlainApp, RBACCheck
|
||||
from controllers.common.schema import register_response_schema_models, register_schema_models
|
||||
from controllers.common.session import with_session
|
||||
from controllers.console import console_ns
|
||||
from controllers.console.app.model_config_payload import AppModelConfigPayload
|
||||
from controllers.console.app.wraps import get_app_model
|
||||
from controllers.console.wraps import (
|
||||
RBACPermission,
|
||||
@@ -30,47 +28,7 @@ from libs.login import login_required
|
||||
from models.model import App, AppMode, AppModelConfig
|
||||
from services.app_model_config_service import AppModelConfigService
|
||||
|
||||
|
||||
class ModelConfigRequest(BaseModel):
|
||||
provider: str | None = Field(default=None, description="Model provider")
|
||||
model: str | None = Field(default=None, description="Model name")
|
||||
configs: dict[str, Any] | None = Field(
|
||||
default=None,
|
||||
description="Model configuration parameters",
|
||||
)
|
||||
opening_statement: str | None = Field(default=None, description="Opening statement")
|
||||
suggested_questions: list[str] | None = Field(default=None, description="Suggested questions")
|
||||
more_like_this: dict[str, Any] | None = Field(
|
||||
default=None,
|
||||
description="More like this configuration",
|
||||
)
|
||||
speech_to_text: dict[str, Any] | None = Field(
|
||||
default=None,
|
||||
description="Speech to text configuration",
|
||||
)
|
||||
text_to_speech: dict[str, Any] | None = Field(
|
||||
default=None,
|
||||
description="Text to speech configuration",
|
||||
)
|
||||
retrieval_model: dict[str, Any] | None = Field(
|
||||
default=None,
|
||||
description="Retrieval model configuration",
|
||||
)
|
||||
tools: list[dict[str, Any]] | None = Field(
|
||||
default=None,
|
||||
description="Available tools",
|
||||
)
|
||||
dataset_configs: dict[str, Any] | None = Field(
|
||||
default=None,
|
||||
description="Dataset configurations",
|
||||
)
|
||||
agent_mode: dict[str, Any] | None = Field(
|
||||
default=None,
|
||||
description="Agent mode configuration",
|
||||
)
|
||||
|
||||
|
||||
register_schema_models(console_ns, ModelConfigRequest)
|
||||
register_schema_models(console_ns, AppModelConfigPayload)
|
||||
register_response_schema_models(console_ns, SimpleResultResponse)
|
||||
|
||||
|
||||
@@ -79,7 +37,7 @@ class ModelConfigResource(Resource):
|
||||
@console_ns.doc("update_app_model_config")
|
||||
@console_ns.doc(description="Update application model configuration")
|
||||
@console_ns.doc(params={"app_id": "Application ID"})
|
||||
@console_ns.expect(console_ns.models[ModelConfigRequest.__name__])
|
||||
@console_ns.expect(console_ns.models[AppModelConfigPayload.__name__])
|
||||
@console_ns.response(
|
||||
200,
|
||||
"Model configuration updated successfully",
|
||||
@@ -98,10 +56,11 @@ class ModelConfigResource(Resource):
|
||||
@get_app_model(mode=[AppMode.AGENT_CHAT, AppMode.CHAT, AppMode.COMPLETION])
|
||||
def post(self, session: Session, current_tenant_id: str, current_user_id: str, app_model: App):
|
||||
"""Modify the app model config and dataset joins in one request transaction."""
|
||||
# validate config
|
||||
payload = AppModelConfigPayload.model_validate(console_ns.payload or {})
|
||||
model_configuration = AppModelConfigService.validate_configuration(
|
||||
tenant_id=current_tenant_id,
|
||||
config=cast(dict, request.json),
|
||||
# Preserve omission so feature validators retain their existing defaults.
|
||||
config=payload.model_dump(exclude_unset=True),
|
||||
app_mode=AppMode.value_of(app_model.mode),
|
||||
session=session,
|
||||
)
|
||||
|
||||
384
api/controllers/console/app/model_config_payload.py
Normal file
384
api/controllers/console/app/model_config_payload.py
Normal file
@@ -0,0 +1,384 @@
|
||||
from typing import Literal, NotRequired
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, with_config
|
||||
from typing_extensions import TypedDict
|
||||
|
||||
from core.entities.agent_entities import PlanningStrategy
|
||||
from core.rag.entities.metadata_entities import SupportedComparisonOperator
|
||||
from core.rag.rerank.rerank_type import RerankMode
|
||||
from core.tools.entities.tool_entities import ToolProviderType
|
||||
from graphon.file.enums import FileTransferMethod, FileType
|
||||
from graphon.model_runtime.entities.llm_entities import LLMMode
|
||||
|
||||
# The recursive schema also keeps generated clients from turning JSON values into `any`.
|
||||
type AppConfigJsonValue = str | int | float | bool | None | list[AppConfigJsonValue] | dict[str, AppConfigJsonValue]
|
||||
|
||||
|
||||
# Mypy does not yet support PEP 728 extra_items; pyrefly and Pydantic check these declarations.
|
||||
# https://github.com/python/mypy/issues/18176
|
||||
class AppModelSelectionPayload(TypedDict, extra_items=AppConfigJsonValue): # type: ignore[call-arg]
|
||||
provider: str
|
||||
name: str
|
||||
completion_params: dict[str, AppConfigJsonValue]
|
||||
mode: NotRequired[str | None]
|
||||
|
||||
|
||||
# These config managers persist nested extensions alongside their known fields.
|
||||
# Keep those JSON values when an existing configuration is published again.
|
||||
class AppPromptMessagePayload(TypedDict, extra_items=AppConfigJsonValue): # type: ignore[call-arg]
|
||||
text: str
|
||||
role: str
|
||||
|
||||
|
||||
class AppChatPromptPayload(TypedDict, extra_items=AppConfigJsonValue): # type: ignore[call-arg]
|
||||
prompt: NotRequired[list[AppPromptMessagePayload]]
|
||||
|
||||
|
||||
class AppConversationRolesPayload(TypedDict, extra_items=AppConfigJsonValue): # type: ignore[call-arg]
|
||||
user_prefix: str
|
||||
assistant_prefix: str
|
||||
|
||||
|
||||
class AppCompletionPromptTextPayload(TypedDict, extra_items=AppConfigJsonValue): # type: ignore[call-arg]
|
||||
text: str
|
||||
|
||||
|
||||
class AppCompletionPromptPayload(TypedDict, extra_items=AppConfigJsonValue): # type: ignore[call-arg]
|
||||
prompt: NotRequired[AppCompletionPromptTextPayload]
|
||||
conversation_histories_role: NotRequired[AppConversationRolesPayload]
|
||||
|
||||
|
||||
@with_config(ConfigDict(extra="allow"))
|
||||
class AppInputFieldBasePayload(TypedDict):
|
||||
label: str
|
||||
variable: str
|
||||
description: NotRequired[str]
|
||||
required: NotRequired[bool | None]
|
||||
default: NotRequired[AppConfigJsonValue]
|
||||
max_length: NotRequired[int | None]
|
||||
hide: NotRequired[bool]
|
||||
type: NotRequired[str]
|
||||
enabled: NotRequired[bool]
|
||||
config: NotRequired[dict[str, AppConfigJsonValue]]
|
||||
icon: NotRequired[str | None]
|
||||
icon_background: NotRequired[str | None]
|
||||
json_schema: NotRequired[str | dict[str, AppConfigJsonValue] | None]
|
||||
|
||||
|
||||
class AppInputFieldPayload(AppInputFieldBasePayload, extra_items=AppConfigJsonValue): # type: ignore[call-arg]
|
||||
options: NotRequired[list[str]]
|
||||
|
||||
|
||||
class AppSelectFieldPayload(AppInputFieldBasePayload, extra_items=AppConfigJsonValue): # type: ignore[call-arg]
|
||||
options: NotRequired[list[str] | None]
|
||||
|
||||
|
||||
class AppTextInputPayload(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", serialize_by_alias=True)
|
||||
|
||||
text_input: AppInputFieldPayload = Field(alias="text-input")
|
||||
|
||||
|
||||
class AppSelectInputPayload(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
select: AppSelectFieldPayload
|
||||
|
||||
|
||||
class AppParagraphInputPayload(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
paragraph: AppInputFieldPayload
|
||||
|
||||
|
||||
class AppNumberInputPayload(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
number: AppInputFieldPayload
|
||||
|
||||
|
||||
class AppCheckboxInputPayload(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
checkbox: AppInputFieldPayload
|
||||
|
||||
|
||||
class AppExternalDataInputPayload(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
external_data_tool: AppInputFieldPayload
|
||||
|
||||
|
||||
type AppUserInputFormPayload = (
|
||||
AppTextInputPayload
|
||||
| AppSelectInputPayload
|
||||
| AppParagraphInputPayload
|
||||
| AppNumberInputPayload
|
||||
| AppCheckboxInputPayload
|
||||
| AppExternalDataInputPayload
|
||||
)
|
||||
|
||||
|
||||
class AppFeaturePayload(TypedDict, extra_items=AppConfigJsonValue): # type: ignore[call-arg]
|
||||
enabled: NotRequired[bool | None]
|
||||
|
||||
|
||||
class AppSuggestedQuestionsModelPayload(TypedDict, extra_items=AppConfigJsonValue): # type: ignore[call-arg]
|
||||
provider: str
|
||||
name: str
|
||||
mode: NotRequired[LLMMode | Literal[""]]
|
||||
completion_params: NotRequired[dict[str, AppConfigJsonValue]]
|
||||
|
||||
|
||||
class AppSuggestedQuestionsPayload(TypedDict, extra_items=AppConfigJsonValue): # type: ignore[call-arg]
|
||||
enabled: NotRequired[bool | None]
|
||||
prompt: NotRequired[str]
|
||||
model: NotRequired[AppSuggestedQuestionsModelPayload]
|
||||
|
||||
|
||||
class AppTextToSpeechPayload(TypedDict, extra_items=AppConfigJsonValue): # type: ignore[call-arg]
|
||||
enabled: NotRequired[bool | None]
|
||||
voice: NotRequired[str]
|
||||
language: NotRequired[str]
|
||||
autoPlay: NotRequired[Literal["enabled", "disabled"]]
|
||||
|
||||
|
||||
class AppModerationContentPayload(TypedDict, extra_items=AppConfigJsonValue): # type: ignore[call-arg]
|
||||
enabled: NotRequired[bool | None]
|
||||
preset_response: NotRequired[str | None]
|
||||
|
||||
|
||||
class AppModerationConfigPayload(TypedDict, extra_items=AppConfigJsonValue): # type: ignore[call-arg]
|
||||
inputs_config: NotRequired[AppModerationContentPayload | None]
|
||||
outputs_config: NotRequired[AppModerationContentPayload | None]
|
||||
keywords: NotRequired[str | None]
|
||||
api_based_extension_id: NotRequired[str | None]
|
||||
|
||||
|
||||
class AppModerationPayload(BaseModel):
|
||||
enabled: bool | None = None
|
||||
type: str | None = None
|
||||
config: AppModerationConfigPayload | None = None
|
||||
|
||||
|
||||
class AppExternalDataToolPayload(TypedDict, extra_items=AppConfigJsonValue): # type: ignore[call-arg]
|
||||
enabled: NotRequired[bool | None]
|
||||
type: NotRequired[str]
|
||||
label: NotRequired[str]
|
||||
variable: NotRequired[str]
|
||||
config: NotRequired[dict[str, AppConfigJsonValue]]
|
||||
icon: NotRequired[str]
|
||||
icon_background: NotRequired[str]
|
||||
|
||||
|
||||
class AppDatasetSelectionPayload(TypedDict, extra_items=AppConfigJsonValue): # type: ignore[call-arg]
|
||||
id: NotRequired[str]
|
||||
enabled: NotRequired[bool]
|
||||
|
||||
|
||||
class AppLegacyDatasetSelectionPayload(TypedDict, extra_items=AppConfigJsonValue): # type: ignore[call-arg]
|
||||
id: NotRequired[str]
|
||||
enabled: NotRequired[bool | None]
|
||||
|
||||
|
||||
class AppDatasetToolPayload(TypedDict, extra_items=AppConfigJsonValue): # type: ignore[call-arg]
|
||||
dataset: AppDatasetSelectionPayload
|
||||
|
||||
|
||||
class AppEmptyDatasetCollectionPayload(TypedDict, closed=True):
|
||||
pass
|
||||
|
||||
|
||||
class AppDatasetCollectionPayload(TypedDict, extra_items=AppConfigJsonValue): # type: ignore[call-arg]
|
||||
strategy: NotRequired[str]
|
||||
datasets: list[AppDatasetToolPayload]
|
||||
|
||||
|
||||
class AppRerankingModelPayload(TypedDict, extra_items=AppConfigJsonValue): # type: ignore[call-arg]
|
||||
reranking_provider_name: NotRequired[str]
|
||||
reranking_model_name: NotRequired[str]
|
||||
|
||||
|
||||
class AppVectorWeightPayload(TypedDict, extra_items=AppConfigJsonValue): # type: ignore[call-arg]
|
||||
vector_weight: float
|
||||
embedding_provider_name: str
|
||||
embedding_model_name: str
|
||||
|
||||
|
||||
class AppKeywordWeightPayload(TypedDict, extra_items=AppConfigJsonValue): # type: ignore[call-arg]
|
||||
keyword_weight: float
|
||||
|
||||
|
||||
class AppRetrievalWeightsPayload(TypedDict, extra_items=AppConfigJsonValue): # type: ignore[call-arg]
|
||||
weight_type: NotRequired[Literal["semantic_first", "keyword_first", "customized"]]
|
||||
vector_setting: AppVectorWeightPayload
|
||||
keyword_setting: AppKeywordWeightPayload
|
||||
|
||||
|
||||
class AppMetadataModelPayload(TypedDict, extra_items=AppConfigJsonValue): # type: ignore[call-arg]
|
||||
provider: NotRequired[str]
|
||||
name: NotRequired[str]
|
||||
mode: NotRequired[LLMMode | Literal[""]]
|
||||
completion_params: NotRequired[dict[str, AppConfigJsonValue]]
|
||||
|
||||
|
||||
class AppMetadataConditionPayload(TypedDict, extra_items=AppConfigJsonValue): # type: ignore[call-arg]
|
||||
id: NotRequired[str]
|
||||
name: str
|
||||
metadata_id: NotRequired[str]
|
||||
comparison_operator: SupportedComparisonOperator
|
||||
value: NotRequired[str | int | float | list[str] | None]
|
||||
|
||||
|
||||
class AppMetadataFilteringPayload(TypedDict, extra_items=AppConfigJsonValue): # type: ignore[call-arg]
|
||||
logical_operator: NotRequired[Literal["and", "or"] | None]
|
||||
conditions: NotRequired[list[AppMetadataConditionPayload] | None]
|
||||
|
||||
|
||||
class AppDatasetConfigPayload(TypedDict, extra_items=AppConfigJsonValue): # type: ignore[call-arg]
|
||||
retrieval_model: NotRequired[Literal["single", "multiple"]]
|
||||
datasets: NotRequired[AppDatasetCollectionPayload | AppEmptyDatasetCollectionPayload | None]
|
||||
top_k: NotRequired[int]
|
||||
score_threshold: NotRequired[float | None]
|
||||
score_threshold_enabled: NotRequired[bool]
|
||||
reranking_model: NotRequired[AppRerankingModelPayload | None]
|
||||
weights: NotRequired[AppRetrievalWeightsPayload | None]
|
||||
reranking_enabled: NotRequired[bool]
|
||||
reranking_enable: NotRequired[bool]
|
||||
reranking_mode: NotRequired[RerankMode]
|
||||
metadata_filtering_mode: NotRequired[Literal["disabled", "automatic", "manual"]]
|
||||
metadata_model_config: NotRequired[AppMetadataModelPayload | None]
|
||||
metadata_filtering_conditions: NotRequired[AppMetadataFilteringPayload | None]
|
||||
|
||||
|
||||
class AppAgentPromptPayload(TypedDict, extra_items=AppConfigJsonValue): # type: ignore[call-arg]
|
||||
first_prompt: NotRequired[str]
|
||||
next_iteration: NotRequired[str]
|
||||
|
||||
|
||||
class AppProviderAgentToolPayload(TypedDict, extra_items=AppConfigJsonValue): # type: ignore[call-arg]
|
||||
enabled: NotRequired[bool | None]
|
||||
provider_type: ToolProviderType
|
||||
provider_id: str
|
||||
provider_name: NotRequired[str]
|
||||
tool_name: str
|
||||
tool_label: NotRequired[str]
|
||||
tool_parameters: dict[str, AppConfigJsonValue]
|
||||
plugin_unique_identifier: NotRequired[str | None]
|
||||
credential_id: NotRequired[str | None]
|
||||
isDeleted: NotRequired[bool]
|
||||
notAuthor: NotRequired[bool]
|
||||
|
||||
|
||||
class AppLegacyDatasetToolPayload(TypedDict, extra_items=AppConfigJsonValue): # type: ignore[call-arg]
|
||||
dataset: AppLegacyDatasetSelectionPayload
|
||||
|
||||
|
||||
class AppLegacyGoogleSearchToolPayload(TypedDict, extra_items=AppConfigJsonValue): # type: ignore[call-arg]
|
||||
google_search: AppFeaturePayload
|
||||
|
||||
|
||||
class AppLegacyWebReaderToolPayload(TypedDict, extra_items=AppConfigJsonValue): # type: ignore[call-arg]
|
||||
web_reader: AppFeaturePayload
|
||||
|
||||
|
||||
class AppLegacyWikipediaToolPayload(TypedDict, extra_items=AppConfigJsonValue): # type: ignore[call-arg]
|
||||
wikipedia: AppFeaturePayload
|
||||
|
||||
|
||||
class AppLegacyCurrentDatetimeToolPayload(TypedDict, extra_items=AppConfigJsonValue): # type: ignore[call-arg]
|
||||
current_datetime: AppFeaturePayload
|
||||
|
||||
|
||||
class AppLegacySensitiveWordConfigPayload(TypedDict, extra_items=AppConfigJsonValue): # type: ignore[call-arg]
|
||||
enabled: bool
|
||||
words: list[str]
|
||||
canned_response: str
|
||||
|
||||
|
||||
AppLegacySensitiveWordToolPayload = TypedDict( # type: ignore[misc]
|
||||
"AppLegacySensitiveWordToolPayload",
|
||||
{"sensitive-word-avoidance": AppLegacySensitiveWordConfigPayload},
|
||||
extra_items=AppConfigJsonValue,
|
||||
)
|
||||
|
||||
type AppAgentToolPayload = (
|
||||
AppProviderAgentToolPayload
|
||||
| AppLegacyDatasetToolPayload
|
||||
| AppLegacyGoogleSearchToolPayload
|
||||
| AppLegacyWebReaderToolPayload
|
||||
| AppLegacyWikipediaToolPayload
|
||||
| AppLegacyCurrentDatetimeToolPayload
|
||||
| AppLegacySensitiveWordToolPayload
|
||||
)
|
||||
|
||||
|
||||
class AppAgentModePayload(TypedDict, extra_items=AppConfigJsonValue): # type: ignore[call-arg]
|
||||
enabled: NotRequired[bool | None]
|
||||
strategy: NotRequired[PlanningStrategy | Literal["cot", "function-calling", ""] | None]
|
||||
max_iteration: NotRequired[int]
|
||||
tools: NotRequired[list[AppAgentToolPayload] | None]
|
||||
prompt: NotRequired[AppAgentPromptPayload | str | None]
|
||||
|
||||
|
||||
class AppImageUploadPayload(TypedDict, extra_items=AppConfigJsonValue): # type: ignore[call-arg]
|
||||
enabled: NotRequired[bool]
|
||||
number_limits: NotRequired[int]
|
||||
detail: NotRequired[Literal["low", "high"] | None]
|
||||
transfer_methods: NotRequired[list[FileTransferMethod]]
|
||||
|
||||
|
||||
class AppImageConfigPayload(TypedDict, extra_items=AppConfigJsonValue): # type: ignore[call-arg]
|
||||
number_limits: NotRequired[int]
|
||||
detail: NotRequired[Literal["low", "high"] | None]
|
||||
transfer_methods: NotRequired[list[FileTransferMethod]]
|
||||
|
||||
|
||||
class AppFileTypeUploadPayload(TypedDict, extra_items=AppConfigJsonValue): # type: ignore[call-arg]
|
||||
enabled: NotRequired[bool | None]
|
||||
number_limits: NotRequired[int | None]
|
||||
transfer_methods: NotRequired[list[FileTransferMethod] | None]
|
||||
|
||||
|
||||
class AppFilePreviewPayload(TypedDict, extra_items=AppConfigJsonValue): # type: ignore[call-arg]
|
||||
mode: NotRequired[str | None]
|
||||
file_type_list: NotRequired[list[str] | None]
|
||||
|
||||
|
||||
class AppFileUploadPayload(TypedDict, extra_items=AppConfigJsonValue): # type: ignore[call-arg]
|
||||
enabled: NotRequired[bool]
|
||||
image: NotRequired[AppImageUploadPayload]
|
||||
image_config: NotRequired[AppImageConfigPayload | None]
|
||||
document: NotRequired[AppFileTypeUploadPayload | None]
|
||||
audio: NotRequired[AppFileTypeUploadPayload | None]
|
||||
video: NotRequired[AppFileTypeUploadPayload | None]
|
||||
custom: NotRequired[AppFileTypeUploadPayload | None]
|
||||
preview_config: NotRequired[AppFilePreviewPayload | None]
|
||||
allowed_file_types: NotRequired[list[FileType]]
|
||||
allowed_file_extensions: NotRequired[list[str]]
|
||||
allowed_file_upload_methods: NotRequired[list[FileTransferMethod]]
|
||||
number_limits: NotRequired[int]
|
||||
|
||||
|
||||
class AppModelConfigPayload(BaseModel):
|
||||
"""Write transport; app-mode validators own defaults and feature-specific rules."""
|
||||
|
||||
model: AppModelSelectionPayload
|
||||
prompt_type: Literal["simple", "advanced", ""] | None = None
|
||||
pre_prompt: str | None = None
|
||||
chat_prompt_config: AppChatPromptPayload | None = None
|
||||
completion_prompt_config: AppCompletionPromptPayload | None = None
|
||||
user_input_form: list[AppUserInputFormPayload] | None = None
|
||||
dataset_query_variable: str | None = None
|
||||
dataset_configs: AppDatasetConfigPayload | None = None
|
||||
agent_mode: AppAgentModePayload | None = None
|
||||
external_data_tools: list[AppExternalDataToolPayload] | None = None
|
||||
file_upload: AppFileUploadPayload | None = None
|
||||
opening_statement: str | None = None
|
||||
suggested_questions: list[str] | None = None
|
||||
suggested_questions_after_answer: AppSuggestedQuestionsPayload | None = None
|
||||
more_like_this: AppFeaturePayload | None = None
|
||||
speech_to_text: AppFeaturePayload | None = None
|
||||
text_to_speech: AppTextToSpeechPayload | None = None
|
||||
retriever_resource: AppFeaturePayload | None = None
|
||||
sensitive_word_avoidance: AppModerationPayload | None = None
|
||||
@@ -16,6 +16,7 @@ from controllers.console.wraps import (
|
||||
with_current_tenant_id,
|
||||
with_current_user,
|
||||
)
|
||||
from extensions.ext_application_services import application_services
|
||||
from extensions.ext_database import db
|
||||
from fields.base import ResponseModel
|
||||
from fields.member_fields import AccountWithRole
|
||||
@@ -31,7 +32,6 @@ from models.comment import (
|
||||
from models.comment import (
|
||||
WorkflowCommentReply as WorkflowCommentReplyModel,
|
||||
)
|
||||
from services.account_service import TenantService
|
||||
from services.workflow_comment_service import WorkflowCommentService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -585,7 +585,7 @@ class WorkflowCommentMentionUsersApi(Resource):
|
||||
current_tenant = current_user.current_tenant # need the tenant object here
|
||||
if current_tenant is None:
|
||||
raise ValueError("current tenant is required")
|
||||
members = TenantService.get_tenant_members(current_tenant, session=db.session())
|
||||
members = application_services().workspaces.member_queries.list_members(current_tenant.id)
|
||||
users = TypeAdapter(list[AccountWithRole]).validate_python(members, from_attributes=True)
|
||||
response = WorkflowCommentMentionUsersPayload(users=users)
|
||||
return response.model_dump(mode="json"), 200
|
||||
|
||||
@@ -18,6 +18,7 @@ from extensions.ext_database import db
|
||||
from libs.login import current_account_with_tenant
|
||||
from models import App, AppMode
|
||||
from models.agent import AgentScope
|
||||
from models.enums import AppStatus
|
||||
|
||||
__all__ = [
|
||||
"get_app_model",
|
||||
@@ -25,31 +26,28 @@ __all__ = [
|
||||
]
|
||||
|
||||
|
||||
def _is_hidden_backing_app(app_model: App, session: Session | scoped_session) -> bool:
|
||||
binding = app_model.agent_app_binding_with_session(session=session, include_archived=True)
|
||||
return binding is not None and binding.scope == AgentScope.WORKFLOW_ONLY
|
||||
|
||||
|
||||
def _load_app_model(session: Session, app_id: str) -> App | None:
|
||||
"""Load the tenant-scoped app row with the request session owned by `with_session`."""
|
||||
def _load_app_model(session: Session | scoped_session, app_id: str) -> App | None:
|
||||
"""Load the tenant-scoped Console app row through the given session."""
|
||||
_, current_tenant_id = current_account_with_tenant()
|
||||
app_model = session.scalar(
|
||||
select(App).where(App.id == app_id, App.tenant_id == current_tenant_id, App.status == "normal").limit(1)
|
||||
select(App)
|
||||
.where(
|
||||
App.id == app_id,
|
||||
App.tenant_id == current_tenant_id,
|
||||
App.status == AppStatus.NORMAL,
|
||||
)
|
||||
.limit(1)
|
||||
)
|
||||
if app_model is not None and _is_hidden_backing_app(app_model, session):
|
||||
return None
|
||||
if app_model is not None:
|
||||
binding = app_model.agent_app_binding_with_session(session=session, include_archived=True)
|
||||
if binding is not None and binding.scope == AgentScope.WORKFLOW_ONLY:
|
||||
return None
|
||||
return app_model
|
||||
|
||||
|
||||
def _load_app_model_from_scoped_session(app_id: str) -> App | None:
|
||||
"""Load the app row for legacy handlers that have not adopted request session injection yet."""
|
||||
_, current_tenant_id = current_account_with_tenant()
|
||||
app_model = db.session.scalar(
|
||||
select(App).where(App.id == app_id, App.tenant_id == current_tenant_id, App.status == "normal").limit(1)
|
||||
)
|
||||
if app_model is not None and _is_hidden_backing_app(app_model, db.session):
|
||||
return None
|
||||
return app_model
|
||||
return _load_app_model(db.session, app_id)
|
||||
|
||||
|
||||
def _get_injected_session(args: tuple[object, ...]) -> Session | None:
|
||||
|
||||
@@ -12,14 +12,8 @@ from extensions.ext_application_services import application_services
|
||||
from libs.helper import EmailStr, dump_response, timezone
|
||||
from libs.login import current_account_with_tenant
|
||||
from libs.token import extract_access_token
|
||||
from services.account_activation_service import (
|
||||
EmailDomainSuspendedError as EmailDomainSuspendedRegistrationError,
|
||||
)
|
||||
from services.account_activation_service import (
|
||||
FrozenAccountError,
|
||||
InvalidInvitationError,
|
||||
InvitationAccountMismatchError,
|
||||
)
|
||||
from services.account_errors import AccountEmailDomainSuspendedError as EmailDomainSuspendedRegistrationError
|
||||
from services.account_errors import FrozenAccountError, InvalidInvitationError, InvitationAccountMismatchError
|
||||
from services.entities.account_activation_entities import ActivationCommand, InvitationLookup
|
||||
|
||||
|
||||
@@ -91,7 +85,7 @@ class ActivateCheckApi(Resource):
|
||||
)
|
||||
@model_validate(ActivateCheckQuery)
|
||||
def get(self, args: ActivateCheckQuery):
|
||||
result = application_services().account_activation.check(
|
||||
result = application_services().accounts.activation.check(
|
||||
InvitationLookup(
|
||||
workspace_id=args.workspace_id,
|
||||
email=args.email,
|
||||
@@ -115,20 +109,20 @@ 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, args: 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
|
||||
|
||||
try:
|
||||
application_services().account_activation.activate(
|
||||
application_services().accounts.activation.activate(
|
||||
ActivationCommand(
|
||||
invitation=InvitationLookup(
|
||||
workspace_id=args.workspace_id,
|
||||
|
||||
@@ -27,7 +27,7 @@ from services.auth.errors import (
|
||||
InvalidDataSourceApiKeyAuthCredentialsError,
|
||||
UnsupportedDataSourceApiKeyAuthProviderError,
|
||||
)
|
||||
from services.entities.data_source_api_key_auth_entities import (
|
||||
from services.data_source.entities.api_key_auth import (
|
||||
DataSourceApiKeyAuthBindingCreate,
|
||||
DataSourceApiKeyAuthCredentials,
|
||||
)
|
||||
@@ -80,7 +80,7 @@ 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)
|
||||
bindings = application_services().data_sources.api_key_auth.list_bindings(request_context)
|
||||
return dump_response(
|
||||
ApiKeyAuthDataSourceListResponse,
|
||||
{
|
||||
@@ -120,7 +120,7 @@ class ApiKeyAuthDataSourceBinding(Resource):
|
||||
),
|
||||
)
|
||||
try:
|
||||
application_services().data_source_api_key_auth.create_binding(request_context, command)
|
||||
application_services().data_sources.api_key_auth.create_binding(request_context, command)
|
||||
except UnsupportedDataSourceApiKeyAuthProviderError as exc:
|
||||
raise DataSourceApiKeyAuthProviderNotSupportedError() from exc
|
||||
except InvalidDataSourceApiKeyAuthCredentialsError as exc:
|
||||
@@ -140,5 +140,5 @@ class ApiKeyAuthDataSourceBindingDelete(Resource):
|
||||
rbac_checks=[RBACCheck(RBACPermission.CREDENTIAL_MANAGE, Workspace())],
|
||||
)
|
||||
def delete(self, request_context: RequestContext, binding_id: UUID):
|
||||
application_services().data_source_api_key_auth.delete_binding(request_context, str(binding_id))
|
||||
application_services().data_sources.api_key_auth.delete_binding(request_context, str(binding_id))
|
||||
return "", HTTPStatus.NO_CONTENT
|
||||
|
||||
@@ -24,13 +24,13 @@ from fields.base import ResponseModel
|
||||
from libs.helper import dump_response
|
||||
from machinery.context import RequestContext
|
||||
from models.account import TenantAccountRole
|
||||
from services.data_source_oauth_service import (
|
||||
from services.data_source.entities.oauth import DataSourceOAuthCallback
|
||||
from services.data_source.oauth_service import (
|
||||
DataSourceOAuthConfigurationError,
|
||||
DataSourceOAuthError,
|
||||
InvalidDataSourceOAuthCodeError,
|
||||
InvalidDataSourceOAuthProviderError,
|
||||
)
|
||||
from services.entities.data_source_oauth_entities import DataSourceOAuthCallback
|
||||
|
||||
from .. import console_ns
|
||||
|
||||
@@ -116,7 +116,7 @@ class OAuthDataSource(Resource):
|
||||
)
|
||||
def get(self, request_context: RequestContext, provider: str):
|
||||
try:
|
||||
service = application_services().resolve_data_source_oauth(provider)
|
||||
service = application_services().data_sources.resolve_oauth(provider)
|
||||
authorization = service.start_authorization(request_context)
|
||||
except InvalidDataSourceOAuthProviderError:
|
||||
return _invalid_provider_response()
|
||||
@@ -141,7 +141,7 @@ class OAuthDataSourceCallback(Resource):
|
||||
def get(self, provider: str):
|
||||
query = query_params_from_request(OAuthDataSourceCallbackQuery)
|
||||
try:
|
||||
service = application_services().resolve_data_source_oauth(provider)
|
||||
service = application_services().data_sources.resolve_oauth(provider)
|
||||
callback = service.complete_callback(
|
||||
code=query.code,
|
||||
error=query.error,
|
||||
@@ -172,7 +172,7 @@ class OAuthDataSourceBinding(Resource):
|
||||
if not query.code:
|
||||
return {"error": "Invalid code"}, HTTPStatus.BAD_REQUEST
|
||||
try:
|
||||
service = application_services().resolve_data_source_oauth(provider)
|
||||
service = application_services().data_sources.resolve_oauth(provider)
|
||||
service.bind(request_context, code=query.code)
|
||||
except InvalidDataSourceOAuthProviderError:
|
||||
return _invalid_provider_response()
|
||||
@@ -198,7 +198,7 @@ class OAuthDataSourceSync(Resource):
|
||||
@console_account_admission()
|
||||
def get(self, request_context: RequestContext, provider: str, binding_id: UUID):
|
||||
try:
|
||||
service = application_services().resolve_data_source_oauth(provider)
|
||||
service = application_services().data_sources.resolve_oauth(provider)
|
||||
service.sync(
|
||||
request_context,
|
||||
binding_id=str(binding_id),
|
||||
|
||||
@@ -154,7 +154,7 @@ class EmailRegisterResetApi(Resource):
|
||||
@model_validate(EmailRegisterResetPayload)
|
||||
def post(self, args: EmailRegisterResetPayload):
|
||||
try:
|
||||
token_pair = application_services().accounts.email_registration.register(
|
||||
tokens = application_services().accounts.email_registration.register(
|
||||
remote_ip=extract_remote_ip(request),
|
||||
token=args.token,
|
||||
new_password=args.new_password,
|
||||
@@ -179,5 +179,5 @@ class EmailRegisterResetApi(Resource):
|
||||
|
||||
return dump_response(
|
||||
EmailRegisterResetResponse,
|
||||
{"result": "success", "data": token_pair},
|
||||
{"result": "success", "data": tokens},
|
||||
)
|
||||
|
||||
@@ -60,12 +60,8 @@ from libs.token import (
|
||||
set_refresh_token_to_cookie,
|
||||
)
|
||||
from services import account_errors
|
||||
from services.entities.account_login_entities import (
|
||||
AuthTokenPair,
|
||||
EmailCodeLoginCommand,
|
||||
EmailCodeSendCommand,
|
||||
PasswordLoginCommand,
|
||||
)
|
||||
from services.entities.account_entities import AccountSessionTokens
|
||||
from services.entities.account_login_entities import EmailCodeLoginCommand, EmailCodeSendCommand, PasswordLoginCommand
|
||||
from services.entities.auth_entities import LoginPayloadBase
|
||||
|
||||
|
||||
@@ -141,7 +137,7 @@ class LoginApi(Resource):
|
||||
except account_errors.AccountApplicationError as error:
|
||||
_raise_request_error(error)
|
||||
|
||||
if not result.workspace_found or result.token_pair is None:
|
||||
if not result.workspace_found or result.tokens is None:
|
||||
return dump_response(
|
||||
SimpleResultOptionalDataResponse,
|
||||
{
|
||||
@@ -149,7 +145,7 @@ class LoginApi(Resource):
|
||||
"data": "workspace not found, please contact system admin to invite you to join in a workspace",
|
||||
},
|
||||
)
|
||||
return _token_response(result.token_pair, SimpleResultOptionalDataResponse, {"result": "success"})
|
||||
return _token_response(result.tokens, SimpleResultOptionalDataResponse, {"result": "success"})
|
||||
|
||||
|
||||
@console_ns.route("/logout")
|
||||
@@ -218,7 +214,7 @@ class EmailCodeLoginApi(Resource):
|
||||
@model_validate(EmailCodeLoginPayload)
|
||||
def post(self, req_data: EmailCodeLoginPayload):
|
||||
try:
|
||||
token_pair = application_services().accounts.authentication.login_with_email_code(
|
||||
tokens = application_services().accounts.authentication.login_with_email_code(
|
||||
EmailCodeLoginCommand(
|
||||
email=req_data.email,
|
||||
code=req_data.code,
|
||||
@@ -231,7 +227,7 @@ class EmailCodeLoginApi(Resource):
|
||||
)
|
||||
except account_errors.AccountApplicationError as error:
|
||||
_raise_request_error(error)
|
||||
return _token_response(token_pair, SimpleResultResponse, {"result": "success"})
|
||||
return _token_response(tokens, SimpleResultResponse, {"result": "success"})
|
||||
|
||||
|
||||
@console_ns.route("/refresh-token")
|
||||
@@ -247,21 +243,21 @@ class RefreshTokenApi(Resource):
|
||||
), HTTPStatus.UNAUTHORIZED
|
||||
|
||||
try:
|
||||
token_pair = application_services().accounts.authentication.refresh(refresh_token)
|
||||
tokens = application_services().accounts.authentication.refresh(refresh_token)
|
||||
except account_errors.InvalidRefreshTokenError as error:
|
||||
return dump_response(
|
||||
SimpleResultMessageResponse,
|
||||
{"result": "fail", "message": str(error)},
|
||||
), HTTPStatus.UNAUTHORIZED
|
||||
return _token_response(token_pair, SimpleResultResponse, {"result": "success"})
|
||||
return _token_response(tokens, SimpleResultResponse, {"result": "success"})
|
||||
|
||||
|
||||
def _token_response(token_pair: AuthTokenPair, response_model: type[BaseModel], data: object):
|
||||
def _token_response(tokens: AccountSessionTokens, response_model: type[BaseModel], data: object):
|
||||
# response-contract:ignore cookie-bearing Flask response
|
||||
response = make_response(dump_response(response_model, data))
|
||||
set_csrf_token_to_cookie(request, response, token_pair.csrf_token)
|
||||
set_access_token_to_cookie(request, response, token_pair.access_token)
|
||||
set_refresh_token_to_cookie(request, response, token_pair.refresh_token)
|
||||
set_csrf_token_to_cookie(request, response, tokens.csrf_token)
|
||||
set_access_token_to_cookie(request, response, tokens.access_token)
|
||||
set_refresh_token_to_cookie(request, response, tokens.refresh_token)
|
||||
return response
|
||||
|
||||
|
||||
|
||||
@@ -1,53 +1,59 @@
|
||||
import json
|
||||
from collections.abc import Generator
|
||||
from datetime import datetime
|
||||
from typing import Any, Literal, cast
|
||||
from typing import Never
|
||||
from uuid import UUID
|
||||
|
||||
from flask import request
|
||||
from flask_restx import Resource
|
||||
from pydantic import BaseModel, Field, field_serializer
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
from werkzeug.exceptions import NotFound
|
||||
|
||||
from configs import dify_config
|
||||
from controllers.common.errors import InvalidArgumentError, NotFoundError
|
||||
from controllers.common.fields import SimpleResultResponse, TextContentResponse
|
||||
from controllers.common.rbac import DatasetId, RBACCheck, Workspace
|
||||
from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models
|
||||
from controllers.common.session import with_session
|
||||
from core.datasource.entities.datasource_entities import DatasourceProviderType, OnlineDocumentPagesMessage
|
||||
from core.datasource.online_document.online_document_plugin import OnlineDocumentDatasourcePlugin
|
||||
from core.entities.knowledge_entities import IndexingEstimate
|
||||
from core.indexing_runner import IndexingRunner
|
||||
from core.rag.extractor.entity.datasource_type import DatasourceType
|
||||
from core.rag.extractor.entity.extract_setting import ExtractSetting, NotionInfo
|
||||
from core.rag.extractor.notion_extractor import NotionExtractor
|
||||
from extensions.ext_database import db
|
||||
from fields.base import ResponseModel
|
||||
from libs.datetime_utils import naive_utc_now
|
||||
from libs.helper import dump_response, to_timestamp
|
||||
from libs.login import login_required
|
||||
from models import Account, DataSourceOauthBinding, Document
|
||||
from services.dataset_service import DatasetService, DocumentService
|
||||
from services.datasource_provider_service import DatasourceProviderService
|
||||
from tasks.document_indexing_sync_task import document_indexing_sync_task
|
||||
|
||||
from .. import console_ns
|
||||
from ..wraps import (
|
||||
RBACPermission,
|
||||
account_initialization_required,
|
||||
is_admin_or_owner_required,
|
||||
model_validate,
|
||||
rbac_permission_required,
|
||||
setup_required,
|
||||
with_current_tenant_id,
|
||||
with_current_user,
|
||||
from controllers.console import console_ns
|
||||
from controllers.console.app.error import ProviderNotInitializeError
|
||||
from controllers.console.datasets.error import (
|
||||
DatasetAccessDeniedRequestError,
|
||||
IndexingEstimateError,
|
||||
InvalidActionError,
|
||||
)
|
||||
from controllers.console.flask_admission import console_account_admission
|
||||
from controllers.console.wraps import model_validate
|
||||
from core.entities.knowledge_entities import IndexingEstimate
|
||||
from core.rbac import RBACPermission
|
||||
from extensions.ext_application_services import application_services
|
||||
from fields.base import ResponseModel
|
||||
from fields.dataset_fields import NotionEstimateWorkspacePayload
|
||||
from libs.helper import dump_response, to_timestamp
|
||||
from machinery.context import RequestContext
|
||||
from models.account import TenantAccountRole
|
||||
from services.data_source.binding_application_service import (
|
||||
BindingAction,
|
||||
DataSourceBindingNotFoundError,
|
||||
DataSourceBindingStateError,
|
||||
)
|
||||
from services.data_source.entities.notion_import import NotionPageType
|
||||
from services.data_source.notion_import_application_service import (
|
||||
DatasetIsNotNotionSourceError,
|
||||
NotionImportCredentialUnavailableError,
|
||||
)
|
||||
from services.knowledge.dataset_access import DatasetAccessDeniedError, DatasetNotFoundError
|
||||
from services.knowledge.document_sync import SyncDocumentNotFoundError, SyncDocumentSourceError
|
||||
from services.knowledge.entities.indexing_estimate import NewSourcesEstimateCommand, NotionEstimateSource
|
||||
from services.knowledge.indexing.estimate import (
|
||||
EstimateSourceNotFoundError,
|
||||
IndexingEstimateCredentialUnavailableError,
|
||||
IndexingEstimateExecutionError,
|
||||
IndexingEstimateProviderUnavailableError,
|
||||
)
|
||||
|
||||
_ADMIN_OR_OWNER_ROLES = frozenset({TenantAccountRole.ADMIN, TenantAccountRole.OWNER})
|
||||
_DATA_SOURCE_OAUTH_BASE_PATH = "/console/api/oauth/data-source"
|
||||
|
||||
|
||||
class NotionEstimatePayload(BaseModel):
|
||||
notion_info_list: list[dict[str, Any]]
|
||||
process_rule: dict[str, Any]
|
||||
notion_info_list: list[NotionEstimateWorkspacePayload] = Field(min_length=1)
|
||||
process_rule: dict[str, object]
|
||||
doc_form: str = Field(default="text_model")
|
||||
doc_language: str = Field(default="English")
|
||||
|
||||
@@ -72,7 +78,7 @@ class DataSourceIntegratePageResponse(ResponseModel):
|
||||
page_id: str
|
||||
page_icon: DataSourceIntegrateIconResponse | None
|
||||
parent_id: str
|
||||
type: str
|
||||
type: NotionPageType
|
||||
|
||||
|
||||
class DataSourceIntegrateWorkspaceResponse(ResponseModel):
|
||||
@@ -106,7 +112,7 @@ class NotionIntegratePageResponse(ResponseModel):
|
||||
page_id: str
|
||||
page_icon: DataSourceIntegrateIconResponse | None
|
||||
parent_id: str | None
|
||||
type: str
|
||||
page_type: NotionPageType = Field(alias="type")
|
||||
is_bound: bool
|
||||
|
||||
|
||||
@@ -132,325 +138,204 @@ register_response_schema_models(
|
||||
)
|
||||
|
||||
|
||||
@console_ns.route(
|
||||
"/data-source/integrates",
|
||||
"/data-source/integrates/<uuid:binding_id>/<string:action>",
|
||||
)
|
||||
class DataSourceApi(Resource):
|
||||
@setup_required
|
||||
@login_required
|
||||
@is_admin_or_owner_required
|
||||
@rbac_permission_required(RBACCheck(RBACPermission.CREDENTIAL_MANAGE, Workspace()))
|
||||
@account_initialization_required
|
||||
def _raise_dataset_access_error(error: DatasetNotFoundError | DatasetAccessDeniedError) -> Never:
|
||||
if isinstance(error, DatasetNotFoundError):
|
||||
raise NotFoundError(description="Dataset not found.") from None
|
||||
raise DatasetAccessDeniedRequestError(description=str(error)) from None
|
||||
|
||||
|
||||
@console_ns.route("/data-source/integrates")
|
||||
class DataSourceIntegrationListApi(Resource):
|
||||
@console_ns.response(200, "Success", console_ns.models[DataSourceIntegrateListResponse.__name__])
|
||||
@with_current_tenant_id
|
||||
def get(self, current_tenant_id: str) -> tuple[dict[str, Any], int]:
|
||||
# get workspace data source integrates
|
||||
data_source_integrates = db.session.scalars(
|
||||
select(DataSourceOauthBinding).where(
|
||||
DataSourceOauthBinding.tenant_id == current_tenant_id,
|
||||
DataSourceOauthBinding.disabled == False,
|
||||
)
|
||||
).all()
|
||||
@console_account_admission(
|
||||
allowed_roles=_ADMIN_OR_OWNER_ROLES,
|
||||
rbac_checks=(RBACCheck(RBACPermission.CREDENTIAL_MANAGE, Workspace()),),
|
||||
)
|
||||
def get(self, request_context: RequestContext) -> tuple[dict[str, object], int]:
|
||||
bindings = application_services().data_sources.bindings.list_integrations(request_context)
|
||||
base_url = dify_config.CONSOLE_API_URL.rstrip("/")
|
||||
data = [
|
||||
{
|
||||
"id": binding.id,
|
||||
"provider": binding.provider,
|
||||
"created_at": binding.created_at,
|
||||
"is_bound": True,
|
||||
"disabled": binding.disabled,
|
||||
"source_info": binding.source_info,
|
||||
"link": f"{base_url}{_DATA_SOURCE_OAUTH_BASE_PATH}/{binding.provider}",
|
||||
}
|
||||
for binding in bindings
|
||||
]
|
||||
return dump_response(DataSourceIntegrateListResponse, {"data": data}), 200
|
||||
|
||||
base_url = request.url_root.rstrip("/")
|
||||
data_source_oauth_base_path = "/console/api/oauth/data-source"
|
||||
providers = ["notion"]
|
||||
|
||||
integrate_data: list[dict[str, bool | datetime | dict[str, Any] | str | None]] = []
|
||||
for provider in providers:
|
||||
# existing_integrate = next((ai for ai in data_source_integrates if ai.provider == provider), None)
|
||||
existing_integrates = filter(lambda item: item.provider == provider, data_source_integrates)
|
||||
if existing_integrates:
|
||||
for existing_integrate in list(existing_integrates):
|
||||
integrate_data.append(
|
||||
{
|
||||
"id": existing_integrate.id,
|
||||
"provider": provider,
|
||||
"created_at": existing_integrate.created_at,
|
||||
"is_bound": True,
|
||||
"disabled": existing_integrate.disabled,
|
||||
"source_info": existing_integrate.source_info,
|
||||
"link": f"{base_url}{data_source_oauth_base_path}/{provider}",
|
||||
}
|
||||
)
|
||||
else:
|
||||
integrate_data.append(
|
||||
{
|
||||
"id": None,
|
||||
"provider": provider,
|
||||
"created_at": None,
|
||||
"source_info": None,
|
||||
"is_bound": False,
|
||||
"disabled": None,
|
||||
"link": f"{base_url}{data_source_oauth_base_path}/{provider}",
|
||||
}
|
||||
)
|
||||
return dump_response(DataSourceIntegrateListResponse, {"data": integrate_data}), 200
|
||||
|
||||
@setup_required
|
||||
@login_required
|
||||
@is_admin_or_owner_required
|
||||
@rbac_permission_required(RBACCheck(RBACPermission.CREDENTIAL_MANAGE, Workspace()))
|
||||
@account_initialization_required
|
||||
@console_ns.route("/data-source/integrates/<uuid:binding_id>/<string:action>")
|
||||
class DataSourceIntegrationApi(Resource):
|
||||
@console_ns.response(200, "Success", console_ns.models[SimpleResultResponse.__name__])
|
||||
@with_current_tenant_id
|
||||
@with_session
|
||||
@console_account_admission(
|
||||
allowed_roles=_ADMIN_OR_OWNER_ROLES,
|
||||
rbac_checks=(RBACCheck(RBACPermission.CREDENTIAL_MANAGE, Workspace()),),
|
||||
)
|
||||
def patch(
|
||||
self, session: Session, current_tenant_id: str, binding_id: UUID, action: Literal["enable", "disable"]
|
||||
) -> tuple[dict[str, str], int]:
|
||||
binding_id_str = str(binding_id)
|
||||
data_source_binding = session.scalar(
|
||||
select(DataSourceOauthBinding).where(
|
||||
DataSourceOauthBinding.id == binding_id_str, DataSourceOauthBinding.tenant_id == current_tenant_id
|
||||
self,
|
||||
request_context: RequestContext,
|
||||
binding_id: UUID,
|
||||
action: str,
|
||||
) -> tuple[dict[str, object], int]:
|
||||
try:
|
||||
validated_action = BindingAction(action)
|
||||
except ValueError as error:
|
||||
raise InvalidActionError() from error
|
||||
try:
|
||||
application_services().data_sources.bindings.change_state(
|
||||
request_context,
|
||||
str(binding_id),
|
||||
validated_action,
|
||||
)
|
||||
)
|
||||
if data_source_binding is None:
|
||||
raise NotFound("Data source binding not found.")
|
||||
# enable binding
|
||||
match action:
|
||||
case "enable":
|
||||
if data_source_binding.disabled:
|
||||
data_source_binding.disabled = False
|
||||
data_source_binding.updated_at = naive_utc_now()
|
||||
else:
|
||||
raise ValueError("Data source is not disabled.")
|
||||
# disable binding
|
||||
case "disable":
|
||||
if not data_source_binding.disabled:
|
||||
data_source_binding.disabled = True
|
||||
data_source_binding.updated_at = naive_utc_now()
|
||||
else:
|
||||
raise ValueError("Data source is disabled.")
|
||||
return {"result": "success"}, 200
|
||||
except DataSourceBindingNotFoundError as error:
|
||||
raise NotFoundError(description="Data source binding not found.") from error
|
||||
except DataSourceBindingStateError as error:
|
||||
raise InvalidArgumentError(description=str(error)) from error
|
||||
return dump_response(SimpleResultResponse, {"result": "success"}), 200
|
||||
|
||||
|
||||
@console_ns.route("/notion/pre-import/pages")
|
||||
class DataSourceNotionListApi(Resource):
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@console_ns.doc(params=query_params_from_model(DataSourceNotionListQuery))
|
||||
@console_ns.response(200, "Success", console_ns.models[NotionIntegrateInfoListResponse.__name__])
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
@with_session(write=False)
|
||||
@console_account_admission()
|
||||
@model_validate(DataSourceNotionListQuery)
|
||||
def get(
|
||||
self,
|
||||
req_data: DataSourceNotionListQuery,
|
||||
session: Session,
|
||||
current_tenant_id: str,
|
||||
current_user: Account,
|
||||
) -> tuple[dict[str, Any], int]:
|
||||
datasource_provider_service = DatasourceProviderService()
|
||||
credential = datasource_provider_service.get_datasource_credentials(
|
||||
tenant_id=current_tenant_id,
|
||||
credential_id=req_data.credential_id,
|
||||
provider="notion_datasource",
|
||||
plugin_id="langgenius/notion_datasource",
|
||||
)
|
||||
if not credential:
|
||||
raise NotFound("Credential not found.")
|
||||
exist_page_ids = []
|
||||
# import notion in the exist dataset
|
||||
if req_data.dataset_id:
|
||||
dataset = DatasetService.get_dataset(req_data.dataset_id, session)
|
||||
if not dataset:
|
||||
raise NotFound("Dataset not found.")
|
||||
if dataset.data_source_type != "notion_import":
|
||||
raise ValueError("Dataset is not notion type.")
|
||||
|
||||
documents = session.scalars(
|
||||
select(Document).where(
|
||||
Document.dataset_id == req_data.dataset_id,
|
||||
Document.tenant_id == current_tenant_id,
|
||||
Document.data_source_type == "notion_import",
|
||||
Document.enabled.is_(True),
|
||||
)
|
||||
).all()
|
||||
if documents:
|
||||
for document in documents:
|
||||
data_source_info = json.loads(document.data_source_info)
|
||||
exist_page_ids.append(data_source_info["notion_page_id"])
|
||||
# get all authorized pages
|
||||
from core.datasource.datasource_manager import DatasourceManager
|
||||
|
||||
datasource_runtime = DatasourceManager.get_datasource_runtime(
|
||||
provider_id="langgenius/notion_datasource/notion_datasource",
|
||||
datasource_name="notion_datasource",
|
||||
tenant_id=current_tenant_id,
|
||||
datasource_type=DatasourceProviderType.ONLINE_DOCUMENT,
|
||||
)
|
||||
datasource_provider_service = DatasourceProviderService()
|
||||
if credential:
|
||||
datasource_runtime.runtime.credentials = credential
|
||||
datasource_runtime = cast(OnlineDocumentDatasourcePlugin, datasource_runtime)
|
||||
online_document_result: Generator[OnlineDocumentPagesMessage, None, None] = (
|
||||
datasource_runtime.get_online_document_pages(
|
||||
user_id=current_user.id,
|
||||
datasource_parameters={},
|
||||
provider_type=datasource_runtime.datasource_provider_type(),
|
||||
)
|
||||
)
|
||||
request_context: RequestContext,
|
||||
) -> tuple[dict[str, object], int]:
|
||||
try:
|
||||
pages = []
|
||||
workspace_info = {}
|
||||
for message in online_document_result:
|
||||
result = message.result
|
||||
for info in result:
|
||||
workspace_info = {
|
||||
"workspace_id": info.workspace_id,
|
||||
"workspace_name": info.workspace_name,
|
||||
"workspace_icon": info.workspace_icon,
|
||||
}
|
||||
for page in info.pages:
|
||||
page_info = {
|
||||
"page_id": page.page_id,
|
||||
"page_name": page.page_name,
|
||||
"type": page.type,
|
||||
"parent_id": page.parent_id,
|
||||
"is_bound": page.page_id in exist_page_ids,
|
||||
"page_icon": page.page_icon,
|
||||
}
|
||||
pages.append(page_info)
|
||||
except Exception as e:
|
||||
raise e
|
||||
notion_info = [{**workspace_info, "pages": pages}] if workspace_info else []
|
||||
return dump_response(NotionIntegrateInfoListResponse, {"notion_info": notion_info}), 200
|
||||
result = application_services().data_sources.notion_imports.list_pages(
|
||||
request_context,
|
||||
credential_id=req_data.credential_id,
|
||||
dataset_id=req_data.dataset_id,
|
||||
)
|
||||
except NotionImportCredentialUnavailableError as error:
|
||||
raise NotFoundError(description="Credential not found.") from error
|
||||
except (DatasetNotFoundError, DatasetAccessDeniedError) as error:
|
||||
_raise_dataset_access_error(error)
|
||||
except DatasetIsNotNotionSourceError as error:
|
||||
raise InvalidArgumentError(description="Dataset is not notion type.") from error
|
||||
return dump_response(NotionIntegrateInfoListResponse, {"notion_info": result.workspaces}), 200
|
||||
|
||||
|
||||
@console_ns.route("/notion/pages/<uuid:page_id>/<string:page_type>/preview")
|
||||
class DataSourceNotionPreviewApi(Resource):
|
||||
"""Preview one authorized Notion page through the datasource credential."""
|
||||
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@console_ns.doc(params=query_params_from_model(DataSourceNotionPreviewQuery))
|
||||
@console_ns.response(200, "Success", console_ns.models[TextContentResponse.__name__])
|
||||
@with_current_tenant_id
|
||||
@console_account_admission()
|
||||
@model_validate(DataSourceNotionPreviewQuery)
|
||||
def get(
|
||||
self,
|
||||
req_data: DataSourceNotionPreviewQuery,
|
||||
current_tenant_id: str,
|
||||
request_context: RequestContext,
|
||||
page_id: UUID,
|
||||
page_type: str,
|
||||
) -> tuple[dict[str, str], int]:
|
||||
|
||||
datasource_provider_service = DatasourceProviderService()
|
||||
credential = datasource_provider_service.get_datasource_credentials(
|
||||
tenant_id=current_tenant_id,
|
||||
credential_id=req_data.credential_id,
|
||||
provider="notion_datasource",
|
||||
plugin_id="langgenius/notion_datasource",
|
||||
)
|
||||
|
||||
page_id_str = str(page_id)
|
||||
|
||||
extractor = NotionExtractor(
|
||||
notion_workspace_id="",
|
||||
notion_obj_id=page_id_str,
|
||||
notion_page_type=page_type,
|
||||
notion_access_token=credential.get("integration_secret"),
|
||||
tenant_id=current_tenant_id,
|
||||
)
|
||||
|
||||
text_docs = extractor.extract()
|
||||
return {"content": "\n".join([doc.page_content for doc in text_docs])}, 200
|
||||
) -> tuple[dict[str, object], int]:
|
||||
try:
|
||||
validated_page_type = NotionPageType(page_type)
|
||||
except ValueError as error:
|
||||
raise InvalidArgumentError(description="Invalid Notion page type.") from error
|
||||
try:
|
||||
content = application_services().data_sources.notion_imports.preview_page(
|
||||
request_context,
|
||||
credential_id=req_data.credential_id,
|
||||
page_id=str(page_id),
|
||||
page_type=validated_page_type,
|
||||
)
|
||||
except NotionImportCredentialUnavailableError as error:
|
||||
raise NotFoundError(description="Credential not found.") from error
|
||||
return dump_response(TextContentResponse, {"content": content}), 200
|
||||
|
||||
|
||||
@console_ns.route("/datasets/notion-indexing-estimate")
|
||||
class DataSourceNotionIndexingEstimateApi(Resource):
|
||||
"""Estimate indexing work for selected Notion pages."""
|
||||
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@console_ns.expect(console_ns.models[NotionEstimatePayload.__name__])
|
||||
@console_ns.response(200, "Success", console_ns.models[IndexingEstimate.__name__])
|
||||
@with_current_tenant_id
|
||||
@with_session
|
||||
@console_account_admission()
|
||||
@model_validate(NotionEstimatePayload)
|
||||
def post(
|
||||
self,
|
||||
req_data: NotionEstimatePayload,
|
||||
session: Session,
|
||||
current_tenant_id: str,
|
||||
) -> tuple[dict[str, Any], int]:
|
||||
args = req_data.model_dump()
|
||||
# validate args
|
||||
DocumentService.estimate_args_validate(args)
|
||||
notion_info_list = req_data.notion_info_list
|
||||
extract_settings = []
|
||||
for notion_info in notion_info_list:
|
||||
workspace_id = notion_info["workspace_id"]
|
||||
credential_id = notion_info.get("credential_id")
|
||||
for page in notion_info["pages"]:
|
||||
extract_setting = ExtractSetting(
|
||||
datasource_type=DatasourceType.NOTION,
|
||||
notion_info=NotionInfo.model_validate(
|
||||
{
|
||||
"credential_id": credential_id,
|
||||
"notion_workspace_id": workspace_id,
|
||||
"notion_obj_id": page["page_id"],
|
||||
"notion_page_type": page["type"],
|
||||
"tenant_id": current_tenant_id,
|
||||
}
|
||||
),
|
||||
document_model=args["doc_form"],
|
||||
request_context: RequestContext,
|
||||
) -> tuple[dict[str, object], int]:
|
||||
command = NewSourcesEstimateCommand(
|
||||
sources=tuple(
|
||||
NotionEstimateSource(
|
||||
workspace_id=workspace.workspace_id,
|
||||
page_id=page.page_id,
|
||||
page_type=page.page_type,
|
||||
credential_id=workspace.credential_id,
|
||||
)
|
||||
extract_settings.append(extract_setting)
|
||||
indexing_runner = IndexingRunner()
|
||||
response = indexing_runner.indexing_estimate(
|
||||
tenant_id=current_tenant_id,
|
||||
extract_settings=extract_settings,
|
||||
tmp_processing_rule=args["process_rule"],
|
||||
doc_form=args["doc_form"],
|
||||
doc_language=args["doc_language"],
|
||||
session=session,
|
||||
for workspace in req_data.notion_info_list
|
||||
for page in workspace.pages
|
||||
),
|
||||
process_rule=req_data.process_rule,
|
||||
doc_form=req_data.doc_form,
|
||||
doc_language=req_data.doc_language,
|
||||
)
|
||||
try:
|
||||
response = application_services().knowledge.indexing_estimates.estimate_new_sources(
|
||||
request_context,
|
||||
command,
|
||||
)
|
||||
except IndexingEstimateCredentialUnavailableError as error:
|
||||
raise NotFoundError(description="Credential not found.") from error
|
||||
except EstimateSourceNotFoundError as error:
|
||||
raise NotFoundError(description=str(error)) from error
|
||||
except IndexingEstimateProviderUnavailableError as error:
|
||||
raise ProviderNotInitializeError(str(error)) from error
|
||||
except IndexingEstimateExecutionError as error:
|
||||
raise IndexingEstimateError(str(error)) from error
|
||||
return dump_response(IndexingEstimate, response), 200
|
||||
|
||||
|
||||
@console_ns.route("/datasets/<uuid:dataset_id>/notion/sync")
|
||||
class DataSourceNotionDatasetSyncApi(Resource):
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@console_ns.response(200, "Success", console_ns.models[SimpleResultResponse.__name__])
|
||||
@rbac_permission_required(RBACCheck(RBACPermission.DATASET_EDIT, DatasetId()))
|
||||
@with_session(write=False)
|
||||
def get(self, session: Session, dataset_id: UUID) -> tuple[dict[str, str], int]:
|
||||
dataset_id_str = str(dataset_id)
|
||||
dataset = DatasetService.get_dataset(dataset_id_str, session)
|
||||
if dataset is None:
|
||||
raise NotFound("Dataset not found.")
|
||||
|
||||
documents = DocumentService.get_document_by_dataset_id(dataset_id_str, session)
|
||||
for document in documents:
|
||||
document_indexing_sync_task.delay(dataset_id_str, document.id)
|
||||
return {"result": "success"}, 200
|
||||
@console_account_admission(
|
||||
rbac_checks=(RBACCheck(RBACPermission.DATASET_EDIT, DatasetId()),),
|
||||
)
|
||||
def get(self, request_context: RequestContext, dataset_id: UUID) -> tuple[dict[str, object], int]:
|
||||
try:
|
||||
application_services().knowledge.document_sync.sync_dataset(request_context, str(dataset_id))
|
||||
except (DatasetNotFoundError, DatasetAccessDeniedError) as error:
|
||||
_raise_dataset_access_error(error)
|
||||
return dump_response(SimpleResultResponse, {"result": "success"}), 200
|
||||
|
||||
|
||||
@console_ns.route("/datasets/<uuid:dataset_id>/documents/<uuid:document_id>/notion/sync")
|
||||
class DataSourceNotionDocumentSyncApi(Resource):
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@console_ns.response(200, "Success", console_ns.models[SimpleResultResponse.__name__])
|
||||
@rbac_permission_required(RBACCheck(RBACPermission.DATASET_EDIT, DatasetId()))
|
||||
@with_session(write=False)
|
||||
def get(self, session: Session, dataset_id: UUID, document_id: UUID) -> tuple[dict[str, str], int]:
|
||||
dataset_id_str = str(dataset_id)
|
||||
document_id_str = str(document_id)
|
||||
dataset = DatasetService.get_dataset(dataset_id_str, session)
|
||||
if dataset is None:
|
||||
raise NotFound("Dataset not found.")
|
||||
|
||||
document = DocumentService.get_document(dataset_id_str, document_id_str, session=session)
|
||||
if document is None:
|
||||
raise NotFound("Document not found.")
|
||||
document_indexing_sync_task.delay(dataset_id_str, document_id_str)
|
||||
return {"result": "success"}, 200
|
||||
@console_account_admission(
|
||||
rbac_checks=(RBACCheck(RBACPermission.DATASET_EDIT, DatasetId()),),
|
||||
)
|
||||
def get(
|
||||
self,
|
||||
request_context: RequestContext,
|
||||
dataset_id: UUID,
|
||||
document_id: UUID,
|
||||
) -> tuple[dict[str, object], int]:
|
||||
try:
|
||||
application_services().knowledge.document_sync.sync_document(
|
||||
request_context,
|
||||
str(dataset_id),
|
||||
str(document_id),
|
||||
)
|
||||
except (DatasetNotFoundError, DatasetAccessDeniedError) as error:
|
||||
_raise_dataset_access_error(error)
|
||||
except SyncDocumentNotFoundError as error:
|
||||
raise NotFoundError(description="Document not found.") from error
|
||||
except SyncDocumentSourceError as error:
|
||||
raise InvalidArgumentError(description=str(error)) from error
|
||||
return dump_response(SimpleResultResponse, {"result": "success"}), 200
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -25,6 +25,12 @@ class InvalidActionError(BaseHTTPException):
|
||||
code = 400
|
||||
|
||||
|
||||
class DatasetAccessDeniedRequestError(BaseHTTPException):
|
||||
error_code = "dataset_access_denied"
|
||||
description = "Dataset access denied."
|
||||
code = 403
|
||||
|
||||
|
||||
class DocumentAlreadyFinishedError(BaseHTTPException):
|
||||
error_code = "document_already_finished"
|
||||
description = "The document has been processed. Please refresh the page or go to the document details."
|
||||
|
||||
@@ -1,16 +1,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
from typing import Any, Never
|
||||
from uuid import UUID
|
||||
|
||||
from flask_restx import Resource
|
||||
from pydantic import AliasChoices, BaseModel, Field, field_validator
|
||||
from sqlalchemy.orm import Session
|
||||
from werkzeug.exceptions import Forbidden, InternalServerError, NotFound
|
||||
|
||||
import services
|
||||
from controllers.common.fields import UsageCountResponse
|
||||
from controllers.common.rbac import DatasetId, RBACCheck, Workspace
|
||||
from controllers.common.schema import (
|
||||
@@ -18,31 +15,42 @@ from controllers.common.schema import (
|
||||
register_response_schema_models,
|
||||
register_schema_models,
|
||||
)
|
||||
from controllers.common.session import with_session
|
||||
from controllers.console import console_ns
|
||||
from controllers.console.datasets.error import DatasetNameDuplicateError
|
||||
from controllers.console.flask_admission import console_account_admission
|
||||
from controllers.console.wraps import (
|
||||
RBACPermission,
|
||||
account_initialization_required,
|
||||
edit_permission_required,
|
||||
model_validate,
|
||||
rbac_permission_required,
|
||||
setup_required,
|
||||
with_current_tenant_id,
|
||||
with_current_user,
|
||||
)
|
||||
from extensions.ext_application_services import application_services
|
||||
from fields.base import ResponseModel
|
||||
from fields.dataset_fields import DatasetDetailResponse, dataset_detail_response_source
|
||||
from fields.dataset_fields import (
|
||||
DatasetDetailResponse,
|
||||
)
|
||||
from libs.helper import dump_response
|
||||
from libs.login import login_required
|
||||
from libs.pagination import clamp_pagination
|
||||
from models import Account
|
||||
from models.dataset import ExternalKnowledgeApis
|
||||
from services.dataset_service import DatasetService
|
||||
from services.enterprise import rbac_service as enterprise_rbac_service
|
||||
from machinery.context import RequestContext
|
||||
from models.account import TenantAccountRole
|
||||
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.errors.dataset import DatasetNameDuplicateError as DatasetNameDuplicateFailure
|
||||
from services.knowledge.dataset_access import DatasetAccessDeniedError, DatasetNotFoundError
|
||||
from services.knowledge.external.application import ExternalHitTestingError, ExternalTemplateNotFoundError
|
||||
|
||||
_DATASET_EDIT_ROLES = frozenset(
|
||||
{TenantAccountRole.OWNER, TenantAccountRole.ADMIN, TenantAccountRole.EDITOR, TenantAccountRole.DATASET_OPERATOR}
|
||||
)
|
||||
_EXTERNAL_CONNECT_ROLES = frozenset({TenantAccountRole.OWNER, TenantAccountRole.ADMIN, TenantAccountRole.EDITOR})
|
||||
|
||||
|
||||
def _raise_external_error(error: Exception) -> Never:
|
||||
if isinstance(error, (DatasetNotFoundError, ExternalTemplateNotFoundError)):
|
||||
raise NotFound(str(error)) from error
|
||||
if isinstance(error, DatasetAccessDeniedError):
|
||||
raise Forbidden(str(error)) from error
|
||||
if isinstance(error, DatasetNameDuplicateFailure):
|
||||
raise DatasetNameDuplicateError() from error
|
||||
if isinstance(error, ExternalHitTestingError):
|
||||
raise InternalServerError(str(error)) from error
|
||||
raise error
|
||||
|
||||
|
||||
class ExternalKnowledgeApiPayload(BaseModel):
|
||||
@@ -85,28 +93,6 @@ class ExternalKnowledgeApiResponse(ResponseModel):
|
||||
return value
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ExternalKnowledgeApiResponseSource:
|
||||
external_knowledge_api: ExternalKnowledgeApis
|
||||
session: Session
|
||||
|
||||
@property
|
||||
def dataset_bindings(self) -> Any:
|
||||
return self.external_knowledge_api.get_dataset_bindings(session=self.session)
|
||||
|
||||
def __getattr__(self, name: str) -> Any:
|
||||
return getattr(self.external_knowledge_api, name) # guard-ignore: no-new-getattr -- delegates model fields
|
||||
|
||||
|
||||
def external_knowledge_api_response(
|
||||
external_knowledge_api: ExternalKnowledgeApis, *, session: Session
|
||||
) -> ExternalKnowledgeApiResponse:
|
||||
return ExternalKnowledgeApiResponse.model_validate(
|
||||
ExternalKnowledgeApiResponseSource(external_knowledge_api=external_knowledge_api, session=session),
|
||||
from_attributes=True,
|
||||
)
|
||||
|
||||
|
||||
class ExternalKnowledgeApiListResponse(ResponseModel):
|
||||
data: list[ExternalKnowledgeApiResponse]
|
||||
has_more: bool
|
||||
@@ -161,25 +147,13 @@ class ExternalApiTemplateListApi(Resource):
|
||||
"External API templates retrieved successfully",
|
||||
console_ns.models[ExternalKnowledgeApiListResponse.__name__],
|
||||
)
|
||||
@setup_required
|
||||
@login_required
|
||||
@with_current_tenant_id
|
||||
@account_initialization_required
|
||||
@with_session(write=False)
|
||||
@console_account_admission()
|
||||
@model_validate(ExternalApiTemplateListQuery)
|
||||
def get(self, req_data: ExternalApiTemplateListQuery, session: Session, current_tenant_id: str):
|
||||
|
||||
effective_page, effective_limit = clamp_pagination(req_data.page, req_data.limit, 100)
|
||||
external_knowledge_apis, total = ExternalDatasetService.get_external_knowledge_apis(
|
||||
effective_page, effective_limit, current_tenant_id, req_data.keyword, session=session
|
||||
def get(self, req_data: ExternalApiTemplateListQuery, request_context: RequestContext):
|
||||
result = application_services().knowledge.external.list_templates(
|
||||
request_context, page=req_data.page, limit=req_data.limit, keyword=req_data.keyword
|
||||
)
|
||||
return ExternalKnowledgeApiListResponse(
|
||||
data=[external_knowledge_api_response(item, session=session) for item in external_knowledge_apis],
|
||||
has_more=effective_page * effective_limit < total,
|
||||
limit=effective_limit,
|
||||
total=total,
|
||||
page=effective_page,
|
||||
).model_dump(mode="json"), 200
|
||||
return dump_response(ExternalKnowledgeApiListResponse, result), 200
|
||||
|
||||
@console_ns.doc("create_external_api_template")
|
||||
@console_ns.doc(description="Create external knowledge API template")
|
||||
@@ -190,48 +164,16 @@ class ExternalApiTemplateListApi(Resource):
|
||||
console_ns.models[ExternalKnowledgeApiResponse.__name__],
|
||||
)
|
||||
@console_ns.response(403, "Permission denied")
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
@with_session
|
||||
@console_account_admission(allowed_roles=_DATASET_EDIT_ROLES)
|
||||
@model_validate(ExternalKnowledgeApiPayload)
|
||||
def post(
|
||||
self,
|
||||
req_data: ExternalKnowledgeApiPayload,
|
||||
session: Session,
|
||||
current_tenant_id: str,
|
||||
current_user: Account,
|
||||
):
|
||||
|
||||
ExternalDatasetService.validate_api_list(req_data.settings)
|
||||
|
||||
# The role of the current user in the ta table must be admin, owner, or editor, or dataset_operator
|
||||
if not current_user.is_dataset_editor:
|
||||
raise Forbidden()
|
||||
|
||||
def post(self, req_data: ExternalKnowledgeApiPayload, request_context: RequestContext):
|
||||
try:
|
||||
external_knowledge_api = ExternalDatasetService.create_external_knowledge_api(
|
||||
tenant_id=current_tenant_id,
|
||||
user_id=current_user.id,
|
||||
args=req_data.model_dump(),
|
||||
session=session,
|
||||
result = application_services().knowledge.external.create_template(
|
||||
request_context, name=req_data.name, settings=req_data.settings
|
||||
)
|
||||
except services.errors.dataset.DatasetNameDuplicateError:
|
||||
raise DatasetNameDuplicateError()
|
||||
|
||||
# if dify_config.RBAC_ENABLED:
|
||||
# enterprise_rbac_service.RBACService.DatasetAccess.replace_whitelist(
|
||||
# current_tenant_id,
|
||||
# current_user.id,
|
||||
# external_knowledge_api.id,
|
||||
# enterprise_rbac_service.ReplaceMemberBindings(automatic_include_workspace_members=True),
|
||||
# )
|
||||
# initialize_created_app_rbac_access_task.delay(
|
||||
# current_tenant_id, current_user.id, dataset_id=external_knowledge_api.id)
|
||||
|
||||
return external_knowledge_api_response(external_knowledge_api, session=session).model_dump(mode="json"), 201
|
||||
except Exception as error:
|
||||
_raise_external_error(error)
|
||||
return dump_response(ExternalKnowledgeApiResponse, result), 201
|
||||
|
||||
|
||||
@console_ns.route("/datasets/external-knowledge-api/<uuid:external_knowledge_api_id>")
|
||||
@@ -245,20 +187,15 @@ class ExternalApiTemplateApi(Resource):
|
||||
console_ns.models[ExternalKnowledgeApiResponse.__name__],
|
||||
)
|
||||
@console_ns.response(404, "Template not found")
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_current_tenant_id
|
||||
@with_session
|
||||
def get(self, session: Session, current_tenant_id: str, external_knowledge_api_id: UUID):
|
||||
external_knowledge_api_id_str = str(external_knowledge_api_id)
|
||||
external_knowledge_api = ExternalDatasetService.get_external_knowledge_api(
|
||||
external_knowledge_api_id=external_knowledge_api_id_str, tenant_id=current_tenant_id, session=session
|
||||
)
|
||||
if external_knowledge_api is None:
|
||||
raise NotFound("API template not found.")
|
||||
|
||||
return external_knowledge_api_response(external_knowledge_api, session=session).model_dump(mode="json"), 200
|
||||
@console_account_admission()
|
||||
def get(self, request_context: RequestContext, external_knowledge_api_id: UUID):
|
||||
try:
|
||||
result = application_services().knowledge.external.get_template(
|
||||
request_context, template_id=str(external_knowledge_api_id)
|
||||
)
|
||||
except Exception as error:
|
||||
_raise_external_error(error)
|
||||
return dump_response(ExternalKnowledgeApiResponse, result), 200
|
||||
|
||||
@console_ns.doc("update_external_api_template")
|
||||
@console_ns.doc(description="Update external knowledge API template")
|
||||
@@ -270,51 +207,31 @@ class ExternalApiTemplateApi(Resource):
|
||||
console_ns.models[ExternalKnowledgeApiResponse.__name__],
|
||||
)
|
||||
@console_ns.response(404, "Template not found")
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
@with_session
|
||||
@console_account_admission()
|
||||
@model_validate(ExternalKnowledgeApiPayload)
|
||||
def patch(
|
||||
self,
|
||||
req_data: ExternalKnowledgeApiPayload,
|
||||
session: Session,
|
||||
current_tenant_id: str,
|
||||
current_user: Account,
|
||||
external_knowledge_api_id: UUID,
|
||||
self, req_data: ExternalKnowledgeApiPayload, request_context: RequestContext, external_knowledge_api_id: UUID
|
||||
):
|
||||
external_knowledge_api_id_str = str(external_knowledge_api_id)
|
||||
try:
|
||||
result = application_services().knowledge.external.update_template(
|
||||
request_context,
|
||||
template_id=str(external_knowledge_api_id),
|
||||
name=req_data.name,
|
||||
settings=req_data.settings,
|
||||
)
|
||||
except Exception as error:
|
||||
_raise_external_error(error)
|
||||
return dump_response(ExternalKnowledgeApiResponse, result), 200
|
||||
|
||||
ExternalDatasetService.validate_api_list(req_data.settings)
|
||||
|
||||
external_knowledge_api = ExternalDatasetService.update_external_knowledge_api(
|
||||
tenant_id=current_tenant_id,
|
||||
user_id=current_user.id,
|
||||
external_knowledge_api_id=external_knowledge_api_id_str,
|
||||
args=req_data.model_dump(),
|
||||
session=session,
|
||||
)
|
||||
|
||||
return external_knowledge_api_response(external_knowledge_api, session=session).model_dump(mode="json"), 200
|
||||
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@console_ns.response(204, "External knowledge API deleted successfully")
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
@with_session
|
||||
def delete(self, session: Session, current_tenant_id: str, current_user: Account, external_knowledge_api_id: UUID):
|
||||
external_knowledge_api_id_str = str(external_knowledge_api_id)
|
||||
|
||||
if not (current_user.has_edit_permission or current_user.is_dataset_operator):
|
||||
raise Forbidden()
|
||||
|
||||
ExternalDatasetService.delete_external_knowledge_api(
|
||||
current_tenant_id, external_knowledge_api_id_str, session=session
|
||||
)
|
||||
@console_account_admission(allowed_roles=_DATASET_EDIT_ROLES)
|
||||
def delete(self, request_context: RequestContext, external_knowledge_api_id: UUID):
|
||||
try:
|
||||
application_services().knowledge.external.delete_template(
|
||||
request_context, template_id=str(external_knowledge_api_id)
|
||||
)
|
||||
except Exception as error:
|
||||
_raise_external_error(error)
|
||||
return "", 204
|
||||
|
||||
|
||||
@@ -324,18 +241,12 @@ class ExternalApiUseCheckApi(Resource):
|
||||
@console_ns.doc(description="Check if external knowledge API is being used")
|
||||
@console_ns.doc(params={"external_knowledge_api_id": "External knowledge API ID"})
|
||||
@console_ns.response(200, "Usage check completed successfully", console_ns.models[UsageCountResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_current_tenant_id
|
||||
@with_session
|
||||
def get(self, session: Session, current_tenant_id: str, external_knowledge_api_id: UUID):
|
||||
external_knowledge_api_id_str = str(external_knowledge_api_id)
|
||||
|
||||
external_knowledge_api_is_using, count = ExternalDatasetService.external_knowledge_api_use_check(
|
||||
external_knowledge_api_id_str, current_tenant_id, session=session
|
||||
@console_account_admission()
|
||||
def get(self, request_context: RequestContext, external_knowledge_api_id: UUID):
|
||||
is_using, count = application_services().knowledge.external.template_usage(
|
||||
request_context, template_id=str(external_knowledge_api_id)
|
||||
)
|
||||
return UsageCountResponse(is_using=external_knowledge_api_is_using, count=count).model_dump(mode="json"), 200
|
||||
return dump_response(UsageCountResponse, {"is_using": is_using, "count": count}), 200
|
||||
|
||||
|
||||
@console_ns.route("/datasets/external")
|
||||
@@ -348,50 +259,17 @@ class ExternalDatasetCreateApi(Resource):
|
||||
)
|
||||
@console_ns.response(400, "Invalid parameters")
|
||||
@console_ns.response(403, "Permission denied")
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@edit_permission_required
|
||||
@rbac_permission_required(RBACCheck(RBACPermission.DATASET_EXTERNAL_CONNECT, Workspace()))
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
@with_session
|
||||
@console_account_admission(
|
||||
allowed_roles=_EXTERNAL_CONNECT_ROLES,
|
||||
rbac_checks=(RBACCheck(RBACPermission.DATASET_EXTERNAL_CONNECT, Workspace()),),
|
||||
)
|
||||
@model_validate(ExternalDatasetCreatePayload)
|
||||
def post(
|
||||
self,
|
||||
req_data: ExternalDatasetCreatePayload,
|
||||
session: Session,
|
||||
current_tenant_id: str,
|
||||
current_user: Account,
|
||||
):
|
||||
# The role of the current user in the ta table must be admin, owner, or editor
|
||||
|
||||
# The role of the current user in the ta table must be admin, owner, or editor, or dataset_operator
|
||||
if not current_user.is_dataset_editor:
|
||||
raise Forbidden()
|
||||
|
||||
def post(self, req_data: ExternalDatasetCreatePayload, request_context: RequestContext):
|
||||
try:
|
||||
dataset = ExternalDatasetService.create_external_dataset(
|
||||
tenant_id=current_tenant_id,
|
||||
user_id=current_user.id,
|
||||
args=req_data,
|
||||
session=session,
|
||||
)
|
||||
except services.errors.dataset.DatasetNameDuplicateError:
|
||||
raise DatasetNameDuplicateError()
|
||||
|
||||
dataset_id_str = str(dataset.id)
|
||||
permission_keys_map = enterprise_rbac_service.RBACService.DatasetPermissions.batch_get(
|
||||
str(current_tenant_id),
|
||||
current_user.id,
|
||||
[dataset_id_str],
|
||||
session=session,
|
||||
)
|
||||
data = DatasetDetailResponse.model_validate(
|
||||
dataset_detail_response_source(dataset, session=session)
|
||||
).model_dump(mode="json")
|
||||
data["permission_keys"] = permission_keys_map.get(dataset_id_str, [])
|
||||
return data, 201
|
||||
result = application_services().knowledge.external.create_dataset(request_context, payload=req_data)
|
||||
except Exception as error:
|
||||
_raise_external_error(error)
|
||||
return dump_response(DatasetDetailResponse, result), 201
|
||||
|
||||
|
||||
@console_ns.route("/datasets/<uuid:dataset_id>/external-hit-testing")
|
||||
@@ -407,36 +285,17 @@ class ExternalKnowledgeHitTestingApi(Resource):
|
||||
)
|
||||
@console_ns.response(404, "Dataset not found")
|
||||
@console_ns.response(400, "Invalid parameters")
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_current_user
|
||||
@rbac_permission_required(RBACCheck(RBACPermission.DATASET_PIPELINE_TEST, DatasetId()))
|
||||
@with_session
|
||||
@console_account_admission(rbac_checks=(RBACCheck(RBACPermission.DATASET_PIPELINE_TEST, DatasetId()),))
|
||||
@model_validate(ExternalHitTestingPayload)
|
||||
def post(self, req_data: ExternalHitTestingPayload, 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.")
|
||||
|
||||
def post(self, req_data: ExternalHitTestingPayload, request_context: RequestContext, dataset_id: UUID):
|
||||
try:
|
||||
DatasetService.check_dataset_permission(dataset, current_user, session)
|
||||
except services.errors.account.NoPermissionError as e:
|
||||
raise Forbidden(str(e))
|
||||
|
||||
HitTestingService.hit_testing_args_check(req_data.model_dump())
|
||||
|
||||
try:
|
||||
response = HitTestingService.external_retrieve(
|
||||
session=session,
|
||||
dataset=dataset,
|
||||
result = application_services().knowledge.external.hit_testing(
|
||||
request_context,
|
||||
dataset_id=str(dataset_id),
|
||||
query=req_data.query,
|
||||
account=current_user,
|
||||
external_retrieval_model=req_data.external_retrieval_model,
|
||||
metadata_filtering_conditions=req_data.metadata_filtering_conditions,
|
||||
retrieval_model=req_data.external_retrieval_model,
|
||||
metadata_filters=req_data.metadata_filtering_conditions,
|
||||
)
|
||||
|
||||
return dump_response(ExternalHitTestingResponse, response)
|
||||
except Exception as e:
|
||||
raise InternalServerError(str(e))
|
||||
except Exception as error:
|
||||
_raise_external_error(error)
|
||||
return dump_response(ExternalHitTestingResponse, result)
|
||||
|
||||
@@ -5,7 +5,7 @@ from pydantic import BaseModel, Field
|
||||
from sqlalchemy.orm import Session
|
||||
from werkzeug.exceptions import Forbidden, InternalServerError, NotFound
|
||||
|
||||
import services
|
||||
import services.errors.base
|
||||
from controllers.console.app.error import (
|
||||
CompletionRequestError,
|
||||
ProviderModelCurrentlyNotSupportError,
|
||||
@@ -23,9 +23,9 @@ from graphon.model_runtime.errors.invoke import InvokeError
|
||||
from libs.login import resolve_account_fallback
|
||||
from models.account import Account
|
||||
from models.dataset import Dataset
|
||||
from services.dataset_service import DatasetService
|
||||
from services.entities.knowledge_entities.knowledge_entities import ExternalRetrievalModel, RetrievalModel
|
||||
from services.hit_testing_service import HitTestingService
|
||||
from services.knowledge.dataset_service import DatasetService
|
||||
from services.knowledge.entities.knowledge_entities import ExternalRetrievalModel, RetrievalModel
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -97,7 +97,7 @@ class DatasetsHitTestingBase:
|
||||
|
||||
try:
|
||||
DatasetService.check_dataset_permission(dataset, current_user, session)
|
||||
except services.errors.account.NoPermissionError as e:
|
||||
except services.errors.base.NoPermissionError as e:
|
||||
raise Forbidden(str(e))
|
||||
|
||||
return dataset
|
||||
|
||||
@@ -1,43 +1,47 @@
|
||||
from typing import Literal
|
||||
from collections.abc import Callable
|
||||
from functools import wraps
|
||||
from uuid import UUID
|
||||
|
||||
from flask_restx import Resource
|
||||
from sqlalchemy.orm import Session
|
||||
from werkzeug.exceptions import Forbidden, NotFound
|
||||
|
||||
import services
|
||||
from controllers.common.controller_schemas import MetadataUpdatePayload
|
||||
from controllers.common.rbac import DatasetId, RBACCheck
|
||||
from controllers.common.schema import register_response_schema_models, register_schema_models
|
||||
from controllers.common.session import with_session
|
||||
from controllers.console import console_ns
|
||||
from controllers.console.wraps import (
|
||||
RBACPermission,
|
||||
account_initialization_required,
|
||||
enterprise_license_required,
|
||||
model_validate,
|
||||
rbac_permission_required,
|
||||
setup_required,
|
||||
with_current_tenant_id,
|
||||
with_current_user,
|
||||
)
|
||||
from controllers.console.flask_admission import console_account_admission
|
||||
from controllers.console.wraps import model_validate
|
||||
from core.rbac import RBACPermission
|
||||
from extensions.ext_application_services import application_services
|
||||
from fields.dataset_fields import (
|
||||
DatasetMetadataBuiltInFieldsResponse,
|
||||
DatasetMetadataListResponse,
|
||||
DatasetMetadataResponse,
|
||||
)
|
||||
from libs.helper import dump_response
|
||||
from libs.login import login_required
|
||||
from models.account import Account
|
||||
from services.dataset_service import DatasetService
|
||||
from services.entities.knowledge_entities.knowledge_entities import (
|
||||
from machinery.context import RequestContext
|
||||
from services.errors.metadata import MetadataResourceNotFoundError
|
||||
from services.knowledge.dataset_access import DatasetAccessDeniedError, DatasetNotFoundError
|
||||
from services.knowledge.entities.knowledge_entities import (
|
||||
DocumentMetadataOperation,
|
||||
MetadataArgs,
|
||||
MetadataDetail,
|
||||
MetadataOperationData,
|
||||
)
|
||||
from services.errors.metadata import MetadataResourceNotFoundError
|
||||
from services.metadata_service import MetadataService
|
||||
|
||||
|
||||
def metadata_errors[**P, T](method: Callable[P, T]) -> Callable[P, T]:
|
||||
@wraps(method)
|
||||
def wrapped(*args: P.args, **kwargs: P.kwargs) -> T:
|
||||
try:
|
||||
return method(*args, **kwargs)
|
||||
except (DatasetNotFoundError, MetadataResourceNotFoundError) as error:
|
||||
raise NotFound(str(error)) from error
|
||||
except DatasetAccessDeniedError as error:
|
||||
raise Forbidden(str(error)) from error
|
||||
|
||||
return wrapped
|
||||
|
||||
|
||||
register_schema_models(
|
||||
console_ns, MetadataArgs, MetadataOperationData, MetadataUpdatePayload, DocumentMetadataOperation, MetadataDetail
|
||||
@@ -52,199 +56,111 @@ register_response_schema_models(
|
||||
|
||||
@console_ns.route("/datasets/<uuid:dataset_id>/metadata")
|
||||
class DatasetMetadataCreateApi(Resource):
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@enterprise_license_required
|
||||
@console_ns.response(201, "Metadata created successfully", console_ns.models[DatasetMetadataResponse.__name__])
|
||||
@console_ns.expect(console_ns.models[MetadataArgs.__name__])
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
@rbac_permission_required(RBACCheck(RBACPermission.DATASET_EDIT, DatasetId()))
|
||||
@with_session
|
||||
@console_account_admission(
|
||||
rbac_checks=(RBACCheck(RBACPermission.DATASET_EDIT, DatasetId()),),
|
||||
)
|
||||
@metadata_errors
|
||||
@model_validate(MetadataArgs)
|
||||
def post(
|
||||
self,
|
||||
req_data: MetadataArgs,
|
||||
session: Session,
|
||||
current_tenant_id: str,
|
||||
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)
|
||||
|
||||
metadata = MetadataService.create_metadata(
|
||||
dataset_id_str, req_data, current_user, current_tenant_id, session=session
|
||||
)
|
||||
def post(self, req_data: MetadataArgs, request_context: RequestContext, dataset_id: UUID):
|
||||
service = application_services().knowledge.metadata
|
||||
ref = service.require_dataset(request_context, str(dataset_id))
|
||||
metadata = service.create_metadata(ref, req_data, actor_id=request_context.account_id)
|
||||
return dump_response(DatasetMetadataResponse, metadata), 201
|
||||
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@enterprise_license_required
|
||||
@console_ns.response(
|
||||
200, "Metadata retrieved successfully", console_ns.models[DatasetMetadataListResponse.__name__]
|
||||
)
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
@rbac_permission_required(RBACCheck(RBACPermission.DATASET_READONLY, DatasetId()))
|
||||
@with_session(write=False)
|
||||
def get(self, session: Session, current_tenant_id: str, current_user: Account, dataset_id: UUID):
|
||||
dataset_id_str = str(dataset_id)
|
||||
dataset = DatasetService.get_dataset_for_tenant(dataset_id_str, current_tenant_id, session=session)
|
||||
if dataset is None:
|
||||
raise NotFound("Dataset not found.")
|
||||
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
|
||||
@console_account_admission(
|
||||
rbac_checks=(RBACCheck(RBACPermission.DATASET_READONLY, DatasetId()),),
|
||||
)
|
||||
@metadata_errors
|
||||
def get(self, request_context: RequestContext, dataset_id: UUID):
|
||||
service = application_services().knowledge.metadata
|
||||
ref = service.require_dataset(request_context, str(dataset_id))
|
||||
return dump_response(DatasetMetadataListResponse, service.get_dataset_metadatas(ref)), 200
|
||||
|
||||
|
||||
@console_ns.route("/datasets/<uuid:dataset_id>/metadata/<uuid:metadata_id>")
|
||||
class DatasetMetadataApi(Resource):
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@enterprise_license_required
|
||||
@console_ns.response(200, "Metadata updated successfully", console_ns.models[DatasetMetadataResponse.__name__])
|
||||
@console_ns.expect(console_ns.models[MetadataUpdatePayload.__name__])
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
@rbac_permission_required(RBACCheck(RBACPermission.DATASET_EDIT, DatasetId()))
|
||||
@with_session
|
||||
@console_account_admission(
|
||||
rbac_checks=(RBACCheck(RBACPermission.DATASET_EDIT, DatasetId()),),
|
||||
)
|
||||
@metadata_errors
|
||||
@model_validate(MetadataUpdatePayload)
|
||||
def patch(
|
||||
self,
|
||||
req_data: MetadataUpdatePayload,
|
||||
session: Session,
|
||||
current_tenant_id: str,
|
||||
current_user: Account,
|
||||
dataset_id: UUID,
|
||||
metadata_id: UUID,
|
||||
self, req_data: MetadataUpdatePayload, request_context: RequestContext, dataset_id: UUID, metadata_id: UUID
|
||||
):
|
||||
name = req_data.name
|
||||
|
||||
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)
|
||||
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)
|
||||
service = application_services().knowledge.metadata
|
||||
ref = service.require_dataset(request_context, str(dataset_id))
|
||||
metadata = service.update_metadata_name(
|
||||
ref, str(metadata_id), req_data.name, actor_id=request_context.account_id
|
||||
)
|
||||
return dump_response(DatasetMetadataResponse, metadata), 200
|
||||
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@enterprise_license_required
|
||||
@console_ns.response(204, "Metadata deleted successfully")
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
@rbac_permission_required(RBACCheck(RBACPermission.DATASET_EDIT, DatasetId()))
|
||||
@with_session
|
||||
def delete(
|
||||
self,
|
||||
session: Session,
|
||||
current_tenant_id: str,
|
||||
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)
|
||||
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)
|
||||
# Frontend callers only await success and invalidate metadata caches; no response body is consumed.
|
||||
@console_account_admission(
|
||||
rbac_checks=(RBACCheck(RBACPermission.DATASET_EDIT, DatasetId()),),
|
||||
)
|
||||
@metadata_errors
|
||||
def delete(self, request_context: RequestContext, dataset_id: UUID, metadata_id: UUID):
|
||||
service = application_services().knowledge.metadata
|
||||
ref = service.require_dataset(request_context, str(dataset_id))
|
||||
service.delete_metadata(ref, str(metadata_id))
|
||||
return "", 204
|
||||
|
||||
|
||||
@console_ns.route("/datasets/metadata/built-in")
|
||||
class DatasetMetadataBuiltInFieldApi(Resource):
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@enterprise_license_required
|
||||
@console_ns.response(
|
||||
200,
|
||||
"Built-in fields retrieved successfully",
|
||||
console_ns.models[DatasetMetadataBuiltInFieldsResponse.__name__],
|
||||
)
|
||||
def get(self):
|
||||
built_in_fields = MetadataService.get_built_in_fields()
|
||||
return dump_response(DatasetMetadataBuiltInFieldsResponse, {"fields": built_in_fields}), 200
|
||||
@console_account_admission()
|
||||
@metadata_errors
|
||||
def get(self, request_context: RequestContext):
|
||||
fields = application_services().knowledge.metadata.get_built_in_fields()
|
||||
return dump_response(DatasetMetadataBuiltInFieldsResponse, {"fields": fields}), 200
|
||||
|
||||
|
||||
@console_ns.route("/datasets/<uuid:dataset_id>/metadata/built-in/<string:action>")
|
||||
class DatasetMetadataBuiltInFieldActionApi(Resource):
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@enterprise_license_required
|
||||
@console_ns.response(204, "Action completed successfully")
|
||||
@with_current_user
|
||||
@rbac_permission_required(RBACCheck(RBACPermission.DATASET_EDIT, DatasetId()))
|
||||
@with_session
|
||||
def post(self, session: Session, current_user: Account, dataset_id: UUID, action: Literal["enable", "disable"]):
|
||||
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)
|
||||
|
||||
match action:
|
||||
case "enable":
|
||||
MetadataService.enable_built_in_field(dataset, session)
|
||||
case "disable":
|
||||
MetadataService.disable_built_in_field(dataset, session)
|
||||
# Frontend callers only await success and invalidate metadata caches; no response body is consumed.
|
||||
@console_account_admission(
|
||||
rbac_checks=(RBACCheck(RBACPermission.DATASET_EDIT, DatasetId()),),
|
||||
)
|
||||
@metadata_errors
|
||||
def post(self, request_context: RequestContext, dataset_id: UUID, action: str):
|
||||
service = application_services().knowledge.metadata
|
||||
ref = service.require_dataset(request_context, str(dataset_id))
|
||||
if action == "enable":
|
||||
service.enable_built_in_field(ref)
|
||||
elif action == "disable":
|
||||
service.disable_built_in_field(ref)
|
||||
else:
|
||||
raise ValueError("Invalid action.")
|
||||
return "", 204
|
||||
|
||||
|
||||
@console_ns.route("/datasets/<uuid:dataset_id>/documents/metadata")
|
||||
class DocumentMetadataEditApi(Resource):
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@enterprise_license_required
|
||||
@console_ns.expect(console_ns.models[MetadataOperationData.__name__])
|
||||
@console_ns.response(
|
||||
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(RBACCheck(RBACPermission.DATASET_EDIT, DatasetId()))
|
||||
@with_session
|
||||
@console_account_admission(
|
||||
rbac_checks=(RBACCheck(RBACPermission.DATASET_EDIT, DatasetId()),),
|
||||
)
|
||||
@metadata_errors
|
||||
@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)
|
||||
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
|
||||
|
||||
# Frontend callers only await success and invalidate caches; no response body is consumed.
|
||||
def post(self, req_data: MetadataOperationData, request_context: RequestContext, dataset_id: UUID):
|
||||
service = application_services().knowledge.metadata
|
||||
ref = service.require_dataset(request_context, str(dataset_id))
|
||||
service.update_documents_metadata(ref, req_data, actor_id=request_context.account_id)
|
||||
return "", 204
|
||||
|
||||
@@ -24,6 +24,7 @@ from core.entities.provider_entities import ProviderConfig
|
||||
from core.plugin.entities.plugin_daemon import PluginOAuthAuthorizationUrlResponse
|
||||
from core.plugin.impl.oauth import OAuthHandler
|
||||
from core.tools.entities.common_entities import I18nObject
|
||||
from extensions.ext_application_services import application_services
|
||||
from extensions.ext_database import db
|
||||
from fields.base import ResponseModel
|
||||
from graphon.model_runtime.errors.validate import CredentialsValidateFailedError
|
||||
@@ -32,7 +33,6 @@ 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
|
||||
|
||||
|
||||
@@ -176,7 +176,7 @@ class DatasourcePluginOAuthAuthorizationUrl(Resource):
|
||||
datasource_provider_id = DatasourceProviderID(provider_id)
|
||||
provider_name = datasource_provider_id.provider_name
|
||||
plugin_id = datasource_provider_id.plugin_id
|
||||
oauth_config = DatasourceProviderService().get_oauth_client(
|
||||
oauth_config = application_services().data_sources.providers.get_oauth_client(
|
||||
tenant_id=tenant_id,
|
||||
datasource_provider_id=datasource_provider_id,
|
||||
)
|
||||
@@ -246,7 +246,7 @@ class DatasourceOAuthCallback(Resource):
|
||||
tenant_id: str = context["tenant_id"]
|
||||
datasource_provider_id = DatasourceProviderID(provider_id)
|
||||
plugin_id = datasource_provider_id.plugin_id
|
||||
datasource_provider_service = DatasourceProviderService()
|
||||
datasource_provider_service = application_services().data_sources.providers
|
||||
oauth_client_params = datasource_provider_service.get_oauth_client(
|
||||
tenant_id=tenant_id,
|
||||
datasource_provider_id=datasource_provider_id,
|
||||
@@ -315,7 +315,7 @@ class DatasourceAuth(Resource):
|
||||
@model_validate(DatasourceCredentialPayload)
|
||||
def post(self, req_data: DatasourceCredentialPayload, current_tenant_id: str, provider_id: str):
|
||||
datasource_provider_id = DatasourceProviderID(provider_id)
|
||||
datasource_provider_service = DatasourceProviderService()
|
||||
datasource_provider_service = application_services().data_sources.providers
|
||||
|
||||
try:
|
||||
datasource_provider_service.add_datasource_api_key_provider(
|
||||
@@ -342,14 +342,14 @@ class DatasourceAuth(Resource):
|
||||
@with_current_tenant_id
|
||||
def get(self, current_tenant_id: str, user: Account, provider_id: str):
|
||||
datasource_provider_id = DatasourceProviderID(provider_id)
|
||||
datasource_provider_service = DatasourceProviderService()
|
||||
datasource_provider_service = application_services().data_sources.providers
|
||||
|
||||
datasources = datasource_provider_service.list_datasource_credentials(
|
||||
tenant_id=current_tenant_id,
|
||||
provider=datasource_provider_id.provider_name,
|
||||
plugin_id=datasource_provider_id.plugin_id,
|
||||
user=user,
|
||||
session=db.session(),
|
||||
credential_query=application_services().credential_queries,
|
||||
)
|
||||
return dump_response(DatasourceCredentialListResponse, {"result": datasources}), 200
|
||||
|
||||
@@ -370,7 +370,7 @@ class DatasourceAuthDeleteApi(Resource):
|
||||
plugin_id = datasource_provider_id.plugin_id
|
||||
provider_name = datasource_provider_id.provider_name
|
||||
|
||||
datasource_provider_service = DatasourceProviderService()
|
||||
datasource_provider_service = application_services().data_sources.providers
|
||||
datasource_provider_service.remove_datasource_credentials(
|
||||
tenant_id=current_tenant_id,
|
||||
auth_id=req_data.credential_id,
|
||||
@@ -397,7 +397,7 @@ class DatasourceAuthUpdateApi(Resource):
|
||||
def post(self, req_data: DatasourceCredentialUpdatePayload, current_tenant_id: str, provider_id: str):
|
||||
datasource_provider_id = DatasourceProviderID(provider_id)
|
||||
|
||||
datasource_provider_service = DatasourceProviderService()
|
||||
datasource_provider_service = application_services().data_sources.providers
|
||||
datasource_provider_service.update_datasource_credentials(
|
||||
tenant_id=current_tenant_id,
|
||||
auth_id=req_data.credential_id,
|
||||
@@ -422,9 +422,12 @@ class DatasourceAuthListApi(Resource):
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
def get(self, current_tenant_id: str, user: Account):
|
||||
datasource_provider_service = DatasourceProviderService()
|
||||
datasource_provider_service = application_services().data_sources.providers
|
||||
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(),
|
||||
user=user,
|
||||
credential_query=application_services().credential_queries,
|
||||
)
|
||||
return dump_response(DatasourceProviderAuthListResponse, {"result": datasources}), 200
|
||||
|
||||
@@ -442,9 +445,12 @@ class DatasourceHardCodeAuthListApi(Resource):
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
def get(self, current_tenant_id: str, user: Account):
|
||||
datasource_provider_service = DatasourceProviderService()
|
||||
datasource_provider_service = application_services().data_sources.providers
|
||||
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(),
|
||||
user=user,
|
||||
credential_query=application_services().credential_queries,
|
||||
)
|
||||
return dump_response(DatasourceProviderAuthListResponse, {"result": datasources}), 200
|
||||
|
||||
@@ -464,7 +470,7 @@ class DatasourceAuthOauthCustomClient(Resource):
|
||||
@model_validate(DatasourceCustomClientPayload)
|
||||
def post(self, req_data: DatasourceCustomClientPayload, current_tenant_id: str, provider_id: str):
|
||||
datasource_provider_id = DatasourceProviderID(provider_id)
|
||||
datasource_provider_service = DatasourceProviderService()
|
||||
datasource_provider_service = application_services().data_sources.providers
|
||||
datasource_provider_service.setup_oauth_custom_client_params(
|
||||
tenant_id=current_tenant_id,
|
||||
datasource_provider_id=datasource_provider_id,
|
||||
@@ -482,7 +488,7 @@ class DatasourceAuthOauthCustomClient(Resource):
|
||||
@with_current_tenant_id
|
||||
def delete(self, current_tenant_id: str, provider_id: str):
|
||||
datasource_provider_id = DatasourceProviderID(provider_id)
|
||||
datasource_provider_service = DatasourceProviderService()
|
||||
datasource_provider_service = application_services().data_sources.providers
|
||||
datasource_provider_service.remove_oauth_custom_client_params(
|
||||
tenant_id=current_tenant_id,
|
||||
datasource_provider_id=datasource_provider_id,
|
||||
@@ -503,7 +509,7 @@ class DatasourceAuthDefaultApi(Resource):
|
||||
@model_validate(DatasourceDefaultPayload)
|
||||
def post(self, req_data: DatasourceDefaultPayload, current_tenant_id: str, provider_id: str):
|
||||
datasource_provider_id = DatasourceProviderID(provider_id)
|
||||
datasource_provider_service = DatasourceProviderService()
|
||||
datasource_provider_service = application_services().data_sources.providers
|
||||
datasource_provider_service.set_default_datasource_provider(
|
||||
tenant_id=current_tenant_id,
|
||||
datasource_provider_id=datasource_provider_id,
|
||||
@@ -525,7 +531,7 @@ class DatasourceUpdateProviderNameApi(Resource):
|
||||
@model_validate(DatasourceUpdateNamePayload)
|
||||
def post(self, req_data: DatasourceUpdateNamePayload, current_tenant_id: str, provider_id: str):
|
||||
datasource_provider_id = DatasourceProviderID(provider_id)
|
||||
datasource_provider_service = DatasourceProviderService()
|
||||
datasource_provider_service = application_services().data_sources.providers
|
||||
datasource_provider_service.update_datasource_provider_name(
|
||||
tenant_id=current_tenant_id,
|
||||
datasource_provider_id=datasource_provider_id,
|
||||
|
||||
@@ -9,6 +9,7 @@ from controllers.common.schema import register_schema_models
|
||||
from controllers.console import console_ns
|
||||
from controllers.console.datasets.wraps import get_rag_pipeline
|
||||
from controllers.console.wraps import account_initialization_required, model_validate, setup_required, with_current_user
|
||||
from extensions.ext_application_services import application_services
|
||||
from extensions.ext_database import db
|
||||
from libs.login import login_required
|
||||
from models import Account
|
||||
@@ -51,5 +52,6 @@ class DataSourceContentPreviewApi(Resource):
|
||||
datasource_type=datasource_type,
|
||||
is_published=True,
|
||||
credential_id=req_data.credential_id,
|
||||
datasource_providers=application_services().data_sources.providers,
|
||||
)
|
||||
return preview_content, 200
|
||||
|
||||
@@ -35,10 +35,11 @@ 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 repositories.knowledge.dataset_read_repository import get_pipeline_dataset
|
||||
from services.entities.knowledge_entities.rag_pipeline_entities import IconInfo, PipelineTemplateInfoEntity
|
||||
from services.errors.account import NoPermissionError
|
||||
from services.errors.base import NoPermissionError
|
||||
from services.errors.rag_pipeline import RagPipelineResourceNotFoundError
|
||||
from services.knowledge.dataset_service import DatasetService
|
||||
from services.rag_pipeline.rag_pipeline import RagPipelineService
|
||||
|
||||
logger: logging.Logger = logging.getLogger(__name__)
|
||||
@@ -232,7 +233,7 @@ class PublishCustomizedPipelineTemplateApi(Resource):
|
||||
pipeline: Pipeline,
|
||||
) -> tuple[str, int]:
|
||||
session = db.session()
|
||||
dataset = pipeline.retrieve_dataset(session=session)
|
||||
dataset = get_pipeline_dataset(pipeline, session=session)
|
||||
if dataset is None:
|
||||
raise NotFound("Dataset not found")
|
||||
|
||||
|
||||
@@ -17,14 +17,17 @@ from controllers.console.wraps import (
|
||||
with_current_user,
|
||||
)
|
||||
from extensions.ext_database import db
|
||||
from fields.dataset_fields import DatasetDetailResponse, dataset_detail_response_source
|
||||
from fields.dataset_fields import (
|
||||
DatasetDetailResponse,
|
||||
)
|
||||
from libs.helper import dump_response
|
||||
from libs.login import login_required
|
||||
from models import Account
|
||||
from models.dataset import DatasetPermissionEnum
|
||||
from services.dataset_service import DatasetPermissionService, DatasetService
|
||||
from services.enterprise import rbac_service as enterprise_rbac_service
|
||||
from services.entities.knowledge_entities.rag_pipeline_entities import IconInfo, RagPipelineDatasetCreateEntity
|
||||
from services.knowledge.dataset_read_service import load_dataset_detail
|
||||
from services.knowledge.dataset_service import DatasetPermissionService, DatasetService
|
||||
from services.rag_pipeline.rag_pipeline_dsl_service import RagPipelineDslService
|
||||
|
||||
|
||||
@@ -136,4 +139,4 @@ class CreateEmptyRagPipelineDatasetApi(Resource):
|
||||
),
|
||||
session=session,
|
||||
)
|
||||
return dump_response(DatasetDetailResponse, dataset_detail_response_source(dataset, session=session)), 201
|
||||
return dump_response(DatasetDetailResponse, load_dataset_detail(dataset, session=session)), 201
|
||||
|
||||
@@ -9,7 +9,7 @@ from pydantic import BaseModel, Field, RootModel, ValidationError
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
from werkzeug.exceptions import BadRequest, Forbidden, InternalServerError, NotFound
|
||||
|
||||
import services
|
||||
import services.errors.base
|
||||
from configs import dify_config
|
||||
from controllers.common.controller_schemas import DefaultBlockConfigQuery, WorkflowListQuery, WorkflowUpdatePayload
|
||||
from controllers.common.fields import SimpleResultResponse
|
||||
@@ -46,6 +46,7 @@ from core.app.apps.base_app_queue_manager import AppQueueManager
|
||||
from core.app.apps.pipeline.pipeline_generator import PipelineGenerator
|
||||
from core.app.entities.app_invoke_entities import InvokeFrom
|
||||
from core.plugin.entities.plugin_daemon import PluginDatasourceProviderEntity
|
||||
from extensions.ext_application_services import application_services
|
||||
from extensions.ext_database import db
|
||||
from factories import variable_factory
|
||||
from fields.base import ResponseModel
|
||||
@@ -66,10 +67,10 @@ from models.dataset import Pipeline
|
||||
from models.model import EndUser
|
||||
from models.workflow import Workflow
|
||||
from services.agent.retirement_service import WorkflowAgentRetirementService
|
||||
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.knowledge.dataset_service import DatasetService
|
||||
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
|
||||
@@ -303,7 +304,13 @@ class RagPipelineDraftRunIterationNodeApi(Resource):
|
||||
|
||||
try:
|
||||
response = PipelineGenerateService.generate_single_iteration(
|
||||
pipeline=pipeline, user=current_user, node_id=node_id, args=args, session=db.session(), streaming=True
|
||||
generator=application_services().knowledge.pipeline_generator,
|
||||
pipeline=pipeline,
|
||||
user=current_user,
|
||||
node_id=node_id,
|
||||
args=args,
|
||||
session=db.session(),
|
||||
streaming=True,
|
||||
)
|
||||
|
||||
return helper.compact_generate_response(response)
|
||||
@@ -338,7 +345,13 @@ class RagPipelineDraftRunLoopNodeApi(Resource):
|
||||
|
||||
try:
|
||||
response = PipelineGenerateService.generate_single_loop(
|
||||
pipeline=pipeline, user=current_user, node_id=node_id, args=args, session=db.session(), streaming=True
|
||||
generator=application_services().knowledge.pipeline_generator,
|
||||
pipeline=pipeline,
|
||||
user=current_user,
|
||||
node_id=node_id,
|
||||
args=args,
|
||||
session=db.session(),
|
||||
streaming=True,
|
||||
)
|
||||
|
||||
return helper.compact_generate_response(response)
|
||||
@@ -374,6 +387,7 @@ class DraftRagPipelineRunApi(Resource):
|
||||
|
||||
try:
|
||||
response = PipelineGenerateService.generate(
|
||||
generator=application_services().knowledge.pipeline_generator,
|
||||
session=session,
|
||||
pipeline=pipeline,
|
||||
user=current_user,
|
||||
@@ -409,6 +423,7 @@ class PublishedRagPipelineRunApi(Resource):
|
||||
|
||||
try:
|
||||
response = PipelineGenerateService.generate(
|
||||
generator=application_services().knowledge.pipeline_generator,
|
||||
session=session,
|
||||
pipeline=pipeline,
|
||||
user=current_user,
|
||||
@@ -450,6 +465,7 @@ class RagPipelinePublishedDatasourceNodeRunApi(Resource):
|
||||
datasource_type=req_data.datasource_type,
|
||||
is_published=False,
|
||||
credential_id=req_data.credential_id,
|
||||
datasource_providers=application_services().data_sources.providers,
|
||||
)
|
||||
)
|
||||
)
|
||||
@@ -483,6 +499,7 @@ class RagPipelineDraftDatasourceNodeRunApi(Resource):
|
||||
datasource_type=req_data.datasource_type,
|
||||
is_published=False,
|
||||
credential_id=req_data.credential_id,
|
||||
datasource_providers=application_services().data_sources.providers,
|
||||
)
|
||||
)
|
||||
)
|
||||
@@ -1016,7 +1033,9 @@ class DatasourceListApi(Resource):
|
||||
@account_initialization_required
|
||||
@with_current_tenant_id
|
||||
def get(self, current_tenant_id: str):
|
||||
providers = RagPipelineManageService.list_rag_pipeline_datasources(current_tenant_id)
|
||||
providers = RagPipelineManageService.list_rag_pipeline_datasources(
|
||||
current_tenant_id, datasource_providers=application_services().data_sources.providers
|
||||
)
|
||||
return RagPipelineDatasourceListResponse.model_validate(providers, from_attributes=True).model_dump(mode="json")
|
||||
|
||||
|
||||
@@ -1069,7 +1088,7 @@ class RagPipelineTransformApi(Resource):
|
||||
raise Forbidden()
|
||||
try:
|
||||
DatasetService.check_dataset_permission(dataset, current_user, session)
|
||||
except services.errors.account.NoPermissionError as exc:
|
||||
except services.errors.base.NoPermissionError as exc:
|
||||
raise Forbidden(str(exc)) from exc
|
||||
|
||||
try:
|
||||
|
||||
@@ -7,8 +7,9 @@ from controllers.common.schema import query_params_from_model, register_response
|
||||
from controllers.console import console_ns
|
||||
from controllers.console.datasets.error import WebsiteCrawlError
|
||||
from controllers.console.wraps import account_initialization_required, model_validate, setup_required
|
||||
from extensions.ext_application_services import application_services
|
||||
from libs.login import login_required
|
||||
from services.website_service import WebsiteCrawlApiRequest, WebsiteCrawlStatusApiRequest, WebsiteService
|
||||
from services.data_source.website_service import WebsiteCrawlApiRequest, WebsiteCrawlStatusApiRequest, WebsiteService
|
||||
|
||||
|
||||
class WebsiteCrawlPayload(BaseModel):
|
||||
@@ -49,7 +50,7 @@ class WebsiteCrawlApi(Resource):
|
||||
|
||||
# Crawl URL using typed request
|
||||
try:
|
||||
result = WebsiteService.crawl_url(api_request)
|
||||
result = WebsiteService(providers=application_services().data_sources.providers).crawl_url(api_request)
|
||||
except Exception as e:
|
||||
raise WebsiteCrawlError(str(e))
|
||||
return result, 200
|
||||
@@ -77,7 +78,9 @@ class WebsiteCrawlStatusApi(Resource):
|
||||
|
||||
# Get crawl status using typed request
|
||||
try:
|
||||
result = WebsiteService.get_crawl_status_typed(api_request)
|
||||
result = WebsiteService(providers=application_services().data_sources.providers).get_crawl_status_typed(
|
||||
api_request
|
||||
)
|
||||
except Exception as e:
|
||||
raise WebsiteCrawlError(str(e))
|
||||
return result, 200
|
||||
|
||||
@@ -3,13 +3,14 @@
|
||||
import logging
|
||||
from collections.abc import Callable
|
||||
from functools import wraps
|
||||
from http import HTTPStatus
|
||||
|
||||
from flask import Response, request
|
||||
from flask_restx import Resource
|
||||
from werkzeug.exceptions import HTTPException, InternalServerError
|
||||
|
||||
from controllers.common.audio_response import audio_binary_response
|
||||
from controllers.common.controller_schemas import TextToAudioPayload
|
||||
from controllers.common.errors import InternalServerError
|
||||
from controllers.common.fields import AudioBinaryResponse, AudioTranscriptResponse
|
||||
from controllers.common.schema import register_response_schema_models, register_schema_model
|
||||
from controllers.console import console_ns
|
||||
@@ -26,14 +27,17 @@ from controllers.console.app.error import (
|
||||
SpeechToTextDisabledError,
|
||||
UnsupportedAudioTypeError,
|
||||
)
|
||||
from controllers.console.explore.error import AgentVersionNotFoundHTTPError
|
||||
from controllers.console.explore.installed_app_admission import get_installed_app
|
||||
from controllers.console.flask_admission import console_account_admission
|
||||
from controllers.console.wraps import model_validate
|
||||
from core.errors.error import ModelCurrentlyNotSupportError, ProviderTokenNotInitError, QuotaExceededError
|
||||
from extensions.ext_application_services import application_services
|
||||
from graphon.model_runtime.errors.invoke import InvokeError
|
||||
from libs.exception import BaseHTTPException
|
||||
from libs.helper import dump_response
|
||||
from machinery.context import RequestContext
|
||||
from services.agent.errors import AgentVersionNotFoundError
|
||||
from services.app_definition_query_service import AppDefinitionUnavailableError
|
||||
from services.audio_types import AudioAppRef, AudioUpload
|
||||
from services.errors.app_model_config import AppModelConfigBrokenError
|
||||
@@ -83,7 +87,9 @@ def _audio_errors[**P, R](view: Callable[P, R]) -> Callable[P, R]:
|
||||
raise ProviderModelCurrentlyNotSupportError() from error
|
||||
except InvokeError as error:
|
||||
raise CompletionRequestError(error.description) from error
|
||||
except (HTTPException, ValueError):
|
||||
except AgentVersionNotFoundError as error:
|
||||
raise AgentVersionNotFoundHTTPError() from error
|
||||
except (BaseHTTPException, ValueError):
|
||||
raise
|
||||
except Exception as error:
|
||||
logger.exception("Installed-app audio operation failed")
|
||||
@@ -97,13 +103,18 @@ def _audio_errors[**P, R](view: Callable[P, R]) -> Callable[P, R]:
|
||||
endpoint="installed_app_audio",
|
||||
)
|
||||
class ChatAudioApi(Resource):
|
||||
@console_ns.response(200, "Success", console_ns.models[AudioTranscriptResponse.__name__])
|
||||
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[AudioTranscriptResponse.__name__])
|
||||
@console_account_admission()
|
||||
@get_installed_app
|
||||
@_audio_errors
|
||||
def post(self, request_context: RequestContext, installed_app: InstalledAppRef) -> dict[str, object]:
|
||||
# Let Flask handle multipart parsing errors before mapping audio operation failures.
|
||||
file = request.files.get("file")
|
||||
audio = AudioUpload(stream=file.stream, mime_type=file.mimetype) if file is not None else None
|
||||
return self._transcribe(installed_app, audio)
|
||||
|
||||
@_audio_errors
|
||||
def _transcribe(self, installed_app: InstalledAppRef, audio: AudioUpload | None) -> dict[str, object]:
|
||||
"""Transcribe an upload; None lets the audio service report a missing file."""
|
||||
transcript = application_services().app_audio.transcript_asr(
|
||||
app=AudioAppRef(
|
||||
app_id=installed_app.app_id,
|
||||
@@ -121,7 +132,7 @@ class ChatAudioApi(Resource):
|
||||
)
|
||||
class ChatTextApi(Resource):
|
||||
@console_ns.expect(console_ns.models[TextToAudioPayload.__name__])
|
||||
@console_ns.response(200, "Success", console_ns.models[AudioBinaryResponse.__name__])
|
||||
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[AudioBinaryResponse.__name__])
|
||||
@console_account_admission()
|
||||
@get_installed_app
|
||||
@model_validate(TextToAudioPayload)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from datetime import datetime
|
||||
from http import HTTPStatus
|
||||
from typing import cast
|
||||
|
||||
from flask_restx import Namespace, Resource
|
||||
@@ -60,7 +61,7 @@ class BannerApi(Resource):
|
||||
"""Resource for banner list."""
|
||||
|
||||
@api.doc(params=query_params_from_model(BannerListQuery))
|
||||
@api.response(200, "Success", api.models[BannerListResponse.__name__])
|
||||
@api.response(HTTPStatus.OK, "Success", api.models[BannerListResponse.__name__])
|
||||
@model_validate(BannerListQuery)
|
||||
def get(self, req_data: BannerListQuery):
|
||||
"""Get banner list."""
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import logging
|
||||
from http import HTTPStatus
|
||||
from typing import Any, Literal
|
||||
from uuid import UUID
|
||||
|
||||
from flask import Response
|
||||
from flask_restx import Resource
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from werkzeug.exceptions import InternalServerError, NotFound, Unauthorized
|
||||
|
||||
import services
|
||||
from controllers.common.errors import InternalServerError, NotFoundError, UnauthorizedError
|
||||
from controllers.common.fields import SimpleResultResponse
|
||||
from controllers.common.schema import register_response_schema_models, register_schema_models
|
||||
from controllers.console.app.error import (
|
||||
@@ -87,7 +88,7 @@ register_response_schema_models(console_ns, SimpleResultResponse)
|
||||
)
|
||||
class CompletionApi(Resource):
|
||||
@console_ns.expect(console_ns.models[CompletionMessageExplorePayload.__name__])
|
||||
@console_ns.response(200, "Success")
|
||||
@console_ns.response(HTTPStatus.OK, "Success")
|
||||
@console_account_admission()
|
||||
@get_installed_app
|
||||
@model_validate(CompletionMessageExplorePayload)
|
||||
@@ -98,7 +99,7 @@ class CompletionApi(Resource):
|
||||
installed_app: InstalledAppRef,
|
||||
) -> Response:
|
||||
try:
|
||||
response = application_services().installed_app_generation.generate_completion(
|
||||
response = application_services().installed_apps.generation.generate_completion(
|
||||
installed_app=installed_app,
|
||||
account_id=request_context.account_id,
|
||||
args=req_data.model_dump(exclude_none=True),
|
||||
@@ -111,11 +112,11 @@ class CompletionApi(Resource):
|
||||
except InstalledAppNotCompletionError:
|
||||
raise NotCompletionAppError() from None
|
||||
except InstalledAppNotFoundError:
|
||||
raise NotFound("Installed app not found") from None
|
||||
raise NotFoundError("Installed app not found") from None
|
||||
except AccountNotFoundError:
|
||||
raise Unauthorized("Account no longer exists.") from None
|
||||
raise UnauthorizedError("Account no longer exists.") from None
|
||||
except services.errors.conversation.ConversationNotExistsError:
|
||||
raise NotFound("Conversation Not Exists.")
|
||||
raise NotFoundError("Conversation Not Exists.")
|
||||
except services.errors.conversation.ConversationCompletedError:
|
||||
raise ConversationCompletedError()
|
||||
except services.errors.app_model_config.AppModelConfigBrokenError:
|
||||
@@ -141,7 +142,7 @@ class CompletionApi(Resource):
|
||||
endpoint="installed_app_stop_completion",
|
||||
)
|
||||
class CompletionStopApi(Resource):
|
||||
@console_ns.response(200, "Success", console_ns.models[SimpleResultResponse.__name__])
|
||||
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[SimpleResultResponse.__name__])
|
||||
@console_account_admission()
|
||||
@get_installed_app
|
||||
def post(
|
||||
@@ -158,7 +159,7 @@ class CompletionStopApi(Resource):
|
||||
app_mode=AppMode.value_of(app_mode),
|
||||
)
|
||||
|
||||
return SimpleResultResponse(result="success").model_dump(mode="json"), 200
|
||||
return SimpleResultResponse(result="success").model_dump(mode="json"), HTTPStatus.OK
|
||||
|
||||
|
||||
@console_ns.route(
|
||||
@@ -167,7 +168,7 @@ class CompletionStopApi(Resource):
|
||||
)
|
||||
class ChatApi(Resource):
|
||||
@console_ns.expect(console_ns.models[ChatMessagePayload.__name__])
|
||||
@console_ns.response(200, "Success")
|
||||
@console_ns.response(HTTPStatus.OK, "Success")
|
||||
@console_account_admission()
|
||||
@get_installed_app
|
||||
@model_validate(ChatMessagePayload)
|
||||
@@ -178,7 +179,7 @@ class ChatApi(Resource):
|
||||
installed_app: InstalledAppRef,
|
||||
) -> Response:
|
||||
try:
|
||||
response = application_services().installed_app_generation.generate_chat(
|
||||
response = application_services().installed_apps.generation.generate_chat(
|
||||
installed_app=installed_app,
|
||||
account_id=request_context.account_id,
|
||||
args=req_data.model_dump(exclude_none=True),
|
||||
@@ -191,11 +192,11 @@ class ChatApi(Resource):
|
||||
except InstalledAppNotChatError:
|
||||
raise NotChatAppError() from None
|
||||
except InstalledAppNotFoundError:
|
||||
raise NotFound("Installed app not found") from None
|
||||
raise NotFoundError("Installed app not found") from None
|
||||
except AccountNotFoundError:
|
||||
raise Unauthorized("Account no longer exists.") from None
|
||||
raise UnauthorizedError("Account no longer exists.") from None
|
||||
except services.errors.conversation.ConversationNotExistsError:
|
||||
raise NotFound("Conversation Not Exists.")
|
||||
raise NotFoundError("Conversation Not Exists.")
|
||||
except services.errors.conversation.ConversationCompletedError:
|
||||
raise ConversationCompletedError()
|
||||
except services.errors.app_model_config.AppModelConfigBrokenError:
|
||||
@@ -223,7 +224,7 @@ class ChatApi(Resource):
|
||||
endpoint="installed_app_stop_chat_completion",
|
||||
)
|
||||
class ChatStopApi(Resource):
|
||||
@console_ns.response(200, "Success", console_ns.models[SimpleResultResponse.__name__])
|
||||
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[SimpleResultResponse.__name__])
|
||||
@console_account_admission()
|
||||
@get_installed_app
|
||||
def post(
|
||||
@@ -240,4 +241,4 @@ class ChatStopApi(Resource):
|
||||
app_mode=app_mode,
|
||||
)
|
||||
|
||||
return SimpleResultResponse(result="success").model_dump(mode="json"), 200
|
||||
return SimpleResultResponse(result="success").model_dump(mode="json"), HTTPStatus.OK
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from collections.abc import Callable
|
||||
from functools import wraps
|
||||
from http import HTTPStatus
|
||||
from uuid import UUID
|
||||
|
||||
from flask_restx import Resource
|
||||
@@ -84,7 +85,7 @@ def _conversation_errors[**P, R](view: Callable[P, R]) -> Callable[P, R]:
|
||||
@console_ns.route("/installed-apps/<uuid:installed_app_id>/conversations", endpoint="installed_app_conversations")
|
||||
class ConversationListApi(Resource):
|
||||
@console_ns.doc(params=query_params_from_model(ConversationListQuery))
|
||||
@console_ns.response(200, "Success", console_ns.models[ConversationInfiniteScrollPagination.__name__])
|
||||
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[ConversationInfiniteScrollPagination.__name__])
|
||||
@console_account_admission()
|
||||
@get_installed_app
|
||||
@model_validate(ConversationListQuery)
|
||||
@@ -92,7 +93,7 @@ class ConversationListApi(Resource):
|
||||
def get(
|
||||
self, query: ConversationListQuery, request_context: RequestContext, installed_app: InstalledAppRef
|
||||
) -> dict[str, object]:
|
||||
page = application_services().installed_app_conversations.get_page(
|
||||
page = application_services().installed_apps.conversations.get_page(
|
||||
installed_app=installed_app,
|
||||
account_id=request_context.account_id,
|
||||
last_id=query.last_id or None,
|
||||
@@ -106,15 +107,15 @@ class ConversationListApi(Resource):
|
||||
"/installed-apps/<uuid:installed_app_id>/conversations/<uuid:c_id>", endpoint="installed_app_conversation"
|
||||
)
|
||||
class ConversationApi(Resource):
|
||||
@console_ns.response(204, "Conversation deleted successfully")
|
||||
@console_ns.response(HTTPStatus.NO_CONTENT, "Conversation deleted successfully")
|
||||
@console_account_admission()
|
||||
@get_installed_app
|
||||
@_conversation_errors
|
||||
def delete(self, request_context: RequestContext, installed_app: InstalledAppRef, c_id: UUID) -> tuple[str, int]:
|
||||
application_services().installed_app_conversations.delete(
|
||||
application_services().installed_apps.conversations.delete(
|
||||
installed_app=installed_app, account_id=request_context.account_id, conversation_id=str(c_id)
|
||||
)
|
||||
return "", 204
|
||||
return "", HTTPStatus.NO_CONTENT
|
||||
|
||||
|
||||
@console_ns.route(
|
||||
@@ -123,7 +124,9 @@ class ConversationApi(Resource):
|
||||
)
|
||||
class ConversationRenameApi(Resource):
|
||||
@console_ns.expect(console_ns.models[ConversationRenamePayload.__name__])
|
||||
@console_ns.response(200, "Conversation renamed successfully", console_ns.models[SimpleConversation.__name__])
|
||||
@console_ns.response(
|
||||
HTTPStatus.OK, "Conversation renamed successfully", console_ns.models[SimpleConversation.__name__]
|
||||
)
|
||||
@console_account_admission()
|
||||
@get_installed_app
|
||||
@model_validate(ConversationRenamePayload)
|
||||
@@ -135,7 +138,7 @@ class ConversationRenameApi(Resource):
|
||||
installed_app: InstalledAppRef,
|
||||
c_id: UUID,
|
||||
) -> dict[str, object]:
|
||||
conversation = application_services().installed_app_conversations.rename(
|
||||
conversation = application_services().installed_apps.conversations.rename(
|
||||
installed_app=installed_app,
|
||||
account_id=request_context.account_id,
|
||||
conversation_id=str(c_id),
|
||||
@@ -149,12 +152,12 @@ class ConversationRenameApi(Resource):
|
||||
"/installed-apps/<uuid:installed_app_id>/conversations/<uuid:c_id>/pin", endpoint="installed_app_conversation_pin"
|
||||
)
|
||||
class ConversationPinApi(Resource):
|
||||
@console_ns.response(200, "Success", console_ns.models[ResultResponse.__name__])
|
||||
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[ResultResponse.__name__])
|
||||
@console_account_admission()
|
||||
@get_installed_app
|
||||
@_conversation_errors
|
||||
def patch(self, request_context: RequestContext, installed_app: InstalledAppRef, c_id: UUID) -> dict[str, object]:
|
||||
application_services().installed_app_conversations.set_pinned(
|
||||
application_services().installed_apps.conversations.set_pinned(
|
||||
installed_app=installed_app,
|
||||
account_id=request_context.account_id,
|
||||
conversation_id=str(c_id),
|
||||
@@ -168,12 +171,12 @@ class ConversationPinApi(Resource):
|
||||
endpoint="installed_app_conversation_unpin",
|
||||
)
|
||||
class ConversationUnPinApi(Resource):
|
||||
@console_ns.response(200, "Success", console_ns.models[ResultResponse.__name__])
|
||||
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[ResultResponse.__name__])
|
||||
@console_account_admission()
|
||||
@get_installed_app
|
||||
@_conversation_errors
|
||||
def patch(self, request_context: RequestContext, installed_app: InstalledAppRef, c_id: UUID) -> dict[str, object]:
|
||||
application_services().installed_app_conversations.set_pinned(
|
||||
application_services().installed_apps.conversations.set_pinned(
|
||||
installed_app=installed_app,
|
||||
account_id=request_context.account_id,
|
||||
conversation_id=str(c_id),
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
from http import HTTPStatus
|
||||
|
||||
from core.logging.context import get_request_id
|
||||
from libs.exception import BaseHTTPException
|
||||
|
||||
|
||||
class AgentVersionNotFoundHTTPError(BaseHTTPException):
|
||||
error_code = "agent_version_not_found_error"
|
||||
description = "Agent config version not found."
|
||||
code = HTTPStatus.NOT_FOUND
|
||||
|
||||
|
||||
class InstalledAppHTTPError(BaseHTTPException):
|
||||
"""A Console error with safe details and a request ID for troubleshooting."""
|
||||
|
||||
@@ -14,115 +22,115 @@ class InstalledAppHTTPError(BaseHTTPException):
|
||||
class InstalledAppNotFoundHTTPError(InstalledAppHTTPError):
|
||||
error_code = "installed_app_not_found"
|
||||
description = "The app was not found in this workspace."
|
||||
code = 404
|
||||
code = HTTPStatus.NOT_FOUND
|
||||
|
||||
|
||||
class InstalledAppUnavailableHTTPError(InstalledAppHTTPError):
|
||||
error_code = "installed_app_unavailable"
|
||||
description = "The app is not available in this app library."
|
||||
code = 404
|
||||
code = HTTPStatus.NOT_FOUND
|
||||
|
||||
|
||||
class InstalledAppInvalidCursorError(InstalledAppHTTPError):
|
||||
error_code = "invalid_cursor"
|
||||
description = "The app list cursor is invalid. Refresh the list and try again."
|
||||
code = 400
|
||||
code = HTTPStatus.BAD_REQUEST
|
||||
|
||||
|
||||
class WebAppAccessUnavailableHTTPError(InstalledAppHTTPError):
|
||||
error_code = "web_app_access_unavailable"
|
||||
description = "The app access service is unavailable. Try again later."
|
||||
code = 503
|
||||
code = HTTPStatus.SERVICE_UNAVAILABLE
|
||||
|
||||
|
||||
class ConversationNotFoundHTTPError(InstalledAppHTTPError):
|
||||
error_code = "conversation_not_found"
|
||||
description = "The conversation was not found for this account and app."
|
||||
code = 404
|
||||
code = HTTPStatus.NOT_FOUND
|
||||
|
||||
|
||||
class ConversationCursorNotFoundHTTPError(InstalledAppHTTPError):
|
||||
error_code = "conversation_cursor_not_found"
|
||||
description = "The conversation cursor is not in the current list. Refresh the list and try again."
|
||||
code = 404
|
||||
code = HTTPStatus.NOT_FOUND
|
||||
|
||||
|
||||
class ConversationFirstMessageNotFoundHTTPError(InstalledAppHTTPError):
|
||||
error_code = "conversation_first_message_not_found"
|
||||
description = "The conversation has no message from which to generate a name."
|
||||
code = 404
|
||||
code = HTTPStatus.NOT_FOUND
|
||||
|
||||
|
||||
class ConversationNameRequiredHTTPError(InstalledAppHTTPError):
|
||||
error_code = "conversation_name_required"
|
||||
description = "A name is required when automatic naming is disabled."
|
||||
code = 400
|
||||
code = HTTPStatus.BAD_REQUEST
|
||||
|
||||
|
||||
class MessageNotFoundHTTPError(InstalledAppHTTPError):
|
||||
error_code = "message_not_found"
|
||||
description = "The message was not found for this account and app."
|
||||
code = 404
|
||||
code = HTTPStatus.NOT_FOUND
|
||||
|
||||
|
||||
class MessageCursorNotFoundHTTPError(InstalledAppHTTPError):
|
||||
error_code = "message_cursor_not_found"
|
||||
description = "The message cursor is not in this conversation. Refresh the list and try again."
|
||||
code = 404
|
||||
code = HTTPStatus.NOT_FOUND
|
||||
|
||||
|
||||
class MessageFeedbackRatingRequiredHTTPError(InstalledAppHTTPError):
|
||||
error_code = "message_feedback_rating_required"
|
||||
description = "A rating is required when there is no existing feedback to remove."
|
||||
code = 400
|
||||
code = HTTPStatus.BAD_REQUEST
|
||||
|
||||
|
||||
class NotCompletionAppError(BaseHTTPException):
|
||||
error_code = "not_completion_app"
|
||||
description = "Not Completion App"
|
||||
code = 400
|
||||
code = HTTPStatus.BAD_REQUEST
|
||||
|
||||
|
||||
class NotChatAppError(BaseHTTPException):
|
||||
error_code = "not_chat_app"
|
||||
description = "App mode is invalid."
|
||||
code = 400
|
||||
code = HTTPStatus.BAD_REQUEST
|
||||
|
||||
|
||||
class NotWorkflowAppError(BaseHTTPException):
|
||||
error_code = "not_workflow_app"
|
||||
description = "Only support workflow app."
|
||||
code = 400
|
||||
code = HTTPStatus.BAD_REQUEST
|
||||
|
||||
|
||||
class AppSuggestedQuestionsAfterAnswerDisabledError(BaseHTTPException):
|
||||
error_code = "app_suggested_questions_after_answer_disabled"
|
||||
description = "Function Suggested questions after answer disabled."
|
||||
code = 403
|
||||
code = HTTPStatus.FORBIDDEN
|
||||
|
||||
|
||||
class AppAccessDeniedError(BaseHTTPException):
|
||||
error_code = "access_denied"
|
||||
description = "App access denied."
|
||||
code = 403
|
||||
code = HTTPStatus.FORBIDDEN
|
||||
|
||||
|
||||
class RecommendedAppNotFoundError(BaseHTTPException):
|
||||
error_code = "recommended_app_not_found"
|
||||
description = "Recommended app not found."
|
||||
code = 404
|
||||
code = HTTPStatus.NOT_FOUND
|
||||
|
||||
|
||||
class AppPreviewSiteUnavailableError(BaseHTTPException):
|
||||
error_code = "app_site_unavailable"
|
||||
description = "The app preview site is unavailable."
|
||||
code = 403
|
||||
code = HTTPStatus.FORBIDDEN
|
||||
|
||||
|
||||
class AppPreviewOwnerUnavailableError(BaseHTTPException):
|
||||
error_code = "app_owner_unavailable"
|
||||
description = "The app preview owner is unavailable."
|
||||
code = 403
|
||||
code = HTTPStatus.FORBIDDEN
|
||||
|
||||
|
||||
class TrialAppNotAllowed(BaseHTTPException):
|
||||
@@ -132,7 +140,7 @@ class TrialAppNotAllowed(BaseHTTPException):
|
||||
"""
|
||||
|
||||
error_code = "trial_app_not_allowed"
|
||||
code = 403
|
||||
code = HTTPStatus.FORBIDDEN
|
||||
description = "the app is not allowed to be trial."
|
||||
|
||||
|
||||
@@ -143,11 +151,11 @@ class TrialAppLimitExceeded(BaseHTTPException):
|
||||
"""
|
||||
|
||||
error_code = "trial_app_limit_exceeded"
|
||||
code = 403
|
||||
code = HTTPStatus.FORBIDDEN
|
||||
description = "The user has exceeded the trial app limit."
|
||||
|
||||
|
||||
class TrialAppFeatureDisabledError(BaseHTTPException):
|
||||
error_code = "trial_app_feature_disabled"
|
||||
code = 403
|
||||
code = HTTPStatus.FORBIDDEN
|
||||
description = "Trial app feature is not enabled."
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import base64
|
||||
import binascii
|
||||
import logging
|
||||
from http import HTTPStatus
|
||||
|
||||
from flask_restx import Resource
|
||||
from pydantic import BaseModel, Field, computed_field
|
||||
@@ -139,13 +140,13 @@ register_response_schema_models(
|
||||
@console_ns.route("/installed-apps")
|
||||
class InstalledAppsListApi(Resource):
|
||||
@console_ns.doc(params=query_params_from_model(InstalledAppsListQuery))
|
||||
@console_ns.response(200, "Success", console_ns.models[InstalledAppListResponse.__name__])
|
||||
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[InstalledAppListResponse.__name__])
|
||||
@console_account_admission()
|
||||
@model_validate(InstalledAppsListQuery)
|
||||
def get(self, query: InstalledAppsListQuery, request_context: RequestContext) -> dict[str, object]:
|
||||
cursor = _decode_installed_app_cursor(query.cursor)
|
||||
try:
|
||||
page = application_services().installed_apps.get_visible_page(
|
||||
page = application_services().installed_apps.management.get_visible_page(
|
||||
tenant_id=request_context.active_workspace_id,
|
||||
user_id=request_context.account_id,
|
||||
cursor=cursor,
|
||||
@@ -184,7 +185,7 @@ class InstalledAppsListApi(Resource):
|
||||
class InstalledAppApi(Resource):
|
||||
"""Read or update an admitted workspace installation."""
|
||||
|
||||
@console_ns.response(200, "Success", console_ns.models[InstalledAppResponse.__name__])
|
||||
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[InstalledAppResponse.__name__])
|
||||
@console_account_admission()
|
||||
@get_installed_app
|
||||
def get(
|
||||
@@ -193,7 +194,7 @@ class InstalledAppApi(Resource):
|
||||
installed_app: InstalledAppRef,
|
||||
) -> dict[str, object]:
|
||||
try:
|
||||
detail = application_services().installed_apps.get_detail(
|
||||
detail = application_services().installed_apps.management.get_detail(
|
||||
installed_app=installed_app, account_id=request_context.account_id
|
||||
)
|
||||
except InstalledAppUnavailableError as error:
|
||||
@@ -208,7 +209,7 @@ class InstalledAppApi(Resource):
|
||||
),
|
||||
)
|
||||
|
||||
@console_ns.response(200, "Success", console_ns.models[SimpleResultMessageResponse.__name__])
|
||||
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[SimpleResultMessageResponse.__name__])
|
||||
@console_ns.expect(console_ns.models[InstalledAppUpdatePayload.__name__])
|
||||
@console_account_admission()
|
||||
@get_installed_app
|
||||
@@ -217,7 +218,9 @@ class InstalledAppApi(Resource):
|
||||
self, req_data: InstalledAppUpdatePayload, request_context: RequestContext, installed_app: InstalledAppRef
|
||||
) -> dict[str, str]:
|
||||
try:
|
||||
application_services().installed_apps.set_pinned(installed_app=installed_app, is_pinned=req_data.is_pinned)
|
||||
application_services().installed_apps.management.set_pinned(
|
||||
installed_app=installed_app, is_pinned=req_data.is_pinned
|
||||
)
|
||||
except InstalledAppNotFoundError as error:
|
||||
raise InstalledAppNotFoundHTTPError() from error
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ def get_installed_app[T, **P, R](
|
||||
if installed_app_id is None:
|
||||
raise RuntimeError("The installed-app admission route must provide installed_app_id")
|
||||
try:
|
||||
installed_app = application_services().installed_app_access.get_access(
|
||||
installed_app = application_services().installed_apps.access.get_access(
|
||||
installed_app_id=str(installed_app_id),
|
||||
tenant_id=request_context.active_workspace_id,
|
||||
account_id=request_context.account_id,
|
||||
|
||||
@@ -3,15 +3,16 @@
|
||||
import logging
|
||||
from collections.abc import Callable
|
||||
from functools import wraps
|
||||
from http import HTTPStatus
|
||||
from typing import Literal
|
||||
from uuid import UUID
|
||||
|
||||
from flask import Response
|
||||
from flask_restx import Resource
|
||||
from pydantic import BaseModel
|
||||
from werkzeug.exceptions import HTTPException, InternalServerError, Unauthorized
|
||||
|
||||
from controllers.common.controller_schemas import MessageFeedbackPayload, MessageListQuery
|
||||
from controllers.common.errors import InternalServerError, UnauthorizedError
|
||||
from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models
|
||||
from controllers.console import console_ns
|
||||
from controllers.console.app.error import (
|
||||
@@ -41,6 +42,7 @@ from fields.conversation_fields import ResultResponse
|
||||
from fields.message_fields import ExploreMessageInfiniteScrollPagination, SuggestedQuestionsResponse
|
||||
from graphon.model_runtime.errors.invoke import InvokeError
|
||||
from libs import helper
|
||||
from libs.exception import BaseHTTPException
|
||||
from machinery.context import RequestContext
|
||||
from services.account_errors import AccountNotFoundError
|
||||
from services.app_definition_query_service import AppDefinitionUnavailableError
|
||||
@@ -55,6 +57,7 @@ from services.errors.message import (
|
||||
from services.installed_app_access_service import InstalledAppNotFoundError, InstalledAppRef
|
||||
from services.installed_app_generation_service import InstalledAppNotCompletionError
|
||||
from services.installed_app_message_service import FeedbackRatingRequiredError, MessageNotChatAppError
|
||||
from services.message_suggested_questions_service import SuggestedQuestionsAccount, SuggestedQuestionsActorNotFoundError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -81,8 +84,8 @@ def _message_errors[**P, R](view: Callable[P, R]) -> Callable[P, R]:
|
||||
raise InstalledAppNotFoundHTTPError() from error
|
||||
except AppDefinitionUnavailableError as error:
|
||||
raise AppUnavailableError() from error
|
||||
except AccountNotFoundError as error:
|
||||
raise Unauthorized("Account no longer exists.") from error
|
||||
except (AccountNotFoundError, SuggestedQuestionsActorNotFoundError) as error:
|
||||
raise UnauthorizedError("Account no longer exists.") from error
|
||||
except MessageNotChatAppError as error:
|
||||
raise NotChatAppError() from error
|
||||
except InstalledAppNotCompletionError as error:
|
||||
@@ -107,7 +110,7 @@ def _message_errors[**P, R](view: Callable[P, R]) -> Callable[P, R]:
|
||||
raise ProviderModelCurrentlyNotSupportError() from error
|
||||
except InvokeError as error:
|
||||
raise CompletionRequestError(error.description) from error
|
||||
except (HTTPException, ValueError):
|
||||
except (BaseHTTPException, ValueError):
|
||||
raise
|
||||
except Exception as error:
|
||||
logger.exception("Installed-app message operation failed")
|
||||
@@ -122,7 +125,7 @@ def _message_errors[**P, R](view: Callable[P, R]) -> Callable[P, R]:
|
||||
)
|
||||
class MessageListApi(Resource):
|
||||
@console_ns.doc(params=query_params_from_model(MessageListQuery))
|
||||
@console_ns.response(200, "Success", console_ns.models[ExploreMessageInfiniteScrollPagination.__name__])
|
||||
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[ExploreMessageInfiniteScrollPagination.__name__])
|
||||
@console_account_admission()
|
||||
@get_installed_app
|
||||
@model_validate(MessageListQuery)
|
||||
@@ -130,7 +133,7 @@ class MessageListApi(Resource):
|
||||
def get(
|
||||
self, query: MessageListQuery, request_context: RequestContext, installed_app: InstalledAppRef
|
||||
) -> dict[str, object]:
|
||||
page = application_services().installed_app_messages.get_page(
|
||||
page = application_services().installed_apps.messages.get_page(
|
||||
installed_app=installed_app,
|
||||
account_id=request_context.account_id,
|
||||
conversation_id=query.conversation_id,
|
||||
@@ -146,7 +149,7 @@ class MessageListApi(Resource):
|
||||
)
|
||||
class MessageFeedbackApi(Resource):
|
||||
@console_ns.expect(console_ns.models[MessageFeedbackPayload.__name__])
|
||||
@console_ns.response(200, "Feedback submitted successfully", console_ns.models[ResultResponse.__name__])
|
||||
@console_ns.response(HTTPStatus.OK, "Feedback submitted successfully", console_ns.models[ResultResponse.__name__])
|
||||
@console_account_admission()
|
||||
@get_installed_app
|
||||
@model_validate(MessageFeedbackPayload)
|
||||
@@ -158,7 +161,7 @@ class MessageFeedbackApi(Resource):
|
||||
installed_app: InstalledAppRef,
|
||||
message_id: UUID,
|
||||
) -> dict[str, object]:
|
||||
application_services().installed_app_messages.set_feedback(
|
||||
application_services().installed_apps.messages.set_feedback(
|
||||
installed_app=installed_app,
|
||||
account_id=request_context.account_id,
|
||||
message_id=str(message_id),
|
||||
@@ -174,7 +177,7 @@ class MessageFeedbackApi(Resource):
|
||||
)
|
||||
class MessageMoreLikeThisApi(Resource):
|
||||
@console_ns.doc(params=query_params_from_model(MoreLikeThisQuery))
|
||||
@console_ns.response(200, "Success")
|
||||
@console_ns.response(HTTPStatus.OK, "Success")
|
||||
@console_account_admission()
|
||||
@get_installed_app
|
||||
@model_validate(MoreLikeThisQuery)
|
||||
@@ -186,7 +189,7 @@ class MessageMoreLikeThisApi(Resource):
|
||||
installed_app: InstalledAppRef,
|
||||
message_id: UUID,
|
||||
) -> Response:
|
||||
response = application_services().installed_app_generation.generate_more_like_this(
|
||||
response = application_services().installed_apps.generation.generate_more_like_this(
|
||||
installed_app=installed_app,
|
||||
account_id=request_context.account_id,
|
||||
message_id=str(message_id),
|
||||
@@ -201,17 +204,22 @@ class MessageMoreLikeThisApi(Resource):
|
||||
endpoint="installed_app_suggested_question",
|
||||
)
|
||||
class MessageSuggestedQuestionApi(Resource):
|
||||
@console_ns.response(200, "Success", console_ns.models[SuggestedQuestionsResponse.__name__])
|
||||
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[SuggestedQuestionsResponse.__name__])
|
||||
@console_account_admission()
|
||||
@get_installed_app
|
||||
@_message_errors
|
||||
def get(
|
||||
self, request_context: RequestContext, installed_app: InstalledAppRef, message_id: UUID
|
||||
) -> dict[str, object]:
|
||||
if installed_app.app_mode not in {"chat", "agent-chat", "advanced-chat"}:
|
||||
raise NotChatAppError()
|
||||
|
||||
try:
|
||||
questions = application_services().installed_app_messages.get_suggested_questions(
|
||||
installed_app=installed_app,
|
||||
account_id=request_context.account_id,
|
||||
questions = application_services().message_suggested_questions.get_suggested_questions(
|
||||
app_id=installed_app.app_id,
|
||||
app_owner_tenant_id=installed_app.app_owner_tenant_id,
|
||||
expected_app_mode=installed_app.app_mode,
|
||||
actor=SuggestedQuestionsAccount(account_id=request_context.account_id, invoke_from="explore"),
|
||||
message_id=str(message_id),
|
||||
)
|
||||
except (
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
from http import HTTPStatus
|
||||
from typing import Any
|
||||
|
||||
from flask_restx import Resource
|
||||
@@ -32,7 +33,7 @@ register_response_schema_models(console_ns, Parameters, ExploreAppMetaResponse)
|
||||
class AppParameterApi(Resource):
|
||||
"""Resource for app variables."""
|
||||
|
||||
@console_ns.response(200, "Success", console_ns.models[Parameters.__name__])
|
||||
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[Parameters.__name__])
|
||||
@console_account_admission()
|
||||
@get_installed_app
|
||||
def get(self, request_context: RequestContext, installed_app: InstalledAppRef) -> dict[str, object]:
|
||||
@@ -47,7 +48,7 @@ class AppParameterApi(Resource):
|
||||
|
||||
@console_ns.route("/installed-apps/<uuid:installed_app_id>/meta", endpoint="installed_app_meta")
|
||||
class ExploreAppMetaApi(Resource):
|
||||
@console_ns.response(200, "Success", console_ns.models[ExploreAppMetaResponse.__name__])
|
||||
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[ExploreAppMetaResponse.__name__])
|
||||
@console_account_admission()
|
||||
@get_installed_app
|
||||
def get(self, request_context: RequestContext, installed_app: InstalledAppRef) -> dict[str, object]:
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
from typing import Any
|
||||
from http import HTTPStatus
|
||||
from typing import Any, Literal
|
||||
from uuid import UUID
|
||||
|
||||
from flask import make_response, send_file
|
||||
from flask_restx import Resource
|
||||
from pydantic import BaseModel, Field, computed_field, field_validator
|
||||
from pydantic import BaseModel, ConfigDict, Field, computed_field, field_validator
|
||||
|
||||
from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models
|
||||
from controllers.console import console_ns
|
||||
@@ -10,7 +12,9 @@ from controllers.console.explore.error import RecommendedAppNotFoundError
|
||||
from controllers.console.flask_admission import console_account_admission
|
||||
from controllers.console.wraps import model_validate
|
||||
from extensions.ext_application_services import application_services
|
||||
from fields.app_export_fields import AppExportResponse
|
||||
from fields.base import ResponseModel
|
||||
from libs.flask_restx_compat import BINARY_RESPONSE_MEDIA_TYPES_VENDOR_KEY
|
||||
from libs.helper import build_icon_url, dump_response
|
||||
from machinery.context import RequestContext
|
||||
from services.recommended_app_query_service import RecommendedAppNotFoundError as RecommendedAppQueryNotFoundError
|
||||
@@ -20,6 +24,13 @@ class RecommendedAppsQuery(BaseModel):
|
||||
language: str = Field(default="en-US", description="Language code for recommended app localization")
|
||||
|
||||
|
||||
class RecommendedAgentExportQuery(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
version_id: UUID = Field(description="Current published template snapshot ID")
|
||||
format: Literal["yaml", "ifpkg"] = Field(default="ifpkg", description="Export format; defaults to ifpkg")
|
||||
|
||||
|
||||
class RecommendedAppInfoResponse(ResponseModel):
|
||||
id: str
|
||||
name: str | None = None
|
||||
@@ -77,11 +88,14 @@ class RecommendedAppDetailResponse(ResponseModel):
|
||||
mode: str
|
||||
export_data: str
|
||||
can_trial: bool
|
||||
package_url: str | None = Field(default=None, description="Download URL for a New Agent .ifpkg template")
|
||||
version_id: str | None = Field(default=None, description="Published version for direct local template creation")
|
||||
|
||||
|
||||
register_schema_models(
|
||||
console_ns,
|
||||
RecommendedAppsQuery,
|
||||
RecommendedAgentExportQuery,
|
||||
)
|
||||
register_response_schema_models(
|
||||
console_ns,
|
||||
@@ -90,13 +104,14 @@ register_response_schema_models(
|
||||
RecommendedAppListResponse,
|
||||
LearnDifyAppListResponse,
|
||||
RecommendedAppDetailResponse,
|
||||
AppExportResponse,
|
||||
)
|
||||
|
||||
|
||||
@console_ns.route("/explore/apps")
|
||||
class RecommendedAppListApi(Resource):
|
||||
@console_ns.doc(params=query_params_from_model(RecommendedAppsQuery))
|
||||
@console_ns.response(200, "Success", console_ns.models[RecommendedAppListResponse.__name__])
|
||||
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[RecommendedAppListResponse.__name__])
|
||||
@console_account_admission()
|
||||
@model_validate(RecommendedAppsQuery)
|
||||
def get(self, req_data: RecommendedAppsQuery, _request_context: RequestContext):
|
||||
@@ -111,7 +126,7 @@ class RecommendedAppListApi(Resource):
|
||||
@console_ns.route("/explore/apps/learn-dify")
|
||||
class LearnDifyAppListApi(Resource):
|
||||
@console_ns.doc(params=query_params_from_model(RecommendedAppsQuery))
|
||||
@console_ns.response(200, "Success", console_ns.models[LearnDifyAppListResponse.__name__])
|
||||
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[LearnDifyAppListResponse.__name__])
|
||||
@console_account_admission()
|
||||
@model_validate(RecommendedAppsQuery)
|
||||
def get(self, req_data: RecommendedAppsQuery, _request_context: RequestContext):
|
||||
@@ -125,8 +140,8 @@ class LearnDifyAppListApi(Resource):
|
||||
|
||||
@console_ns.route("/explore/apps/<uuid:app_id>")
|
||||
class RecommendedAppApi(Resource):
|
||||
@console_ns.response(200, "Success", console_ns.models[RecommendedAppDetailResponse.__name__])
|
||||
@console_ns.response(404, "Recommended app not found")
|
||||
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[RecommendedAppDetailResponse.__name__])
|
||||
@console_ns.response(HTTPStatus.NOT_FOUND, "Recommended app not found")
|
||||
@console_account_admission()
|
||||
def get(self, _request_context: RequestContext, app_id: UUID):
|
||||
try:
|
||||
@@ -134,3 +149,37 @@ class RecommendedAppApi(Resource):
|
||||
except RecommendedAppQueryNotFoundError:
|
||||
raise RecommendedAppNotFoundError() from None
|
||||
return dump_response(RecommendedAppDetailResponse, result)
|
||||
|
||||
|
||||
@console_ns.route("/trial-apps/<uuid:app_id>/export")
|
||||
class RecommendedAgentExportApi(Resource):
|
||||
@console_ns.doc(params=query_params_from_model(RecommendedAgentExportQuery))
|
||||
@console_ns.doc(
|
||||
produces=["application/json", "application/zip"],
|
||||
vendor={BINARY_RESPONSE_MEDIA_TYPES_VENDOR_KEY: ["application/zip"]},
|
||||
)
|
||||
@console_ns.response(200, "Published Agent template export", console_ns.models[AppExportResponse.__name__])
|
||||
@console_ns.response(404, "Published template unavailable")
|
||||
@model_validate(RecommendedAgentExportQuery)
|
||||
def get(self, query: RecommendedAgentExportQuery, app_id: UUID):
|
||||
# Both formats require public catalog membership and the current published version.
|
||||
try:
|
||||
exported = application_services().recommended_app_packages.export(
|
||||
app_id=str(app_id), version_id=query.version_id, format=query.format
|
||||
)
|
||||
except RecommendedAppQueryNotFoundError:
|
||||
raise RecommendedAppNotFoundError() from None
|
||||
if isinstance(exported, str):
|
||||
response = make_response(dump_response(AppExportResponse, {"data": exported}))
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
return response
|
||||
try:
|
||||
response = send_file(
|
||||
exported.archive, mimetype="application/zip", as_attachment=True, download_name=exported.filename
|
||||
)
|
||||
except Exception:
|
||||
exported.close()
|
||||
raise
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
response.call_on_close(exported.close)
|
||||
return response
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
from http import HTTPStatus
|
||||
from uuid import UUID
|
||||
|
||||
from flask_restx import Resource
|
||||
from werkzeug.exceptions import NotFound
|
||||
|
||||
from controllers.common.controller_schemas import SavedMessageCreatePayload, SavedMessageListQuery
|
||||
from controllers.common.errors import NotFoundError
|
||||
from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models
|
||||
from controllers.console import console_ns
|
||||
from controllers.console.explore.error import NotCompletionAppError
|
||||
@@ -32,7 +33,7 @@ def _require_completion_app(installed_app: InstalledAppRef) -> str:
|
||||
@console_ns.route("/installed-apps/<uuid:installed_app_id>/saved-messages", endpoint="installed_app_saved_messages")
|
||||
class SavedMessageListApi(Resource):
|
||||
@console_ns.doc(params=query_params_from_model(SavedMessageListQuery))
|
||||
@console_ns.response(200, "Success", console_ns.models[SavedMessageInfiniteScrollPagination.__name__])
|
||||
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[SavedMessageInfiniteScrollPagination.__name__])
|
||||
@console_account_admission()
|
||||
@get_installed_app
|
||||
@model_validate(SavedMessageListQuery)
|
||||
@@ -52,7 +53,7 @@ class SavedMessageListApi(Resource):
|
||||
return dump_response(SavedMessageInfiniteScrollPagination, pagination)
|
||||
|
||||
@console_ns.expect(console_ns.models[SavedMessageCreatePayload.__name__])
|
||||
@console_ns.response(200, "Success", console_ns.models[ResultResponse.__name__])
|
||||
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[ResultResponse.__name__])
|
||||
@console_account_admission()
|
||||
@get_installed_app
|
||||
@model_validate(SavedMessageCreatePayload)
|
||||
@@ -71,7 +72,7 @@ class SavedMessageListApi(Resource):
|
||||
message_id=str(req_data.message_id),
|
||||
)
|
||||
except MessageNotExistsError:
|
||||
raise NotFound("Message Not Exists.")
|
||||
raise NotFoundError("Message Not Exists.")
|
||||
|
||||
return ResultResponse(result="success").model_dump(mode="json")
|
||||
|
||||
@@ -80,7 +81,7 @@ class SavedMessageListApi(Resource):
|
||||
"/installed-apps/<uuid:installed_app_id>/saved-messages/<uuid:message_id>", endpoint="installed_app_saved_message"
|
||||
)
|
||||
class SavedMessageApi(Resource):
|
||||
@console_ns.response(204, "Saved message deleted successfully")
|
||||
@console_ns.response(HTTPStatus.NO_CONTENT, "Saved message deleted successfully")
|
||||
@console_account_admission()
|
||||
@get_installed_app
|
||||
def delete(
|
||||
@@ -96,4 +97,4 @@ class SavedMessageApi(Resource):
|
||||
message_id=str(message_id),
|
||||
)
|
||||
|
||||
return "", 204
|
||||
return "", HTTPStatus.NO_CONTENT
|
||||
|
||||
@@ -3,17 +3,18 @@ from collections.abc import Callable, Mapping
|
||||
from dataclasses import asdict
|
||||
from datetime import datetime
|
||||
from functools import wraps
|
||||
from http import HTTPStatus
|
||||
from typing import Any, Literal
|
||||
from uuid import UUID
|
||||
|
||||
from flask import Response, request
|
||||
from flask_restx import Resource
|
||||
from pydantic import AliasChoices, BaseModel, Field, field_validator
|
||||
from werkzeug.exceptions import HTTPException, InternalServerError, NotFound, Unauthorized
|
||||
|
||||
import services
|
||||
from configs import dify_config
|
||||
from controllers.common.audio_response import audio_binary_response
|
||||
from controllers.common.errors import InternalServerError, NotFoundError, UnauthorizedError
|
||||
from controllers.common.fields import (
|
||||
AudioBinaryResponse,
|
||||
AudioTranscriptResponse,
|
||||
@@ -45,23 +46,26 @@ from controllers.console.app.error import (
|
||||
)
|
||||
from controllers.console.app.preview_admission import get_preview_app
|
||||
from controllers.console.explore.error import (
|
||||
AppPreviewOwnerUnavailableError as AppPreviewOwnerUnavailableHttpError,
|
||||
)
|
||||
from controllers.console.explore.error import (
|
||||
AppPreviewSiteUnavailableError as AppPreviewSiteUnavailableHttpError,
|
||||
)
|
||||
from controllers.console.explore.error import (
|
||||
AgentVersionNotFoundHTTPError,
|
||||
AppSuggestedQuestionsAfterAnswerDisabledError,
|
||||
NotChatAppError,
|
||||
NotCompletionAppError,
|
||||
NotWorkflowAppError,
|
||||
)
|
||||
from controllers.console.explore.trial_app_admission import get_trial_app
|
||||
from controllers.console.explore.error import (
|
||||
AppPreviewOwnerUnavailableError as AppPreviewOwnerUnavailableHttpError,
|
||||
)
|
||||
from controllers.console.explore.error import (
|
||||
AppPreviewSiteUnavailableError as AppPreviewSiteUnavailableHttpError,
|
||||
)
|
||||
from controllers.console.explore.trial_app_admission import get_trial_app, get_trial_app_for_stop
|
||||
from controllers.console.files import FILE_UPLOAD_PARAMS, upload_file_from_request_context
|
||||
from controllers.console.flask_admission import console_account_admission
|
||||
from controllers.console.remote_files import RemoteFileUploadPayload, upload_remote_file
|
||||
from controllers.console.wraps import cloud_edition_billing_resource_check, model_validate
|
||||
from controllers.web.error import InvokeRateLimitError as InvokeRateLimitHttpError
|
||||
from core.app.apps.agent_app.errors import AgentAppGeneratorError, AgentAppNotPublishedError
|
||||
from core.app.entities.app_invoke_entities import InvokeFrom
|
||||
from core.errors.error import (
|
||||
AppInvokeQuotaExceededError,
|
||||
ModelCurrentlyNotSupportError,
|
||||
@@ -71,6 +75,7 @@ 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 fields.agent_fields import AgentAppComposerResponse
|
||||
from fields.base import ResponseModel
|
||||
from fields.conversation_variable_fields import WorkflowConversationVariableResponse
|
||||
from fields.file_fields import FileResponse, FileWithSignedUrl
|
||||
@@ -78,12 +83,15 @@ from fields.message_fields import SuggestedQuestionsResponse
|
||||
from graphon.model_runtime.errors.invoke import InvokeError
|
||||
from graphon.variables import SecretVariable, VariableBase
|
||||
from libs import helper
|
||||
from libs.exception import BaseHTTPException
|
||||
from libs.helper import dump_response, to_timestamp, uuid_value
|
||||
from libs.stream import close_stream
|
||||
from libs.url_utils import normalize_api_base_url
|
||||
from machinery.context import RequestContext
|
||||
from models.enums import CreatorUserRole
|
||||
from models.model import AppMode
|
||||
from services.account_errors import AccountNotFoundError
|
||||
from services.agent.errors import AgentVersionNotFoundError
|
||||
from services.app_definition_query_service import AppDefinitionUnavailableError
|
||||
from services.app_preview_query_service import (
|
||||
AppPreviewOwnerUnavailableError,
|
||||
@@ -152,7 +160,7 @@ class TrialDatasetListQuery(BaseModel):
|
||||
ids: list[str] = Field(default_factory=list, description="Dataset IDs")
|
||||
|
||||
|
||||
type TrialAppMode = Literal["chat", "agent-chat", "advanced-chat", "workflow", "completion"]
|
||||
type TrialAppMode = Literal["chat", "agent-chat", "agent", "advanced-chat", "workflow", "completion"]
|
||||
type TrialIconType = Literal["emoji", "image", "link"]
|
||||
type JsonObject = dict[str, Any]
|
||||
|
||||
@@ -447,6 +455,7 @@ register_response_schema_models(
|
||||
SiteResponse,
|
||||
SuggestedQuestionsResponse,
|
||||
TrialAppDetailResponse,
|
||||
AgentAppComposerResponse,
|
||||
TrialDatasetListResponse,
|
||||
TrialWorkflowResponse,
|
||||
)
|
||||
@@ -460,7 +469,7 @@ simple_account_model = console_ns.models[TrialSimpleAccount.__name__]
|
||||
)
|
||||
class TrialAppFileUploadApi(Resource):
|
||||
@console_ns.doc(consumes=["multipart/form-data"], params=FILE_UPLOAD_PARAMS)
|
||||
@console_ns.response(201, "File uploaded successfully", console_ns.models[FileResponse.__name__])
|
||||
@console_ns.response(HTTPStatus.CREATED, "File uploaded successfully", console_ns.models[FileResponse.__name__])
|
||||
@console_account_admission()
|
||||
@get_trial_app
|
||||
@cloud_edition_billing_resource_check("documents")
|
||||
@@ -470,7 +479,7 @@ class TrialAppFileUploadApi(Resource):
|
||||
request_context=request_context,
|
||||
resource_tenant_id=trial_app.tenant_id,
|
||||
)
|
||||
return dump_response(FileResponse, upload_file), 201
|
||||
return dump_response(FileResponse, upload_file), HTTPStatus.CREATED
|
||||
|
||||
|
||||
@console_ns.route(
|
||||
@@ -479,7 +488,9 @@ class TrialAppFileUploadApi(Resource):
|
||||
)
|
||||
class TrialAppRemoteFileUploadApi(Resource):
|
||||
@console_ns.expect(console_ns.models[RemoteFileUploadPayload.__name__])
|
||||
@console_ns.response(201, "File uploaded successfully", console_ns.models[FileWithSignedUrl.__name__])
|
||||
@console_ns.response(
|
||||
HTTPStatus.CREATED, "File uploaded successfully", console_ns.models[FileWithSignedUrl.__name__]
|
||||
)
|
||||
@console_account_admission()
|
||||
@get_trial_app
|
||||
@cloud_edition_billing_resource_check("documents")
|
||||
@@ -493,7 +504,7 @@ class TrialAppRemoteFileUploadApi(Resource):
|
||||
current_user=FileUploadActor(id=request_context.account_id, creator_role=CreatorUserRole.ACCOUNT),
|
||||
resource_tenant_id=trial_app.tenant_id,
|
||||
)
|
||||
return dump_response(FileWithSignedUrl, remote_file), 201
|
||||
return dump_response(FileWithSignedUrl, remote_file), HTTPStatus.CREATED
|
||||
|
||||
|
||||
@console_ns.route(
|
||||
@@ -502,7 +513,7 @@ class TrialAppRemoteFileUploadApi(Resource):
|
||||
)
|
||||
class TrialAppWorkflowRunApi(Resource):
|
||||
@console_ns.expect(console_ns.models[WorkflowRunRequest.__name__])
|
||||
@console_ns.response(200, "Success")
|
||||
@console_ns.response(HTTPStatus.OK, "Success")
|
||||
@console_account_admission()
|
||||
@get_trial_app
|
||||
@model_validate(WorkflowRunRequest)
|
||||
@@ -511,7 +522,7 @@ class TrialAppWorkflowRunApi(Resource):
|
||||
Run workflow
|
||||
"""
|
||||
try:
|
||||
response = application_services().trial_app_generation.generate_workflow(
|
||||
response = application_services().trial_apps.generation.generate_workflow(
|
||||
trial_app=trial_app,
|
||||
account_id=request_context.account_id,
|
||||
args=req_data.model_dump(),
|
||||
@@ -523,7 +534,7 @@ class TrialAppWorkflowRunApi(Resource):
|
||||
except AppDefinitionUnavailableError as error:
|
||||
raise AppUnavailableError() from error
|
||||
except AccountNotFoundError as error:
|
||||
raise Unauthorized("Account no longer exists.") from error
|
||||
raise UnauthorizedError("Account no longer exists.") from error
|
||||
except services.errors.app_model_config.AppModelConfigBrokenError as error:
|
||||
raise AppUnavailableError() from error
|
||||
except ProviderTokenNotInitError as ex:
|
||||
@@ -545,7 +556,7 @@ class TrialAppWorkflowRunApi(Resource):
|
||||
|
||||
@console_ns.route("/trial-apps/<uuid:app_id>/workflows/tasks/<string:task_id>/stop")
|
||||
class TrialAppWorkflowTaskStopApi(Resource):
|
||||
@console_ns.response(200, "Success", console_ns.models[SimpleResultResponse.__name__])
|
||||
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[SimpleResultResponse.__name__])
|
||||
@console_account_admission()
|
||||
@get_trial_app
|
||||
def post(self, request_context: RequestContext, trial_app: TrialAppRef, task_id: str) -> dict[str, object]:
|
||||
@@ -566,7 +577,7 @@ class TrialAppWorkflowTaskStopApi(Resource):
|
||||
)
|
||||
class TrialChatApi(Resource):
|
||||
@console_ns.expect(console_ns.models[ChatRequest.__name__])
|
||||
@console_ns.response(200, "Success")
|
||||
@console_ns.response(HTTPStatus.OK, "Success")
|
||||
@console_account_admission()
|
||||
@get_trial_app
|
||||
@model_validate(ChatRequest)
|
||||
@@ -580,7 +591,7 @@ class TrialChatApi(Resource):
|
||||
args["parent_message_id"] = uuid_value(args["parent_message_id"])
|
||||
|
||||
try:
|
||||
response = application_services().trial_app_generation.generate_chat(
|
||||
response = application_services().trial_apps.generation.generate_chat(
|
||||
trial_app=trial_app,
|
||||
account_id=request_context.account_id,
|
||||
args=args,
|
||||
@@ -592,14 +603,16 @@ class TrialChatApi(Resource):
|
||||
except AppDefinitionUnavailableError as error:
|
||||
raise AppUnavailableError() from error
|
||||
except AccountNotFoundError as error:
|
||||
raise Unauthorized("Account no longer exists.") from error
|
||||
raise UnauthorizedError("Account no longer exists.") from error
|
||||
except services.errors.conversation.ConversationNotExistsError:
|
||||
raise NotFound("Conversation Not Exists.")
|
||||
raise NotFoundError("Conversation Not Exists.")
|
||||
except services.errors.conversation.ConversationCompletedError:
|
||||
raise ConversationCompletedError()
|
||||
except services.errors.app_model_config.AppModelConfigBrokenError:
|
||||
logger.exception("App model config broken.")
|
||||
raise AppUnavailableError()
|
||||
except (AgentAppGeneratorError, AgentAppNotPublishedError):
|
||||
raise AppUnavailableError() from None
|
||||
except ProviderTokenNotInitError as ex:
|
||||
raise ProviderNotInitializeError(ex.description)
|
||||
except QuotaExceededError:
|
||||
@@ -617,16 +630,37 @@ class TrialChatApi(Resource):
|
||||
raise InternalServerError()
|
||||
|
||||
|
||||
@console_ns.route(
|
||||
"/trial-apps/<uuid:app_id>/chat-messages/<string:task_id>/stop",
|
||||
endpoint="trial_app_stop_chat_completion",
|
||||
)
|
||||
class TrialChatTaskStopApi(Resource):
|
||||
@console_ns.response(200, "Success", console_ns.models[SimpleResultResponse.__name__])
|
||||
@console_account_admission()
|
||||
@get_trial_app_for_stop
|
||||
def post(self, request_context: RequestContext, trial_app: TrialAppRef, task_id: str):
|
||||
if trial_app.app_mode not in {"chat", "agent-chat", "agent", "advanced-chat"}:
|
||||
raise NotChatAppError()
|
||||
|
||||
application_services().app_tasks.stop_task(
|
||||
task_id=task_id,
|
||||
invoke_from=InvokeFrom.EXPLORE,
|
||||
user_id=request_context.account_id,
|
||||
app_mode=AppMode.value_of(trial_app.app_mode),
|
||||
)
|
||||
return SimpleResultResponse(result="success").model_dump(mode="json"), 200
|
||||
|
||||
|
||||
@console_ns.route(
|
||||
"/trial-apps/<uuid:app_id>/messages/<uuid:message_id>/suggested-questions",
|
||||
endpoint="trial_app_suggested_question",
|
||||
)
|
||||
class TrialMessageSuggestedQuestionApi(Resource):
|
||||
@console_ns.response(200, "Success", console_ns.models[SuggestedQuestionsResponse.__name__])
|
||||
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[SuggestedQuestionsResponse.__name__])
|
||||
@console_account_admission()
|
||||
@get_trial_app
|
||||
def get(self, request_context: RequestContext, trial_app: TrialAppRef, message_id: UUID) -> dict[str, object]:
|
||||
if trial_app.app_mode not in {"chat", "agent-chat", "advanced-chat"}:
|
||||
if trial_app.app_mode not in {"chat", "agent-chat", "agent", "advanced-chat"}:
|
||||
raise NotChatAppError()
|
||||
|
||||
try:
|
||||
@@ -640,11 +674,11 @@ class TrialMessageSuggestedQuestionApi(Resource):
|
||||
except AppDefinitionUnavailableError as error:
|
||||
raise AppUnavailableError() from error
|
||||
except SuggestedQuestionsActorNotFoundError as error:
|
||||
raise Unauthorized("Account no longer exists.") from error
|
||||
raise UnauthorizedError("Account no longer exists.") from error
|
||||
except MessageNotExistsError:
|
||||
raise NotFound("Message not found")
|
||||
raise NotFoundError("Message not found")
|
||||
except ConversationNotExistsError:
|
||||
raise NotFound("Conversation not found")
|
||||
raise NotFoundError("Conversation not found")
|
||||
except SuggestedQuestionsAfterAnswerDisabledError:
|
||||
raise AppSuggestedQuestionsAfterAnswerDisabledError()
|
||||
except ProviderTokenNotInitError as ex:
|
||||
@@ -687,7 +721,9 @@ def _trial_audio_errors[**P, R](view: Callable[P, R]) -> Callable[P, R]:
|
||||
raise ProviderModelCurrentlyNotSupportError() from error
|
||||
except InvokeError as error:
|
||||
raise CompletionRequestError(error.description) from error
|
||||
except (HTTPException, ValueError):
|
||||
except AgentVersionNotFoundError as error:
|
||||
raise AgentVersionNotFoundHTTPError() from error
|
||||
except (BaseHTTPException, ValueError):
|
||||
raise
|
||||
except Exception as error:
|
||||
logger.exception("Trial audio operation failed")
|
||||
@@ -701,18 +737,25 @@ def _trial_audio_errors[**P, R](view: Callable[P, R]) -> Callable[P, R]:
|
||||
endpoint="trial_app_audio",
|
||||
)
|
||||
class TrialChatAudioApi(Resource):
|
||||
@console_ns.response(200, "Success", console_ns.models[AudioTranscriptResponse.__name__])
|
||||
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[AudioTranscriptResponse.__name__])
|
||||
@console_account_admission()
|
||||
@get_trial_app
|
||||
@_trial_audio_errors
|
||||
def post(self, request_context: RequestContext, trial_app: TrialAppRef) -> dict[str, object]:
|
||||
# Let Flask handle multipart parsing errors before mapping audio operation failures.
|
||||
file = request.files.get("file")
|
||||
audio = AudioUpload(stream=file.stream, mime_type=file.mimetype) if file is not None else None
|
||||
return self._transcript_asr(request_context=request_context, trial_app=trial_app, audio=audio)
|
||||
|
||||
@_trial_audio_errors
|
||||
def _transcript_asr(
|
||||
self, *, request_context: RequestContext, trial_app: TrialAppRef, audio: AudioUpload | None
|
||||
) -> dict[str, object]:
|
||||
"""Transcribe an upload; None lets the audio service report a missing file."""
|
||||
transcript = application_services().app_audio.transcript_asr(
|
||||
app=AudioAppRef(app_id=trial_app.app_id, tenant_id=trial_app.tenant_id, app_mode=trial_app.app_mode),
|
||||
audio=audio,
|
||||
)
|
||||
application_services().trial_app_usage.record(app_id=trial_app.app_id, account_id=request_context.account_id)
|
||||
application_services().trial_apps.usage.record(app_id=trial_app.app_id, account_id=request_context.account_id)
|
||||
return dump_response(AudioTranscriptResponse, transcript)
|
||||
|
||||
|
||||
@@ -722,7 +765,7 @@ class TrialChatAudioApi(Resource):
|
||||
)
|
||||
class TrialChatTextApi(Resource):
|
||||
@console_ns.expect(console_ns.models[TextToSpeechRequest.__name__])
|
||||
@console_ns.response(200, "Success", console_ns.models[AudioBinaryResponse.__name__])
|
||||
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[AudioBinaryResponse.__name__])
|
||||
@console_account_admission()
|
||||
@get_trial_app
|
||||
@model_validate(TextToSpeechRequest)
|
||||
@@ -741,7 +784,7 @@ class TrialChatTextApi(Resource):
|
||||
try:
|
||||
# Preserve usage after MIME inspection, including a missing message's
|
||||
# null response. Early provider/MIME failures do not consume a trial.
|
||||
application_services().trial_app_usage.record(
|
||||
application_services().trial_apps.usage.record(
|
||||
app_id=trial_app.app_id, account_id=request_context.account_id
|
||||
)
|
||||
except BaseException:
|
||||
@@ -758,13 +801,13 @@ class TrialChatTextApi(Resource):
|
||||
)
|
||||
class TrialCompletionApi(Resource):
|
||||
@console_ns.expect(console_ns.models[CompletionRequest.__name__])
|
||||
@console_ns.response(200, "Success")
|
||||
@console_ns.response(HTTPStatus.OK, "Success")
|
||||
@console_account_admission()
|
||||
@get_trial_app
|
||||
@model_validate(CompletionRequest)
|
||||
def post(self, req_data: CompletionRequest, request_context: RequestContext, trial_app: TrialAppRef) -> Response:
|
||||
try:
|
||||
response = application_services().trial_app_generation.generate_completion(
|
||||
response = application_services().trial_apps.generation.generate_completion(
|
||||
trial_app=trial_app,
|
||||
account_id=request_context.account_id,
|
||||
args=req_data.model_dump(),
|
||||
@@ -776,9 +819,9 @@ class TrialCompletionApi(Resource):
|
||||
except AppDefinitionUnavailableError as error:
|
||||
raise AppUnavailableError() from error
|
||||
except AccountNotFoundError as error:
|
||||
raise Unauthorized("Account no longer exists.") from error
|
||||
raise UnauthorizedError("Account no longer exists.") from error
|
||||
except services.errors.conversation.ConversationNotExistsError:
|
||||
raise NotFound("Conversation Not Exists.")
|
||||
raise NotFoundError("Conversation Not Exists.")
|
||||
except services.errors.conversation.ConversationCompletedError:
|
||||
raise ConversationCompletedError()
|
||||
except services.errors.app_model_config.AppModelConfigBrokenError:
|
||||
@@ -805,7 +848,7 @@ class TrialCompletionApi(Resource):
|
||||
class TrialSitApi(Resource):
|
||||
"""Resource for trial app sites."""
|
||||
|
||||
@console_ns.response(200, "Success", console_ns.models[SiteResponse.__name__])
|
||||
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[SiteResponse.__name__])
|
||||
@get_preview_app
|
||||
def get(self, app: AppPreviewRef) -> dict[str, object]:
|
||||
"""Retrieve app site info.
|
||||
@@ -828,13 +871,13 @@ class TrialSitApi(Resource):
|
||||
class TrialAppParameterApi(Resource):
|
||||
"""Resource for app variables."""
|
||||
|
||||
@console_ns.response(200, "Success", console_ns.models[ParametersResponse.__name__])
|
||||
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[ParametersResponse.__name__])
|
||||
@get_preview_app
|
||||
def get(self, app: AppPreviewRef) -> dict[str, object]:
|
||||
"""Retrieve app parameters."""
|
||||
|
||||
try:
|
||||
parameters = application_services().app_definitions.get_parameters(app.app_id)
|
||||
parameters = application_services().app_definitions.get_public_parameters(app.app_id)
|
||||
except AppDefinitionUnavailableError:
|
||||
raise AppUnavailableError() from None
|
||||
|
||||
@@ -843,7 +886,7 @@ class TrialAppParameterApi(Resource):
|
||||
|
||||
@console_ns.route("/trial-apps/<uuid:app_id>", endpoint="trial_app")
|
||||
class AppApi(Resource):
|
||||
@console_ns.response(200, "Success", console_ns.models[TrialAppDetailResponse.__name__])
|
||||
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[TrialAppDetailResponse.__name__])
|
||||
@console_account_admission()
|
||||
@get_preview_app
|
||||
def get(self, app: AppPreviewRef, request_context: RequestContext) -> dict[str, object]:
|
||||
@@ -859,7 +902,7 @@ class AppApi(Resource):
|
||||
except AppDefinitionUnavailableError as error:
|
||||
raise AppUnavailableError() from error
|
||||
except AccountNotFoundError as error:
|
||||
raise Unauthorized("Account no longer exists.") from error
|
||||
raise UnauthorizedError("Account no longer exists.") from error
|
||||
except AppPreviewSiteUnavailableError as error:
|
||||
raise AppPreviewSiteUnavailableHttpError(str(error)) from error
|
||||
|
||||
@@ -872,12 +915,24 @@ class AppApi(Resource):
|
||||
return dump_response(TrialAppDetailResponse, source)
|
||||
|
||||
|
||||
@console_ns.route("/trial-apps/<uuid:app_id>/agent-composer")
|
||||
class TrialAgentComposerApi(Resource):
|
||||
@console_ns.response(200, "Published Agent configuration", console_ns.models[AgentAppComposerResponse.__name__])
|
||||
@get_preview_app
|
||||
def get(self, app: AppPreviewRef):
|
||||
try:
|
||||
preview = application_services().app_previews.get_agent_composer(app=app)
|
||||
except AppDefinitionUnavailableError:
|
||||
raise AppUnavailableError() from None
|
||||
return dump_response(AgentAppComposerResponse, preview)
|
||||
|
||||
|
||||
@console_ns.route(
|
||||
"/trial-apps/<uuid:app_id>/workflows",
|
||||
endpoint="trial_app_workflow",
|
||||
)
|
||||
class AppWorkflowApi(Resource):
|
||||
@console_ns.response(200, "Success", console_ns.models[TrialWorkflowResponse.__name__])
|
||||
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[TrialWorkflowResponse.__name__])
|
||||
@get_preview_app
|
||||
def get(self, app: AppPreviewRef) -> dict[str, object]:
|
||||
"""Get a detached workflow definition after catalog preview admission."""
|
||||
@@ -896,7 +951,7 @@ class AppWorkflowApi(Resource):
|
||||
)
|
||||
class DatasetListApi(Resource):
|
||||
@console_ns.doc(params=query_params_from_model(TrialDatasetListQuery))
|
||||
@console_ns.response(200, "Success", console_ns.models[TrialDatasetListResponse.__name__])
|
||||
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[TrialDatasetListResponse.__name__])
|
||||
@get_preview_app
|
||||
def get(self, app: AppPreviewRef) -> dict[str, object]:
|
||||
# These legacy fields are response metadata: the query returns all
|
||||
|
||||
@@ -28,8 +28,10 @@ def trial_feature_enable[**P, R](view: Callable[P, R]) -> Callable[P, R]:
|
||||
|
||||
def get_trial_app[T, **P, R](
|
||||
view: Callable[Concatenate[T, RequestContext, TrialAppRef, P], R],
|
||||
*,
|
||||
check_usage: bool = True,
|
||||
) -> Callable[Concatenate[T, RequestContext, P], R]:
|
||||
"""Check trial availability and inject an admitted app after Console admission."""
|
||||
"""Admit the trial app, optionally skipping generation quota for task cancellation."""
|
||||
|
||||
@wraps(view)
|
||||
def decorated(self: T, request_context: RequestContext, /, *args: P.args, **kwargs: P.kwargs) -> R:
|
||||
@@ -37,8 +39,8 @@ def get_trial_app[T, **P, R](
|
||||
if app_id is None:
|
||||
raise RuntimeError("The trial app admission route must provide app_id")
|
||||
try:
|
||||
trial_app = application_services().trial_app_access.get_access(
|
||||
app_id=str(app_id), account_id=request_context.account_id
|
||||
trial_app = application_services().trial_apps.access.get_access(
|
||||
app_id=str(app_id), account_id=request_context.account_id, check_usage=check_usage
|
||||
)
|
||||
except TrialAppUnavailableError as error:
|
||||
raise TrialAppNotAllowed() from error
|
||||
@@ -47,3 +49,10 @@ def get_trial_app[T, **P, R](
|
||||
return view(self, request_context, trial_app, *args, **kwargs)
|
||||
|
||||
return trial_feature_enable(decorated)
|
||||
|
||||
|
||||
def get_trial_app_for_stop[T, **P, R](
|
||||
view: Callable[Concatenate[T, RequestContext, TrialAppRef, P], R],
|
||||
) -> Callable[Concatenate[T, RequestContext, P], R]:
|
||||
"""Keep trial access checks without requiring remaining generation quota."""
|
||||
return get_trial_app(view, check_usage=False)
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import logging
|
||||
from http import HTTPStatus
|
||||
|
||||
from flask import Response
|
||||
from flask_restx import Resource
|
||||
from werkzeug.exceptions import InternalServerError, Unauthorized
|
||||
|
||||
from controllers.common.controller_schemas import WorkflowRunPayload
|
||||
from controllers.common.errors import InternalServerError, UnauthorizedError
|
||||
from controllers.common.fields import SimpleResultResponse
|
||||
from controllers.common.schema import register_response_schema_models, register_schema_model
|
||||
from controllers.console.app.error import (
|
||||
@@ -44,7 +45,7 @@ register_response_schema_models(console_ns, SimpleResultResponse)
|
||||
@console_ns.route("/installed-apps/<uuid:installed_app_id>/workflows/run")
|
||||
class InstalledAppWorkflowRunApi(Resource):
|
||||
@console_ns.expect(console_ns.models[WorkflowRunPayload.__name__])
|
||||
@console_ns.response(200, "Success")
|
||||
@console_ns.response(HTTPStatus.OK, "Success")
|
||||
@console_account_admission()
|
||||
@get_installed_app
|
||||
@model_validate(WorkflowRunPayload)
|
||||
@@ -58,7 +59,7 @@ class InstalledAppWorkflowRunApi(Resource):
|
||||
Run workflow
|
||||
"""
|
||||
try:
|
||||
response = application_services().installed_app_generation.generate_workflow(
|
||||
response = application_services().installed_apps.generation.generate_workflow(
|
||||
installed_app=installed_app,
|
||||
account_id=request_context.account_id,
|
||||
args=req_data.model_dump(exclude_none=True),
|
||||
@@ -69,7 +70,7 @@ class InstalledAppWorkflowRunApi(Resource):
|
||||
except (AppDefinitionUnavailableError, InstalledAppNotWorkflowError):
|
||||
raise NotWorkflowAppError() from None
|
||||
except AccountNotFoundError:
|
||||
raise Unauthorized("Account no longer exists.") from None
|
||||
raise UnauthorizedError("Account no longer exists.") from None
|
||||
except ProviderTokenNotInitError as ex:
|
||||
raise ProviderNotInitializeError(ex.description)
|
||||
except QuotaExceededError:
|
||||
@@ -89,7 +90,7 @@ class InstalledAppWorkflowRunApi(Resource):
|
||||
|
||||
@console_ns.route("/installed-apps/<uuid:installed_app_id>/workflows/tasks/<string:task_id>/stop")
|
||||
class InstalledAppWorkflowTaskStopApi(Resource):
|
||||
@console_ns.response(200, "Success", console_ns.models[SimpleResultResponse.__name__])
|
||||
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[SimpleResultResponse.__name__])
|
||||
@console_account_admission()
|
||||
@get_installed_app
|
||||
def post(self, request_context: RequestContext, installed_app: InstalledAppRef, task_id: str) -> dict[str, object]:
|
||||
|
||||
@@ -6,17 +6,23 @@ from flask_restx import Resource
|
||||
from pydantic import BaseModel, Field, RootModel, field_validator
|
||||
|
||||
from constants import HIDDEN_VALUE
|
||||
from extensions.ext_database import db
|
||||
from controllers.console.flask_admission import console_account_admission
|
||||
from extensions.ext_application_services import application_services
|
||||
from fields.base import ResponseModel
|
||||
from libs.helper import dump_response, to_timestamp
|
||||
from libs.login import login_required
|
||||
from models.api_based_extension import APIBasedExtension
|
||||
from services.api_based_extension_service import APIBasedExtensionService
|
||||
from machinery.context import RequestContext
|
||||
from services.api_based_extension_application_service import (
|
||||
APIBasedExtensionError,
|
||||
APIBasedExtensionInput,
|
||||
APIBasedExtensionRecord,
|
||||
APIBasedExtensionUpdate,
|
||||
)
|
||||
from services.code_based_extension_service import CodeBasedExtensionService
|
||||
|
||||
from ..common.schema import query_params_from_model, register_response_schema_models, register_schema_models
|
||||
from . import console_ns
|
||||
from .wraps import account_initialization_required, model_validate, setup_required, with_current_tenant_id
|
||||
from .wraps import account_initialization_required, model_validate, setup_required
|
||||
|
||||
|
||||
class CodeBasedExtensionQuery(BaseModel):
|
||||
@@ -77,6 +83,10 @@ register_response_schema_models(
|
||||
)
|
||||
|
||||
|
||||
def _extension_response(extension: APIBasedExtensionRecord) -> dict[str, Any]:
|
||||
return dump_response(APIBasedExtensionResponse, extension)
|
||||
|
||||
|
||||
@console_ns.route("/code-based-extension")
|
||||
class CodeBasedExtensionAPI(Resource):
|
||||
@console_ns.doc("get_code_based_extension")
|
||||
@@ -103,41 +113,32 @@ class APIBasedExtensionAPI(Resource):
|
||||
@console_ns.doc("get_api_based_extensions")
|
||||
@console_ns.doc(description="Get all API-based extensions for current tenant")
|
||||
@console_ns.response(200, "Success", console_ns.models[APIBasedExtensionListResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_current_tenant_id
|
||||
def get(self, current_tenant_id: str):
|
||||
return dump_response(
|
||||
APIBasedExtensionListResponse,
|
||||
APIBasedExtensionService.get_all_by_tenant_id(current_tenant_id, session=db.session()),
|
||||
)
|
||||
@console_account_admission()
|
||||
def get(self, request_context: RequestContext):
|
||||
extensions = application_services().api_based_extensions.list_extensions(request_context)
|
||||
return dump_response(APIBasedExtensionListResponse, extensions)
|
||||
|
||||
@console_ns.doc("create_api_based_extension")
|
||||
@console_ns.doc(description="Create a new API-based extension")
|
||||
@console_ns.expect(console_ns.models[APIBasedExtensionPayload.__name__])
|
||||
@console_ns.response(201, "Extension created successfully", console_ns.models[APIBasedExtensionResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_current_tenant_id
|
||||
@console_account_admission()
|
||||
@model_validate(APIBasedExtensionPayload)
|
||||
def post(self, req_data: APIBasedExtensionPayload, current_tenant_id: str):
|
||||
extension_data = APIBasedExtension(
|
||||
tenant_id=current_tenant_id,
|
||||
name=req_data.name,
|
||||
api_endpoint=req_data.api_endpoint,
|
||||
api_key=req_data.api_key,
|
||||
)
|
||||
def post(self, req_data: APIBasedExtensionPayload, request_context: RequestContext):
|
||||
try:
|
||||
extension = application_services().api_based_extensions.create_extension(
|
||||
request_context,
|
||||
APIBasedExtensionInput(
|
||||
name=req_data.name,
|
||||
api_endpoint=req_data.api_endpoint,
|
||||
api_key=req_data.api_key,
|
||||
),
|
||||
)
|
||||
except APIBasedExtensionError as error:
|
||||
# Legacy clients receive the 400 `invalid_param` envelope for every extension failure.
|
||||
raise ValueError(str(error)) from None
|
||||
|
||||
extension = APIBasedExtensionService.save(extension_data, session=db.session())
|
||||
return APIBasedExtensionResponse(
|
||||
id=extension.id,
|
||||
name=extension.name,
|
||||
api_endpoint=extension.api_endpoint,
|
||||
api_key=req_data.api_key,
|
||||
created_at=to_timestamp(extension.created_at),
|
||||
).model_dump(mode="json"), 201
|
||||
return _extension_response(extension), 201
|
||||
|
||||
|
||||
@console_ns.route("/api-based-extension/<uuid:id>")
|
||||
@@ -146,70 +147,48 @@ class APIBasedExtensionDetailAPI(Resource):
|
||||
@console_ns.doc(description="Get API-based extension by ID")
|
||||
@console_ns.doc(params={"id": "Extension ID"})
|
||||
@console_ns.response(200, "Success", console_ns.models[APIBasedExtensionResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_current_tenant_id
|
||||
def get(self, current_tenant_id: str, id: UUID):
|
||||
api_based_extension_id = str(id)
|
||||
@console_account_admission()
|
||||
def get(self, request_context: RequestContext, id: UUID):
|
||||
try:
|
||||
extension = application_services().api_based_extensions.get_extension(request_context, str(id))
|
||||
except APIBasedExtensionError as error:
|
||||
raise ValueError(str(error)) from None
|
||||
|
||||
return dump_response(
|
||||
APIBasedExtensionResponse,
|
||||
APIBasedExtensionService.get_with_tenant_id(
|
||||
current_tenant_id, api_based_extension_id, session=db.session()
|
||||
),
|
||||
)
|
||||
return _extension_response(extension)
|
||||
|
||||
@console_ns.doc("update_api_based_extension")
|
||||
@console_ns.doc(description="Update API-based extension")
|
||||
@console_ns.doc(params={"id": "Extension ID"})
|
||||
@console_ns.expect(console_ns.models[APIBasedExtensionPayload.__name__])
|
||||
@console_ns.response(200, "Extension updated successfully", console_ns.models[APIBasedExtensionResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_current_tenant_id
|
||||
@console_account_admission()
|
||||
@model_validate(APIBasedExtensionPayload)
|
||||
def post(self, req_data: APIBasedExtensionPayload, current_tenant_id: str, id: UUID):
|
||||
api_based_extension_id = str(id)
|
||||
def post(self, req_data: APIBasedExtensionPayload, request_context: RequestContext, id: UUID):
|
||||
try:
|
||||
extension = application_services().api_based_extensions.update_extension(
|
||||
request_context,
|
||||
str(id),
|
||||
APIBasedExtensionUpdate(
|
||||
name=req_data.name,
|
||||
api_endpoint=req_data.api_endpoint,
|
||||
# The console echoes the masked key back; keep the stored key when it is sent unchanged.
|
||||
api_key=None if req_data.api_key == HIDDEN_VALUE else req_data.api_key,
|
||||
),
|
||||
)
|
||||
except APIBasedExtensionError as error:
|
||||
raise ValueError(str(error)) from None
|
||||
|
||||
extension_data_from_db = APIBasedExtensionService.get_with_tenant_id(
|
||||
current_tenant_id, api_based_extension_id, session=db.session()
|
||||
)
|
||||
|
||||
api_key_for_response = extension_data_from_db.api_key
|
||||
|
||||
extension_data_from_db.name = req_data.name
|
||||
extension_data_from_db.api_endpoint = req_data.api_endpoint
|
||||
|
||||
if req_data.api_key != HIDDEN_VALUE:
|
||||
extension_data_from_db.api_key = req_data.api_key
|
||||
api_key_for_response = req_data.api_key
|
||||
|
||||
APIBasedExtensionService.save(extension_data_from_db, session=db.session())
|
||||
return APIBasedExtensionResponse(
|
||||
id=extension_data_from_db.id,
|
||||
name=extension_data_from_db.name,
|
||||
api_endpoint=extension_data_from_db.api_endpoint,
|
||||
api_key=api_key_for_response,
|
||||
created_at=to_timestamp(extension_data_from_db.created_at),
|
||||
).model_dump(mode="json")
|
||||
return _extension_response(extension)
|
||||
|
||||
@console_ns.doc("delete_api_based_extension")
|
||||
@console_ns.doc(description="Delete API-based extension")
|
||||
@console_ns.doc(params={"id": "Extension ID"})
|
||||
@console_ns.response(204, "Extension deleted successfully")
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_current_tenant_id
|
||||
def delete(self, current_tenant_id: str, id: UUID):
|
||||
api_based_extension_id = str(id)
|
||||
|
||||
extension_data_from_db = APIBasedExtensionService.get_with_tenant_id(
|
||||
current_tenant_id, api_based_extension_id, session=db.session()
|
||||
)
|
||||
|
||||
APIBasedExtensionService.delete(extension_data_from_db, session=db.session())
|
||||
@console_account_admission()
|
||||
def delete(self, request_context: RequestContext, id: UUID):
|
||||
try:
|
||||
application_services().api_based_extensions.delete_extension(request_context, str(id))
|
||||
except APIBasedExtensionError as error:
|
||||
raise ValueError(str(error)) from None
|
||||
|
||||
return "", 204
|
||||
|
||||
@@ -10,22 +10,32 @@ from werkzeug.exceptions import Forbidden
|
||||
|
||||
from configs import dify_config
|
||||
from controllers.common.rbac import RBAC_CHECKS_ATTR, RBACCheck, enforce_rbac_checks
|
||||
from controllers.console.admin import admin_required
|
||||
from controllers.console.wraps import (
|
||||
account_initialization_required,
|
||||
cloud_edition_billing_resource_check,
|
||||
enable_change_email,
|
||||
enterprise_license_required,
|
||||
is_allow_transfer_owner,
|
||||
setup_required,
|
||||
)
|
||||
from core.logging.context import get_request_id, get_trace_id
|
||||
from enums import DeploymentEdition
|
||||
from enums.account import TenantAccountRole
|
||||
from libs.login import current_account_with_tenant, login_required
|
||||
from libs.oauth_bearer import bearer_feature_required
|
||||
from machinery.context import RequestContext
|
||||
from models.account import TenantAccountRole
|
||||
from services.system_feature_service import SystemFeatureService
|
||||
|
||||
|
||||
def console_admin_admission[T, **P, R](
|
||||
view: Callable[Concatenate[T, P], R],
|
||||
) -> Callable[Concatenate[T, P], R]:
|
||||
"""Admit a configured admin API key without requiring a Console account."""
|
||||
|
||||
return setup_required(admin_required(view))
|
||||
|
||||
|
||||
def console_email_registration_admission[T, **P, R](
|
||||
view: Callable[Concatenate[T, P], R],
|
||||
) -> Callable[Concatenate[T, P], R | Response]:
|
||||
@@ -49,29 +59,26 @@ def console_account_admission[T, **P, R](
|
||||
require_change_email_enabled: bool = False,
|
||||
require_initialized: bool = True,
|
||||
require_valid_enterprise_license: bool = False,
|
||||
require_owner_transfer_enabled: bool = False,
|
||||
billing_resource: str | None = None,
|
||||
require_oauth_bearer_enabled: bool = False,
|
||||
allowed_roles: frozenset[TenantAccountRole] | None = None,
|
||||
rbac_checks: Sequence[RBACCheck] | None = None,
|
||||
billing_resource: str | None = None,
|
||||
) -> Callable[
|
||||
[Callable[Concatenate[T, RequestContext, P], R]],
|
||||
Callable[Concatenate[T, P], R | Response],
|
||||
]:
|
||||
"""Declare Console account admission and inject a stable RequestContext.
|
||||
|
||||
All combinations use this decorator factory. Requirements are data, while
|
||||
the execution order stays fixed: edition, setup, login/CSRF, optional
|
||||
account initialization, optional enterprise license and feature checks,
|
||||
role/RBAC checks, then context construction and optional billing-resource
|
||||
admission.
|
||||
Checks run in a fixed order: setup, login/CSRF, optional account
|
||||
initialization, edition, license, feature and billing checks, role/RBAC
|
||||
checks, then context construction.
|
||||
"""
|
||||
|
||||
def decorator(
|
||||
view: Callable[Concatenate[T, RequestContext, P], R],
|
||||
) -> Callable[Concatenate[T, P], R | Response]:
|
||||
admitted_view = cloud_edition_billing_resource_check(billing_resource)(view) if billing_resource else view
|
||||
|
||||
@wraps(view, updated=())
|
||||
@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
|
||||
@@ -91,32 +98,40 @@ def console_account_admission[T, **P, R](
|
||||
request_id=get_request_id(),
|
||||
trace_id=get_trace_id() or request.headers.get("X-Trace-Id"),
|
||||
)
|
||||
return admitted_view(self, request_context, *args, **kwargs)
|
||||
return view(self, request_context, *args, **kwargs)
|
||||
|
||||
if rbac_checks is not None:
|
||||
setattr(inject_request_context, RBAC_CHECKS_ATTR, rbac_checks)
|
||||
|
||||
admitted: Callable[Concatenate[T, P], R | Response] = inject_request_context
|
||||
if billing_resource is not None:
|
||||
admitted = cloud_edition_billing_resource_check(billing_resource)(admitted)
|
||||
if require_change_email_enabled:
|
||||
admitted = enable_change_email(admitted)
|
||||
if require_owner_transfer_enabled:
|
||||
admitted = is_allow_transfer_owner(admitted)
|
||||
if require_oauth_bearer_enabled:
|
||||
admitted = bearer_feature_required(admitted)
|
||||
if require_valid_enterprise_license:
|
||||
admitted = enterprise_license_required(admitted)
|
||||
if editions is not None:
|
||||
admitted = enforce_edition(admitted)
|
||||
if require_initialized:
|
||||
admitted = account_initialization_required(admitted)
|
||||
admitted = login_required(admitted)
|
||||
admitted = setup_required(admitted)
|
||||
|
||||
if editions is None:
|
||||
return admitted
|
||||
return admitted
|
||||
|
||||
@wraps(view)
|
||||
def enforce_edition(self: T, /, *args: P.args, **kwargs: P.kwargs) -> R | Response:
|
||||
if dify_config.DEPLOYMENT_EDITION not in editions:
|
||||
def enforce_edition(
|
||||
admitted: Callable[Concatenate[T, P], R | Response],
|
||||
) -> Callable[Concatenate[T, P], R | Response]:
|
||||
@wraps(admitted)
|
||||
def checked(self: T, /, *args: P.args, **kwargs: P.kwargs) -> R | Response:
|
||||
if editions is not None and dify_config.DEPLOYMENT_EDITION not in editions:
|
||||
abort(HTTPStatus.NOT_FOUND)
|
||||
return admitted(self, *args, **kwargs)
|
||||
|
||||
return enforce_edition
|
||||
return checked
|
||||
|
||||
return decorator
|
||||
|
||||
@@ -7,11 +7,8 @@ from controllers.fastopenapi import console_router
|
||||
from extensions.ext_application_services import application_services
|
||||
from libs.helper import EmailStr, extract_remote_ip
|
||||
from libs.password import valid_password
|
||||
from services.setup_service import (
|
||||
InitializationValidationRequiredError,
|
||||
SetupAlreadyCompletedError,
|
||||
SetupInput,
|
||||
)
|
||||
from services.account.contracts import SetupInput
|
||||
from services.setup_service import InitializationValidationRequiredError, SetupAlreadyCompletedError
|
||||
|
||||
from .error import AlreadySetupError, NotInitValidateError
|
||||
from .init_validate import is_init_validated
|
||||
|
||||
@@ -5,11 +5,11 @@ from typing import cast
|
||||
from flask import Request as FlaskRequest
|
||||
|
||||
from core.db.session_factory import session_factory
|
||||
from extensions.ext_application_services import application_services
|
||||
from extensions.ext_socketio import sio
|
||||
from libs.passport import PassportService
|
||||
from libs.token import extract_access_token
|
||||
from repositories.workflow_collaboration_repository import WorkflowCollaborationRepository
|
||||
from services.account_service import AccountService
|
||||
from services.workflow_collaboration_service import WorkflowCollaborationService
|
||||
|
||||
repository = WorkflowCollaborationRepository()
|
||||
@@ -43,8 +43,8 @@ def socket_connect(sid, environ, auth):
|
||||
logging.warning("Socket connect rejected: missing user_id (sid=%s)", sid)
|
||||
return False
|
||||
|
||||
with sio.app.app_context(), session_factory.create_session() as session:
|
||||
user = AccountService.load_logged_in_account(account_id=user_id, session=session)
|
||||
with sio.app.app_context():
|
||||
user = application_services().accounts.identity.load_user(user_id)
|
||||
if not user:
|
||||
logging.warning("Socket connect rejected: user not found (user_id=%s, sid=%s)", user_id, sid)
|
||||
return False
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
from http import HTTPStatus
|
||||
from typing import Annotated, Literal
|
||||
from typing import Annotated, Literal, NoReturn
|
||||
from urllib import parse
|
||||
from uuid import UUID
|
||||
|
||||
from flask import abort, request
|
||||
from flask import request
|
||||
from flask_restx import Resource
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from sqlalchemy import func, select
|
||||
from werkzeug.exceptions import NotFound
|
||||
from werkzeug.exceptions import Forbidden, NotFound
|
||||
|
||||
import services
|
||||
from configs import dify_config
|
||||
from controllers.common.fields import SimpleResultDataResponse, SimpleResultResponse, VerificationTokenResponse
|
||||
from controllers.common.schema import register_enum_models, register_response_schema_models, register_schema_models
|
||||
@@ -22,30 +20,25 @@ from controllers.console.auth.error import (
|
||||
MemberNotInTenantError,
|
||||
NotOwnerError,
|
||||
OwnerTransferLimitError,
|
||||
OwnerTransferRateLimitExceededError,
|
||||
)
|
||||
from controllers.console.error import EmailSendIpLimitError, SeatsLimitExceeded, WorkspaceMembersLimitExceeded
|
||||
from controllers.console.flask_admission import console_account_admission
|
||||
from controllers.console.workspace.error import InvalidMemberRoleError
|
||||
from controllers.console.wraps import (
|
||||
account_initialization_required,
|
||||
is_allow_transfer_owner,
|
||||
setup_required,
|
||||
with_current_user,
|
||||
)
|
||||
from enums import DeploymentEdition
|
||||
from enums.account import TenantAccountRole
|
||||
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
|
||||
from fields.member_fields import AccountWithRoleListResponse, AccountWithRoleResponse
|
||||
from libs.helper import dump_response, extract_remote_ip
|
||||
from libs.login import login_required
|
||||
from machinery.context import RequestContext
|
||||
from models.account import Account, TenantAccountJoin, TenantAccountRole
|
||||
from services.account_service import AccountService, RegisterService, TenantService
|
||||
from services.errors.account import AccountAlreadyInTenantError
|
||||
from services.feature_service import FeatureService
|
||||
from services.system_feature_service import SystemFeatureService
|
||||
from services.account_errors import AccountNotFoundError
|
||||
from services.errors import workspace as workspace_errors
|
||||
from services.errors.base import NoPermissionError
|
||||
from services.errors.workspace import (
|
||||
InvalidWorkspaceMemberRoleError,
|
||||
WorkspaceApplicationError,
|
||||
WorkspaceInvitationQuotaError,
|
||||
)
|
||||
|
||||
|
||||
class MemberInvitePayload(BaseModel):
|
||||
@@ -142,62 +135,6 @@ register_response_schema_models(
|
||||
)
|
||||
|
||||
|
||||
def _is_role_enabled(role: TenantAccountRole | str) -> bool:
|
||||
if role != TenantAccountRole.DATASET_OPERATOR:
|
||||
return True
|
||||
return dify_config.DATASET_OPERATOR_ENABLED
|
||||
|
||||
|
||||
def _count_new_member_invites(tenant_id: str, emails: list[str]) -> tuple[int, int]:
|
||||
new_member_count = 0
|
||||
new_account_count = 0
|
||||
for email in emails:
|
||||
account = AccountService.get_account_by_email_with_case_fallback(email, session=db.session())
|
||||
if not account:
|
||||
new_member_count += 1
|
||||
new_account_count += 1
|
||||
continue
|
||||
|
||||
exists = db.session.scalar(
|
||||
select(TenantAccountJoin.id)
|
||||
.where(TenantAccountJoin.tenant_id == tenant_id, TenantAccountJoin.account_id == account.id)
|
||||
.limit(1)
|
||||
)
|
||||
if not exists:
|
||||
new_member_count += 1
|
||||
|
||||
return new_member_count, new_account_count
|
||||
|
||||
|
||||
def _count_current_members(tenant_id: str) -> int:
|
||||
return (
|
||||
db.session.scalar(select(func.count(TenantAccountJoin.id)).where(TenantAccountJoin.tenant_id == tenant_id)) or 0
|
||||
)
|
||||
|
||||
|
||||
def _check_member_invite_limits(tenant_id: str, new_member_count: int, new_account_count: int) -> None:
|
||||
if new_member_count <= 0:
|
||||
return
|
||||
|
||||
features = FeatureService.get_features(tenant_id=tenant_id, exclude_vector_space=True)
|
||||
|
||||
if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.ENTERPRISE:
|
||||
workspace_members = features.workspace_members
|
||||
if workspace_members.enabled is True and not workspace_members.is_available(new_member_count):
|
||||
raise WorkspaceMembersLimitExceeded()
|
||||
if new_account_count > 0:
|
||||
seats = SystemFeatureService.get_license().seats
|
||||
if not seats.is_available(new_account_count):
|
||||
raise SeatsLimitExceeded()
|
||||
return
|
||||
|
||||
if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD:
|
||||
members = features.members
|
||||
current_member_count = _count_current_members(tenant_id)
|
||||
if 0 < members.limit < current_member_count + new_member_count:
|
||||
raise WorkspaceMembersLimitExceeded()
|
||||
|
||||
|
||||
@console_ns.route("/workspaces/current/members")
|
||||
class MemberListApi(Resource):
|
||||
"""List all members of current tenant."""
|
||||
@@ -205,7 +142,7 @@ class MemberListApi(Resource):
|
||||
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[AccountWithRoleListResponse.__name__])
|
||||
@console_account_admission()
|
||||
def get(self, request_context: RequestContext):
|
||||
members = application_services().workspace_member_queries.list_current(request_context)
|
||||
members = application_services().workspaces.member_queries.list_current(request_context)
|
||||
serialized_members = [
|
||||
{
|
||||
"id": member.id,
|
||||
@@ -235,123 +172,68 @@ class MemberInviteEmailApi(Resource):
|
||||
"Invalid role or workspace member limit exceeded",
|
||||
console_ns.models[MemberInviteErrorResponse.__name__],
|
||||
)
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_current_user
|
||||
def post(self, current_user: Account):
|
||||
payload = console_ns.payload or {}
|
||||
args = MemberInvitePayload.model_validate(payload)
|
||||
|
||||
invitee_emails = args.emails
|
||||
invitee_role = args.role
|
||||
interface_language = args.language
|
||||
if not dify_config.RBAC_ENABLED:
|
||||
if not TenantAccountRole.is_valid_role(invitee_role):
|
||||
raise InvalidMemberRoleError()
|
||||
if not TenantAccountRole.is_non_owner_role(TenantAccountRole(invitee_role)):
|
||||
raise InvalidMemberRoleError()
|
||||
inviter = current_user
|
||||
if not inviter.current_tenant:
|
||||
raise ValueError("No current tenant")
|
||||
if not _is_role_enabled(invitee_role):
|
||||
raise InvalidMemberRoleError()
|
||||
|
||||
# Check workspace permission for member invitations
|
||||
from libs.workspace_permission import check_workspace_member_invite_permission
|
||||
|
||||
check_workspace_member_invite_permission(inviter.current_tenant.id)
|
||||
|
||||
invitation_results: list[MemberInviteResultResponse] = []
|
||||
console_web_url = dify_config.CONSOLE_WEB_URL
|
||||
|
||||
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,
|
||||
}:
|
||||
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)
|
||||
|
||||
for invitee_email in invitee_emails:
|
||||
try:
|
||||
if not inviter.current_tenant:
|
||||
raise ValueError("No current tenant")
|
||||
token = RegisterService.invite_new_member(
|
||||
tenant=inviter.current_tenant,
|
||||
email=invitee_email,
|
||||
language=interface_language,
|
||||
role=invitee_role,
|
||||
inviter=inviter,
|
||||
session=db.session(),
|
||||
@console_account_admission()
|
||||
def post(self, request_context: RequestContext):
|
||||
args = MemberInvitePayload.model_validate(console_ns.payload or {})
|
||||
try:
|
||||
results = application_services().workspaces.invitations.invite_many(
|
||||
request_context,
|
||||
emails=args.emails,
|
||||
language=args.language,
|
||||
role=args.role,
|
||||
)
|
||||
except InvalidWorkspaceMemberRoleError:
|
||||
raise InvalidMemberRoleError() from None
|
||||
except NoPermissionError as error:
|
||||
raise Forbidden(str(error)) from error
|
||||
except WorkspaceInvitationQuotaError as error:
|
||||
if error.seats:
|
||||
raise SeatsLimitExceeded() from error
|
||||
raise WorkspaceMembersLimitExceeded() from error
|
||||
serialized: list[MemberInviteResultResponse] = []
|
||||
for result in results:
|
||||
if result.status == "success":
|
||||
email = parse.quote(result.email)
|
||||
serialized.append(
|
||||
MemberInviteSuccessResponse(
|
||||
status="success",
|
||||
email=result.email,
|
||||
url=f"{dify_config.CONSOLE_WEB_URL}/activate?email={email}&token={result.token}",
|
||||
)
|
||||
encoded_invitee_email = parse.quote(invitee_email)
|
||||
invitation_results.append(
|
||||
MemberInviteSuccessResponse(
|
||||
status="success",
|
||||
email=invitee_email,
|
||||
url=f"{console_web_url}/activate?email={encoded_invitee_email}&token={token}",
|
||||
)
|
||||
)
|
||||
elif result.status == "already_member":
|
||||
serialized.append(
|
||||
MemberInviteAlreadyMemberResponse(
|
||||
status="already_member", email=result.email, message=result.message or ""
|
||||
)
|
||||
except AccountAlreadyInTenantError:
|
||||
invitation_results.append(
|
||||
MemberInviteAlreadyMemberResponse(
|
||||
status="already_member",
|
||||
email=invitee_email,
|
||||
message="Account already in workspace.",
|
||||
)
|
||||
)
|
||||
except Exception as e:
|
||||
invitation_results.append(
|
||||
MemberInviteFailedResponse(status="failed", email=invitee_email, message=str(e))
|
||||
)
|
||||
|
||||
return (
|
||||
dump_response(
|
||||
MemberInviteResponse,
|
||||
{
|
||||
"result": "success",
|
||||
"invitation_results": invitation_results,
|
||||
"tenant_id": inviter.current_tenant.id if inviter.current_tenant else "",
|
||||
},
|
||||
),
|
||||
HTTPStatus.CREATED,
|
||||
)
|
||||
)
|
||||
else:
|
||||
serialized.append(
|
||||
MemberInviteFailedResponse(status="failed", email=result.email, message=result.message or "")
|
||||
)
|
||||
return dump_response(
|
||||
MemberInviteResponse,
|
||||
{"result": "success", "invitation_results": serialized, "tenant_id": request_context.active_workspace_id},
|
||||
), HTTPStatus.CREATED
|
||||
|
||||
|
||||
@console_ns.route("/workspaces/current/members/<uuid:member_id>")
|
||||
class MemberCancelInviteApi(Resource):
|
||||
"""Cancel an invitation by member id."""
|
||||
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[MemberActionResponse.__name__])
|
||||
@with_current_user
|
||||
def delete(self, current_user: Account, member_id: UUID):
|
||||
if not current_user.current_tenant:
|
||||
raise ValueError("No current tenant")
|
||||
member = db.session.get(Account, str(member_id))
|
||||
if member is None:
|
||||
abort(HTTPStatus.NOT_FOUND)
|
||||
else:
|
||||
try:
|
||||
TenantService.remove_member_from_tenant(
|
||||
current_user.current_tenant, member, current_user, session=db.session()
|
||||
)
|
||||
except services.errors.account.CannotOperateSelfError as e:
|
||||
return {"code": "cannot-operate-self", "message": str(e)}, HTTPStatus.BAD_REQUEST
|
||||
except services.errors.account.NoPermissionError as e:
|
||||
return {"code": "forbidden", "message": str(e)}, HTTPStatus.FORBIDDEN
|
||||
except services.errors.account.MemberNotInTenantError as e:
|
||||
return {"code": "member-not-found", "message": str(e)}, HTTPStatus.NOT_FOUND
|
||||
except Exception as e:
|
||||
raise ValueError(str(e))
|
||||
@console_account_admission()
|
||||
def delete(self, request_context: RequestContext, member_id: UUID):
|
||||
try:
|
||||
application_services().workspaces.members.remove(
|
||||
request_context.active_workspace_id, str(member_id), request_context.account_id
|
||||
)
|
||||
except (WorkspaceApplicationError, AccountNotFoundError, NoPermissionError) as error:
|
||||
return _member_mutation_error_response(error)
|
||||
|
||||
return MemberActionResponse(
|
||||
result="success",
|
||||
tenant_id=current_user.current_tenant.id if current_user.current_tenant else "",
|
||||
tenant_id=request_context.active_workspace_id,
|
||||
).model_dump(mode="json"), HTTPStatus.OK
|
||||
|
||||
|
||||
@@ -361,40 +243,16 @@ class MemberUpdateRoleApi(Resource):
|
||||
|
||||
@console_ns.expect(console_ns.models[MemberRoleUpdatePayload.__name__])
|
||||
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[SimpleResultResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_current_user
|
||||
def put(self, current_user: Account, member_id: UUID):
|
||||
@console_account_admission()
|
||||
def put(self, request_context: RequestContext, member_id: UUID):
|
||||
payload = console_ns.payload or {}
|
||||
args = MemberRoleUpdatePayload.model_validate(payload)
|
||||
new_role = args.role
|
||||
|
||||
if not TenantAccountRole.is_valid_role(new_role):
|
||||
return {"code": "invalid-role", "message": "Invalid role"}, HTTPStatus.BAD_REQUEST
|
||||
if not current_user.current_tenant:
|
||||
raise ValueError("No current tenant")
|
||||
if not _is_role_enabled(new_role):
|
||||
return {"code": "invalid-role", "message": "Invalid role"}, HTTPStatus.BAD_REQUEST
|
||||
member = db.session.get(Account, str(member_id))
|
||||
if not member:
|
||||
abort(HTTPStatus.NOT_FOUND)
|
||||
|
||||
try:
|
||||
assert member is not None, "Member not found"
|
||||
TenantService.update_member_role(
|
||||
current_user.current_tenant, member, new_role, current_user, session=db.session()
|
||||
application_services().workspaces.members.update_role(
|
||||
request_context.active_workspace_id, str(member_id), args.role, request_context.account_id
|
||||
)
|
||||
except services.errors.account.CannotOperateSelfError as e:
|
||||
return {"code": "cannot-operate-self", "message": str(e)}, HTTPStatus.BAD_REQUEST
|
||||
except services.errors.account.NoPermissionError as e:
|
||||
return {"code": "forbidden", "message": str(e)}, HTTPStatus.FORBIDDEN
|
||||
except services.errors.account.MemberNotInTenantError as e:
|
||||
return {"code": "member-not-found", "message": str(e)}, HTTPStatus.NOT_FOUND
|
||||
except services.errors.account.RoleAlreadyAssignedError as e:
|
||||
return {"code": "role-already-assigned", "message": str(e)}, HTTPStatus.BAD_REQUEST
|
||||
except Exception as e:
|
||||
raise ValueError(str(e))
|
||||
except (WorkspaceApplicationError, AccountNotFoundError, NoPermissionError) as error:
|
||||
return _member_mutation_error_response(error)
|
||||
|
||||
return SimpleResultResponse(result="success").model_dump(mode="json")
|
||||
|
||||
@@ -403,15 +261,12 @@ class MemberUpdateRoleApi(Resource):
|
||||
class DatasetOperatorMemberListApi(Resource):
|
||||
"""List all members of current tenant."""
|
||||
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[AccountWithRoleListResponse.__name__])
|
||||
@with_current_user
|
||||
def get(self, current_user: Account):
|
||||
if not current_user.current_tenant:
|
||||
raise ValueError("No current tenant")
|
||||
members = TenantService.get_dataset_operator_members(current_user.current_tenant, session=db.session())
|
||||
@console_account_admission()
|
||||
def get(self, request_context: RequestContext):
|
||||
members = application_services().workspaces.member_queries.list_members(
|
||||
request_context.active_workspace_id, dataset_operators_only=True
|
||||
)
|
||||
return dump_response(AccountWithRoleListResponse, {"accounts": members}), HTTPStatus.OK
|
||||
|
||||
|
||||
@@ -421,37 +276,17 @@ class SendOwnerTransferEmailApi(Resource):
|
||||
|
||||
@console_ns.expect(console_ns.models[OwnerTransferEmailPayload.__name__])
|
||||
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[SimpleResultDataResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@is_allow_transfer_owner
|
||||
@with_current_user
|
||||
def post(self, current_user: Account):
|
||||
payload = console_ns.payload or {}
|
||||
args = OwnerTransferEmailPayload.model_validate(payload)
|
||||
ip_address = extract_remote_ip(request)
|
||||
if AccountService.is_email_send_ip_limit(ip_address):
|
||||
raise EmailSendIpLimitError()
|
||||
# check if the current user is the owner of the workspace
|
||||
if not current_user.current_tenant:
|
||||
raise ValueError("No current tenant")
|
||||
if not TenantService.is_owner(current_user, current_user.current_tenant, session=db.session()):
|
||||
raise NotOwnerError()
|
||||
|
||||
if args.language is not None and args.language == "zh-Hans":
|
||||
language = "zh-Hans"
|
||||
else:
|
||||
language = "en-US"
|
||||
|
||||
email = current_user.email
|
||||
|
||||
token = AccountService.send_owner_transfer_email(
|
||||
account=current_user,
|
||||
email=email,
|
||||
language=language,
|
||||
workspace_name=current_user.current_tenant.name if current_user.current_tenant else "",
|
||||
)
|
||||
|
||||
@console_account_admission(require_owner_transfer_enabled=True)
|
||||
def post(self, request_context: RequestContext):
|
||||
args = OwnerTransferEmailPayload.model_validate(console_ns.payload or {})
|
||||
try:
|
||||
token = application_services().workspaces.owner_transfer.send_code(
|
||||
request_context,
|
||||
ip_address=extract_remote_ip(request),
|
||||
language=args.language,
|
||||
)
|
||||
except (WorkspaceApplicationError, AccountNotFoundError, NoPermissionError) as error:
|
||||
_raise_owner_transfer_error(error)
|
||||
return SimpleResultDataResponse(result="success", data=token).model_dump(mode="json")
|
||||
|
||||
|
||||
@@ -459,107 +294,73 @@ class SendOwnerTransferEmailApi(Resource):
|
||||
class OwnerTransferCheckApi(Resource):
|
||||
@console_ns.expect(console_ns.models[OwnerTransferCheckPayload.__name__])
|
||||
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[VerificationTokenResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@is_allow_transfer_owner
|
||||
@with_current_user
|
||||
def post(self, current_user: Account):
|
||||
payload = console_ns.payload or {}
|
||||
args = OwnerTransferCheckPayload.model_validate(payload)
|
||||
# check if the current user is the owner of the workspace
|
||||
if not current_user.current_tenant:
|
||||
raise ValueError("No current tenant")
|
||||
if not TenantService.is_owner(current_user, current_user.current_tenant, session=db.session()):
|
||||
raise NotOwnerError()
|
||||
|
||||
user_email = current_user.email
|
||||
|
||||
is_owner_transfer_error_rate_limit = AccountService.is_owner_transfer_error_rate_limit(user_email)
|
||||
if is_owner_transfer_error_rate_limit:
|
||||
raise OwnerTransferLimitError()
|
||||
|
||||
token_data = AccountService.get_owner_transfer_data(args.token)
|
||||
if token_data is None:
|
||||
raise InvalidTokenError()
|
||||
|
||||
if user_email != token_data.get("email"):
|
||||
raise InvalidEmailError()
|
||||
|
||||
if args.code != token_data.get("code"):
|
||||
AccountService.add_owner_transfer_error_rate_limit(user_email)
|
||||
raise EmailCodeError()
|
||||
|
||||
# Verified, revoke the first token
|
||||
AccountService.revoke_owner_transfer_token(args.token)
|
||||
|
||||
# Refresh token data by generating a new token
|
||||
_, new_token = AccountService.generate_owner_transfer_token(user_email, code=args.code, additional_data={})
|
||||
|
||||
AccountService.reset_owner_transfer_error_rate_limit(user_email)
|
||||
return VerificationTokenResponse(is_valid=True, email=user_email, token=new_token).model_dump(mode="json")
|
||||
@console_account_admission(require_owner_transfer_enabled=True)
|
||||
def post(self, request_context: RequestContext):
|
||||
args = OwnerTransferCheckPayload.model_validate(console_ns.payload or {})
|
||||
try:
|
||||
email, token = application_services().workspaces.owner_transfer.verify_code(
|
||||
request_context, token=args.token, code=args.code
|
||||
)
|
||||
except (WorkspaceApplicationError, AccountNotFoundError, NoPermissionError) as error:
|
||||
_raise_owner_transfer_error(error)
|
||||
return VerificationTokenResponse(is_valid=True, email=email, token=token).model_dump(mode="json")
|
||||
|
||||
|
||||
@console_ns.route("/workspaces/current/members/<uuid:member_id>/owner-transfer")
|
||||
class OwnerTransfer(Resource):
|
||||
@console_ns.expect(console_ns.models[OwnerTransferPayload.__name__])
|
||||
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[SimpleResultResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@is_allow_transfer_owner
|
||||
@with_current_user
|
||||
def post(self, current_user: Account, member_id: UUID):
|
||||
payload = console_ns.payload or {}
|
||||
args = OwnerTransferPayload.model_validate(payload)
|
||||
|
||||
# check if the current user is the owner of the workspace
|
||||
if not current_user.current_tenant:
|
||||
raise ValueError("No current tenant")
|
||||
if not TenantService.is_owner(current_user, current_user.current_tenant, session=db.session()):
|
||||
raise NotOwnerError()
|
||||
|
||||
if current_user.id == str(member_id):
|
||||
raise CannotTransferOwnerToSelfError()
|
||||
|
||||
transfer_token_data = AccountService.get_owner_transfer_data(args.token)
|
||||
if not transfer_token_data:
|
||||
raise InvalidTokenError()
|
||||
|
||||
if transfer_token_data.get("email") != current_user.email:
|
||||
raise InvalidEmailError()
|
||||
|
||||
AccountService.revoke_owner_transfer_token(args.token)
|
||||
|
||||
member = db.session.get(Account, str(member_id))
|
||||
if not member:
|
||||
raise NotFound()
|
||||
|
||||
if not current_user.current_tenant:
|
||||
raise ValueError("No current tenant")
|
||||
if not TenantService.is_member(member, current_user.current_tenant, session=db.session()):
|
||||
raise MemberNotInTenantError()
|
||||
|
||||
@console_account_admission(require_owner_transfer_enabled=True)
|
||||
def post(self, request_context: RequestContext, member_id: UUID):
|
||||
args = OwnerTransferPayload.model_validate(console_ns.payload or {})
|
||||
try:
|
||||
assert member is not None, "Member not found"
|
||||
TenantService.update_member_role(
|
||||
current_user.current_tenant, member, "owner", current_user, session=db.session()
|
||||
application_services().workspaces.owner_transfer.transfer(
|
||||
request_context, member_id=str(member_id), token=args.token
|
||||
)
|
||||
|
||||
AccountService.send_new_owner_transfer_notify_email(
|
||||
account=member,
|
||||
email=member.email,
|
||||
workspace_name=current_user.current_tenant.name if current_user.current_tenant else "",
|
||||
)
|
||||
|
||||
AccountService.send_old_owner_transfer_notify_email(
|
||||
account=current_user,
|
||||
email=current_user.email,
|
||||
workspace_name=current_user.current_tenant.name if current_user.current_tenant else "",
|
||||
new_owner_email=member.email,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
raise ValueError(str(e))
|
||||
|
||||
except (WorkspaceApplicationError, AccountNotFoundError, NoPermissionError) as error:
|
||||
_raise_owner_transfer_error(error)
|
||||
return SimpleResultResponse(result="success").model_dump(mode="json")
|
||||
|
||||
|
||||
def _member_mutation_error_response(
|
||||
error: WorkspaceApplicationError | AccountNotFoundError | NoPermissionError,
|
||||
) -> tuple[dict[str, str], HTTPStatus]:
|
||||
if isinstance(error, (AccountNotFoundError, workspace_errors.WorkspaceNotFoundError)):
|
||||
raise NotFound() from error
|
||||
if isinstance(error, InvalidWorkspaceMemberRoleError):
|
||||
return {"code": "invalid-role", "message": "Invalid role"}, HTTPStatus.BAD_REQUEST
|
||||
if isinstance(error, workspace_errors.CannotOperateSelfError):
|
||||
return {"code": "cannot-operate-self", "message": str(error)}, HTTPStatus.BAD_REQUEST
|
||||
if isinstance(error, NoPermissionError):
|
||||
return {"code": "forbidden", "message": str(error)}, HTTPStatus.FORBIDDEN
|
||||
if isinstance(error, workspace_errors.MemberNotInTenantError):
|
||||
return {"code": "member-not-found", "message": str(error)}, HTTPStatus.NOT_FOUND
|
||||
if isinstance(error, workspace_errors.RoleAlreadyAssignedError):
|
||||
return {"code": "role-already-assigned", "message": str(error)}, HTTPStatus.BAD_REQUEST
|
||||
raise error
|
||||
|
||||
|
||||
def _raise_owner_transfer_error(
|
||||
error: WorkspaceApplicationError | AccountNotFoundError | NoPermissionError,
|
||||
) -> NoReturn:
|
||||
if isinstance(error, NoPermissionError):
|
||||
raise NotOwnerError() from error
|
||||
if isinstance(error, (AccountNotFoundError, workspace_errors.WorkspaceNotFoundError)):
|
||||
raise NotFound() from error
|
||||
if isinstance(error, workspace_errors.OwnerTransferSendIPLimitedError):
|
||||
raise EmailSendIpLimitError() from error
|
||||
if isinstance(error, workspace_errors.OwnerTransferSendRateLimitError):
|
||||
raise OwnerTransferRateLimitExceededError(error.retry_after_minutes) from error
|
||||
if isinstance(error, workspace_errors.OwnerTransferVerificationLimitError):
|
||||
raise OwnerTransferLimitError() from error
|
||||
if isinstance(error, workspace_errors.InvalidOwnerTransferTokenError):
|
||||
raise InvalidTokenError() from error
|
||||
if isinstance(error, workspace_errors.InvalidOwnerTransferEmailError):
|
||||
raise InvalidEmailError() from error
|
||||
if isinstance(error, workspace_errors.InvalidOwnerTransferCodeError):
|
||||
raise EmailCodeError() from error
|
||||
if isinstance(error, workspace_errors.CannotOperateSelfError):
|
||||
raise CannotTransferOwnerToSelfError() from error
|
||||
if isinstance(error, workspace_errors.MemberNotInTenantError):
|
||||
raise MemberNotInTenantError() from error
|
||||
raise error
|
||||
|
||||
@@ -4,12 +4,10 @@ from typing import Any, Literal
|
||||
from flask import send_file
|
||||
from flask_restx import Resource
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from controllers.common.fields import SimpleResultResponse, ValidationResultResponse
|
||||
from controllers.common.rbac import RBACCheck, Workspace
|
||||
from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models
|
||||
from controllers.common.session import with_session
|
||||
from controllers.console import console_ns
|
||||
from controllers.console.wraps import (
|
||||
RBACPermission,
|
||||
@@ -21,6 +19,7 @@ from controllers.console.wraps import (
|
||||
with_current_tenant_id,
|
||||
with_current_user,
|
||||
)
|
||||
from extensions.ext_application_services import application_services
|
||||
from fields.base import ResponseModel
|
||||
from graphon.model_runtime.entities.model_entities import ModelType
|
||||
from graphon.model_runtime.errors.validate import CredentialsValidateFailedError
|
||||
@@ -33,8 +32,7 @@ from services.entities.model_provider_entities import (
|
||||
ModelProviderSummaryResponse,
|
||||
ProviderResponse,
|
||||
)
|
||||
from services.model_provider_service import ModelProviderService
|
||||
from services.workspace_service import WorkspaceService
|
||||
from services.model_provider.service import ModelProviderService
|
||||
|
||||
|
||||
class ParserModelList(BaseModel):
|
||||
@@ -191,9 +189,8 @@ class ModelProviderCreditsApi(Resource):
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_current_tenant_id
|
||||
@with_session(write=False)
|
||||
def get(self, session: Session, tenant_id: str):
|
||||
credit_pool = WorkspaceService.get_effective_credit_pool(tenant_id, session=session)
|
||||
def get(self, tenant_id: str):
|
||||
credit_pool = application_services().workspaces.management.get_effective_credit_pool(tenant_id)
|
||||
return dump_response(ModelProviderCreditsResponse, credit_pool)
|
||||
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ from controllers.console.wraps import (
|
||||
)
|
||||
from core.entities.model_entities import DefaultModelSetting
|
||||
from core.entities.provider_entities import CredentialConfiguration
|
||||
from extensions.ext_application_services import application_services
|
||||
from extensions.ext_database import db
|
||||
from fields.base import ResponseModel
|
||||
from graphon.model_runtime.entities.model_entities import ModelType, ParameterRule
|
||||
@@ -38,7 +39,7 @@ from services.entities.model_provider_entities import (
|
||||
ProviderWithModelsResponse,
|
||||
)
|
||||
from services.model_load_balancing_service import ModelLoadBalancingService
|
||||
from services.model_provider_service import ModelProviderService
|
||||
from services.model_provider.service import ModelProviderService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -376,6 +377,7 @@ class ModelProviderModelCredentialApi(Resource):
|
||||
tenant_id=tenant_id,
|
||||
provider=provider,
|
||||
user=user,
|
||||
credential_query=application_services().credential_queries,
|
||||
)
|
||||
else:
|
||||
available_credentials = model_provider_service.get_provider_model_available_credentials(
|
||||
|
||||
@@ -62,6 +62,7 @@ from core.tools.entities.tool_entities import (
|
||||
WorkflowToolParameterConfiguration,
|
||||
)
|
||||
from enums import DeploymentEdition
|
||||
from extensions.ext_application_services import application_services
|
||||
from extensions.ext_database import db
|
||||
from fields.base import ResponseModel
|
||||
from libs.helper import alphanumeric, dump_response, uuid_value
|
||||
@@ -664,9 +665,9 @@ class ToolBuiltinProviderGetCredentialsApi(Resource):
|
||||
BuiltinToolManageService.get_builtin_tool_provider_credentials(
|
||||
tenant_id=tenant_id,
|
||||
provider_name=provider,
|
||||
session=db.session(),
|
||||
user=user,
|
||||
include_credential_ids=query.include_credential_ids or None,
|
||||
credential_query=application_services().credential_queries,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -1363,9 +1364,9 @@ class ToolBuiltinProviderGetCredentialInfoApi(Resource):
|
||||
BuiltinToolManageService.get_builtin_tool_provider_credential_info(
|
||||
tenant_id=tenant_id,
|
||||
provider=provider,
|
||||
session=db.session(),
|
||||
user=user,
|
||||
include_credential_ids=query.include_credential_ids or None,
|
||||
credential_query=application_services().credential_queries,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ from core.trigger.entities.api_entities import (
|
||||
)
|
||||
from core.trigger.entities.entities import RequestLog, SubscriptionBuilderUpdater
|
||||
from core.trigger.trigger_manager import TriggerManager
|
||||
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
|
||||
@@ -213,6 +214,7 @@ class TriggerSubscriptionListApi(Resource):
|
||||
tenant_id=tenant_id,
|
||||
provider_id=TriggerProviderID(provider),
|
||||
user=user,
|
||||
credential_query=application_services().credential_queries,
|
||||
),
|
||||
)
|
||||
except ValueError as e:
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user