Compare commits

..

49 Commits

Author SHA1 Message Date
GareArc
3357d925a9 refactor(openapi): fold list into get and remove/revoke into delete
Server side of 01a4939731 on feat/difyctl-v2: get lists, describe shows one
item, delete removes one; stream.run.event becomes get.run.event.
2026-09-23 17:13:59 -07:00
GareArc
a010675ac4 refactor(openapi): verb-first op ids
Catalog op ids lead with the verb, resources singular: run.console_app.workflow,
invite.workspace.member, set.workspace.member_role. Server side of ff0283417e on
feat/difyctl-v2.
2026-09-23 16:42:23 -07:00
GareArc
680cd1d0a9 fix(openapi): send the full node event payloads to openapi callers 2026-09-22 21:09:20 -07:00
GareArc
ad917cb08b chore(api): add jsonschema stubs for the catalog example test
The strict pyrefly project for unit tests fails on an untyped import; the
other tests that import jsonschema sit on its exclusion list.
2026-09-21 22:33:45 -07:00
GareArc
aabf3c0fad feat(openapi): declare examples on every op and ship them in the catalog
Each route names one or more complete inputs on `endpoint(examples=...)`
and the catalog carries them as `{title, input}` next to the schema. A
catalog test checks every example against its op's input schema, with
`workspace_id` left to the client's pin, and that no op ships without one.
2026-09-21 22:33:45 -07:00
GareArc
a92270f312 refactor(openapi): drop tags from the catalog
The tag was always the first segment of the op id, so it told a client
nothing the id does not.
2026-09-21 22:33:45 -07:00
GareArc
6a1a61e3db refactor(openapi): split the catalog fields off EndpointSpec
`EndpointSpec` carried the auth policy the router runs and the catalog
fields the router never reads, so it changed for two unrelated reasons.
`CatalogMeta` now holds op, kind, summary, the input models and the
internal/deprecated flags; `EndpointSpec` keeps requirements and the
edition gate and carries a `CatalogMeta`, so a route is still declared in
one place.
2026-09-21 22:33:45 -07:00
GareArc
dedc2e6da0 fix(openapi): end the read transaction before uploading to object storage
The router opens one session for the loaders and the handler, so by the
time a run, a form submit or the upload route wrote a file to storage the
loaders' transaction was still open, against api/AGENTS.md. Those three
sites now call `end_read_transaction` first; the router's sessions keep
loaded rows readable after commit, and the service that runs next begins
its own transaction.
2026-09-21 22:33:44 -07:00
GareArc
bcecf01a4c refactor(openapi): fill the next-page hint in its own decorator
`accepts(..., op=)` validated the input and also rewrote the response,
which its name did not say. `paginated(op)` now owns the hint; `endpoint`
applies it to every `list` op, inside `accepts` so it still sees the
validated query and the path kwargs the hint echoes. `accepts` is input
only again.
2026-09-21 22:33:44 -07:00
GareArc
05bac6e270 docs(openapi): export file-bearing request bodies as multipart
Flask-RESTX documents every Pydantic body as application/json, so the
contract showed the file upload and the run payloads as JSON with binary
fields, while the wire form is multipart. The document now names both:
a body of only files is multipart alone, a run body is JSON or multipart
with its JSON-text parts marked in `encoding`. The rewrite hangs off a
`finish_document` hook on `ExternalApi` that `Swagger.as_dict` calls last,
so the live openapi.json, the spec generator and the tests agree.
2026-09-21 22:33:44 -07:00
GareArc
6c643b2e8f refactor(openapi): name and constant fixes from review
- `FileUploadRequest` becomes `FileUploadPayload`, the suffix the schema
  guide asks of request bodies and the one its siblings already use.
- The file mapping reads `FileTransferMethod.LOCAL_FILE` instead of the
  literal the test imports the enum to check.
- `index.py` routes the catalog through `CATALOG_ROUTE` instead of
  restating the path `_catalog.py` owns.
- `current_catalog()` replaces the three copies of
  `catalog_for(current_app._get_current_object())`.
2026-09-21 22:33:44 -07:00
GareArc
cc98efa894 refactor(openapi): share the page slice and build the run routes from one table
The workspace and member lists cut the same in-memory page in two places;
`PaginationEnvelope.page_of` now owns that. The four per-mode run Resources
differed only in route segment, op id, summary, payload, admitted modes and
which hint layers wrap the stream, so a `_RunRoute` table holds those and
`_run_api` builds each Resource from its row. The class names, routes and
op ids are unchanged.
2026-09-21 22:33:44 -07:00
GareArc
1925b9f393 fix(openapi): pass through SSE chunks the hint builders cannot read
`attach_stream_hints` parsed every `data:` frame that mentioned the wanted
event name and handed it to the builder unguarded, so a frame that was not
JSON, not an object, or a `message_end` without `conversation_id` raised
inside the live generator and cut the run off mid-stream. Such chunks now
reach the wire unchanged, with a warning in the log for the last case.
2026-09-21 22:33:44 -07:00
GareArc
5ee5b3116f fix(openapi): answer service ValueErrors with a fixed 400 message
`_translate_service_errors` forwarded `str(ex)` to the client, past the
sanitisation the rest of the surface applies; those messages name variable
ids and config keys. The 400 stays, the detail goes to the log.
2026-09-21 22:33:44 -07:00
GareArc
a590b08b96 fix(openapi): allow X-Dify-Catalog on cross-origin requests
The fingerprint header was exposed on responses but missing from the
/openapi/v1 CORS allow list, so a browser client on the allowlist could
never send it and every guarded route answered 412. The header name now
lives in `constants` next to the other X- headers, and the expose list,
the allow list and the catalog module read it from there.
2026-09-21 22:33:44 -07:00
GareArc
c0957631e0 refactor(openapi): delete the deprecated mode-agnostic :run route
The per-mode run ops are the only run surface. The frozen `:run` union no
longer kept the shipped v1 CLI working (its body shape had changed and the
catalog guard refuses requests without X-Dify-Catalog), so keeping it bought
nothing. `AppRunRequest`, the dispatch table and the mode handlers go with
it; the integration tests move to the per-mode routes, and the payload
tests that lived on the union now run against `ChatRunPayload`.
2026-09-21 22:33:43 -07:00
autofix-ci[bot]
eb9d32cbe9 [autofix.ci] apply automated fixes 2026-09-21 22:33:43 -07:00
GareArc
4d8e61826d fix(openapi): cap list limit at 100 and answer service ValueErrors with 400
The catalog advertised `limit` up to 200 for every list op while the app
service rejects anything over 100, so `ops describe` and the server
disagreed. Cap MAX_PAGE_LIMIT at 100, the tightest downstream bound.

The run handler let plain ValueError fall through to a detail-less 500.
Translate it to 400 with the service message, as the console and
service-api surfaces already do, so "Workflow not published" reaches the
caller.
2026-09-21 22:33:43 -07:00
GareArc
b33c84611d test(openapi): annotate the empty header dict for strict pyrefly 2026-09-21 22:33:43 -07:00
autofix-ci[bot]
be5a91584c [autofix.ci] apply automated fixes 2026-09-21 22:33:43 -07:00
GareArc
2b595e2601 feat(openapi): refuse a guarded request that does not name the current catalog
A request built from a stale or tampered local catalog could pick a route
the caller never meant to hit. Every guarded route now requires the
fingerprint of the catalog the request was built from in X-Dify-Catalog and
answers 412 catalog_stale before any handler runs when it is missing or
different. Fixed on both pipelines at Rank.FIRST so no endpoint can leave it
out; _catalog and _version stay unguarded so a client can always recover.
2026-09-21 22:33:43 -07:00
autofix-ci[bot]
2e6474a6e0 [autofix.ci] apply automated fixes 2026-09-21 22:33:43 -07:00
GareArc
a2e871edde refactor(openapi): drop guards on values only our own code supplies
The catalog, hint and run-route code checked things that only a wrong
endpoint declaration or a mis-serialised core event could break: the
duplicate op id and path placeholder checks (the catalog test already
asserts both), the recursive $ref stack (a RecursionError says the same),
the JSON and shape guards on SSE chunks the core frames itself, and the
ValidationError catches when reading an event back through the core's own
entity. Fold the deprecated route's arg builder into _generate_args and let
Hinted declare hints once.
2026-09-21 22:33:42 -07:00
GareArc
d103ea61f2 test(openapi): call the workspace list and upload handlers with their query and body in the integration suite 2026-09-21 22:33:42 -07:00
GareArc
7693cd490a test(openapi): annotate the new unit tests for strict pyrefly 2026-09-21 22:33:42 -07:00
GareArc
9ab668fca3 fix(cli): read the paginated workspace list 2026-09-21 22:33:42 -07:00
GareArc
9e1f258f8b refactor(openapi): read specs and handlers through one walker, without new getattr 2026-09-21 22:33:42 -07:00
autofix-ci[bot]
cf7004a603 [autofix.ci] apply automated fixes 2026-09-21 22:33:42 -07:00
GareArc
a04e56472b test(openapi): trim the per-mode run tests to core and indirect coverage
One rejection case per payload, the legacy handler tests re-pointed at the per-mode routes, matrix rows over a shared trait set, the catalog streaming set as a comprehension, and the redundant wire 422 row dropped. The private _ChatMessageEnd class loses its docstring.
2026-09-21 22:33:42 -07:00
GareArc
0b0247680e refactor(openapi): delete the difyctl version gate
The 426 gate bound a difyctl release to a Dify version through [tool.dify] min_difyctl_version. Under the catalog design the client is not version-bound; the catalog fingerprint is the compatibility signal. Deleted with it: its tests, the UPGRADE_REQUIRED error code, the pyproject block, the packaging tool config and the pyrefly skip entry. GET /openapi/v1/_version stays.
2026-09-21 22:33:42 -07:00
GareArc
05c843980d feat(openapi): one run op per app mode, the mode-agnostic :run deprecated
POST /apps/{app_id}/workflow:run, chat:run, advanced-chat:run and completion:run each take an exact extra=forbid body (WorkflowRunPayload, ChatRunPayload, AdvancedChatRunPayload, CompletionRunPayload) and carry their whole flow in post: mode check, args, generate, hints where a pause can happen, audit, response. Chat-family runs hint the next message on message_end. The legacy :run keeps its union body and dispatch and is marked deprecated in the catalog. Catalog, auth matrix and the guide follow.
2026-09-21 22:33:41 -07:00
GareArc
d4f27bc885 test(openapi): cut the catalog suite to core and indirect coverage
Merge test_catalog_coverage and test_catalog_quality into test_catalog, delete test_upload_part, and drop the unit tests that a wire test or a neighbouring file already covers. Test docstrings and comments go; module docstrings stay. New test lines fall from 1214 to 589.
2026-09-21 22:33:41 -07:00
GareArc
a5a9146969 refactor(openapi): compose hints in the owning handlers over shared page and hint models
Hints no longer know any op id: attach_stream_hints is generic and each route builds its own hint through op_of(view). PageQuery and Hinted replace the hint field and page constants; PaginationEnvelope and AppDslImportResponse extend Hinted. with_form_hints reads the pause event through HumanInputRequiredResponse and passes an unknown form input type through without hints.
2026-09-21 22:33:41 -07:00
GareArc
e3df047dc1 test(openapi): pin the run file wiring and the sse kind
The run handler's file merge was only ever reached with an empty app form, so
nothing covered a real file variable end to end: assert on the args the app
generator is handed instead.

A route that answers an event stream while declaring any other kind would be
read back as one JSON body by the CLI, so read the response models off the
decorated view and require Kind.SSE.
2026-09-21 22:33:41 -07:00
GareArc
fc3a69fdd3 refactor(openapi): tighten the catalog, multipart and hint edges
- `files` is where the file parts land, so a text part of that name is refused
  instead of being silently replaced; the part regex is built from the same
  constant rather than repeating the literal.
- The prefix the url-map scan and the fingerprint header key on now ends in a
  slash, so a neighbouring `/openapi/v1beta` path is not treated as this surface.
- A rule whose placeholders do not all survive the `{name}` substitution (a
  converter carrying arguments) raises instead of publishing raw werkzeug syntax.
- The edition gate moves onto EndpointSpec, so the router's 404 and the catalog's
  omission read one predicate.
- derive_bind returns the Bind values it already produced; the JSON is unchanged.
- The SSE hint pass parses only chunks that can carry a hint.
2026-09-21 22:33:41 -07:00
GareArc
7680592080 feat(openapi): declare file upload and run.events inputs on the endpoint contract
Both routes read their inputs by hand, so the catalog described neither: the
upload op advertised an empty bind table and run.events never mentioned
continue_on_pause or include_state_snapshot. Declare a FileUploadRequest body
and the existing WorkflowEventsQuery on @endpoint instead, and let the shared
validation layer answer the 422.

The upload part is therefore named files[file], like every other file part on
this surface, and its no-file and too-many-files checks become the body model's
required-field error.
2026-09-21 22:33:41 -07:00
GareArc
35446788ea fix(openapi): derive form file rows from the form definition
Submitting a human-input form treated every part as one single-file input, so a
file-list input refused its second part and a URL string in `inputs` never became
a remote_url mapping. The form already declares FileInputConfig and
FileListInputConfig, so read the rows off the definition the handler is holding
and let the run path's rules apply unchanged.
2026-09-21 22:33:41 -07:00
GareArc
cc32d1490a fix(openapi): carry the file type on materialised file mappings
build_from_mapping validates `mapping["type"] or FileType.CUSTOM` against the
file variable's upload config, and `custom` is refused by every normally
configured variable, so every file uploaded through /openapi/v1 was rejected at
run time with "File validation failed". Infer the type with the core's own
standardize_file_type: from the stored upload row for a local file, from the
URL path's extension for a remote one.

Also stop inventing a filename and a mimetype for a part that carries neither
(raise what the upload route raises) and reuse _input_schema's chat-family set
rather than keeping a second copy.
2026-09-21 22:33:40 -07:00
GareArc
e02691ec03 docs(openapi): describe catalog metadata on the endpoint contract 2026-09-21 22:33:40 -07:00
GareArc
4f1dd36b3e test(openapi): prove hints reach the wire through endpoint(), not just accepts 2026-09-21 22:33:40 -07:00
GareArc
ff5a5d13e6 feat(openapi): server-built hints for next page, pending import and paused forms 2026-09-21 22:33:40 -07:00
GareArc
4412e1d9f8 test(openapi): gate catalog schema quality 2026-09-21 22:33:40 -07:00
GareArc
50131644e2 feat(openapi): paginate workspace list with the shared envelope 2026-09-21 22:33:40 -07:00
GareArc
5a48390ce4 test(openapi): pin catalog completeness and the edition filter 2026-09-21 22:33:40 -07:00
GareArc
5254d5f20e feat(openapi): serve the op catalog and stamp its fingerprint on every response 2026-09-21 22:33:40 -07:00
GareArc
00c489dc00 fix(openapi): translate file-service errors and narrow file_rows_of's session type 2026-09-21 22:33:39 -07:00
GareArc
40e6268dda feat(openapi): upload and merge file parts into inputs in the openapi layer 2026-09-21 22:33:39 -07:00
GareArc
fae01a584d feat(openapi): UploadPart file type and multipart body parsing 2026-09-21 22:33:39 -07:00
GareArc
4f90b97957 feat(openapi): declare op id and response kind on every endpoint 2026-09-21 22:33:39 -07:00
6776 changed files with 244774 additions and 241044 deletions

View File

@@ -1,15 +1,93 @@
# Architecture review
# Rule Catalog — Architecture
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.
## Scope
- Covers: controller/service/core-domain/libs/model layering, dependency direction, responsibility placement, observability-friendly flow.
## Transport and domain boundaries
## Rules
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/`.
### 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"}
```
## Dependency direction
### 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
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.
## Business-agnostic libraries
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"
`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/`.
# 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)
```

View File

@@ -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). 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.
- 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).
- Example:
- Bad:
```python

View File

@@ -10,8 +10,13 @@ Read this document when a change involves application hotkeys, focus, dialogs, m
## Keyboard Commands
- 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.
- 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.
## Secondary Surfaces
@@ -23,5 +28,4 @@ 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

View File

@@ -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.
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.
Both frontend and backend environments are initialized when the container is started.
## GitHub Codespaces
@@ -19,6 +19,20 @@ 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
View File

@@ -9,6 +9,3 @@
# 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
View File

@@ -57,10 +57,13 @@
# 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

View File

@@ -15,7 +15,7 @@ runs:
run-install: 'false'
- name: Setup UV and Python
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
with:
enable-cache: true
python-version: '3.12'

View File

@@ -13,11 +13,11 @@ runs:
using: composite
steps:
- name: Setup pnpm and Node.js
uses: pnpm/setup@fbda4c85fc2e1e08721cd8763afea8f48d60f024 # v3.0.0
uses: pnpm/setup@703c52620218391530e48b9e8870d5c0082e1b9b # v2.1.0
with:
install: false
- name: Setup Vite+
uses: voidzero-dev/setup-vp@3754dd7dbdb32bd8f6d28b6043de13ad3a75f21f # v1.21.1
uses: voidzero-dev/setup-vp@9fd26ff49f9b5e6276c2b493da4454ad4527f7f4 # v1.20.0
with:
node-manager: false
package-manager: |

View File

@@ -1,104 +0,0 @@
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
})
}

View File

@@ -1,112 +0,0 @@
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)
})

View File

@@ -52,7 +52,7 @@ jobs:
uses: ./.github/actions/setup-web
- name: Setup UV and Python
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
with:
enable-cache: true
python-version: '3.12'
@@ -80,7 +80,7 @@ jobs:
if [[ "$WCAG_PAGE" == "all" ]]; then
vp run e2e:accessibility:${{ inputs.level }}
else
pnpm exec node ./scripts/run-cucumber.ts --full -- --tags "@axe and @wcag-${{ inputs.level }} and @wcag-page-$WCAG_PAGE"
pnpm exec tsx ./scripts/run-cucumber.ts --full -- --tags "@axe and @wcag-${{ inputs.level }} and @wcag-page-$WCAG_PAGE"
fi
- name: Upload accessibility Cucumber report

View File

@@ -23,7 +23,7 @@ jobs:
with:
persist-credentials: false
- name: Setup UV and Python
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.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@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.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=-p no:opik -p no:langsmith_plugin --cov=./api --cov-branch --import-mode=importlib' \
-o 'addopts=--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@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.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=-p no:opik -p no:langsmith_plugin --cov=./api --cov-branch --import-mode=importlib' \
-o 'addopts=--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@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.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=-p no:opik -p no:langsmith_plugin --cov=./api --cov-branch --import-mode=importlib' \
-o 'addopts=--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@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
with:
python-version: '3.12'
@@ -298,6 +298,13 @@ jobs:
set -euo pipefail
coverage_version=$(python3 -c 'import tomllib; print(next(p["version"] for p in tomllib.load(open("api/uv.lock", "rb"))["package"] if p["name"] == "coverage"))')
{
echo "### API Coverage"
echo ""
echo "Merged backend coverage report generated for Codecov project status."
echo ""
} >> "$GITHUB_STEP_SUMMARY"
mapfile -t unit_coverage < <(find coverage-data -type f -name 'coverage-unit-*' -print | sort)
mapfile -t controller_coverage < <(find coverage-data -type f -name 'coverage-controllers-*' -print | sort)
mapfile -t integration_coverage < <(find coverage-data -type f -name 'coverage-integration-*' -print | sort)
@@ -314,28 +321,21 @@ jobs:
exit 1
fi
report_file="$(mktemp)"
uvx --from "coverage==$coverage_version" coverage combine "${unit_coverage[@]}" "${controller_coverage[@]}" "${integration_coverage[@]}"
uvx --from "coverage==$coverage_version" coverage report --show-missing | tee "$report_file"
echo "Summary: \`$(tail -n 1 "$report_file")\`" >> "$GITHUB_STEP_SUMMARY"
{
echo ""
echo "<details><summary>Coverage report</summary>"
echo ""
echo '```'
cat "$report_file"
echo '```'
echo "</details>"
} >> "$GITHUB_STEP_SUMMARY"
uvx --from "coverage==$coverage_version" coverage xml -o coverage.xml
# Reuse the XML totals instead of analyzing every source file again for a text report.
python3 - <<'PY'
import os
import xml.etree.ElementTree as ET
coverage = ET.parse("coverage.xml").getroot()
with open(os.environ["GITHUB_STEP_SUMMARY"], "a") as summary:
summary.write("### API Coverage\n\n")
summary.write("Merged backend coverage report generated for Codecov project status.\n\n")
summary.write("| Metric | Covered | Total | Coverage |\n")
summary.write("| --- | ---: | ---: | ---: |\n")
for label, metric in (("Lines", "lines"), ("Branches", "branches")):
covered = int(coverage.attrib[f"{metric}-covered"])
total = int(coverage.attrib[f"{metric}-valid"])
percentage = f"{covered / total:.2%}" if total else "N/A"
summary.write(f"| {label} | {covered} | {total} | {percentage} |\n")
summary.write("\nSee Codecov for per-file coverage details.\n")
PY
- name: Report coverage
uses: codecov/codecov-action@303a32d7a59b442fa8d48b6a1cc6825c09c847a5 # v7.1.1
with:

View File

@@ -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@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
- name: Generate Docker Compose
if: github.event_name != 'merge_group' && steps.docker-compose-changes.outputs.any_changed == 'true'

View File

@@ -78,7 +78,7 @@ jobs:
ws2_app_id: ${{ steps.out.outputs.DIFY_E2E_WS2_APP_ID }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v4
with:
ref: ${{ inputs.cli_ref || github.ref }}
persist-credentials: false
@@ -87,7 +87,7 @@ jobs:
with:
bun-version: latest
- uses: pnpm/setup@fbda4c85fc2e1e08721cd8763afea8f48d60f024 # v3.0.0
- uses: pnpm/setup@703c52620218391530e48b9e8870d5c0082e1b9b # v2.1.0
with:
install: false
@@ -121,7 +121,7 @@ jobs:
shell: bash
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v4
with:
ref: ${{ inputs.cli_ref || github.ref }}
persist-credentials: false
@@ -166,7 +166,7 @@ jobs:
shell: bash
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v4
with:
ref: ${{ inputs.cli_ref || github.ref }}
persist-credentials: false
@@ -227,7 +227,7 @@ jobs:
shell: bash
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v4
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@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
uses: actions/upload-artifact@v7
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 # v7.0.1
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v4
with:
ref: ${{ inputs.cli_ref || github.ref }}
persist-credentials: false
@@ -341,7 +341,7 @@ jobs:
shell: bash
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v4
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@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
uses: actions/upload-artifact@v7
with:
name: e2e-last-${{ github.run_id }}
path: cli/test-results/

View File

@@ -5,7 +5,6 @@ on:
branches: [main]
paths:
- 'cli/**'
- 'skills/**'
- 'packages/contracts/generated/api/openapi/**'
workflow_dispatch:

View File

@@ -77,6 +77,11 @@ 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

View File

@@ -51,7 +51,7 @@ jobs:
- name: Run smoke against live dify
working-directory: ./cli
run: DIFY_SERVER=http://localhost:5001 bun scripts/run-smoke.ts
run: pnpm exec tsx scripts/run-smoke.ts --base-url http://localhost:5001
- name: Dump dify logs on failure
if: failure()

View File

@@ -19,7 +19,7 @@ jobs:
persist-credentials: false
- name: Setup UV and Python
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.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@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
with:
enable-cache: true
python-version: '3.12'

View File

@@ -31,7 +31,7 @@ jobs:
persist-credentials: false
- name: Setup UV and Python
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
with:
enable-cache: true
python-version: ${{ matrix.python-version }}

View File

@@ -21,10 +21,9 @@ 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: ubuntu-24.04
runs-on: depot-ubuntu-24.04
outputs:
should_skip: ${{ steps.skip_check.outputs.should_skip || 'false' }}
steps:
@@ -38,7 +37,7 @@ jobs:
# Check which paths were changed to determine which tests to run
check-changes:
name: Check Changed Files
runs-on: ubuntu-24.04
runs-on: depot-ubuntu-24.04
outputs:
api-changed: ${{ steps.changes.outputs.api }}
cli-changed: ${{ steps.changes.outputs.cli }}
@@ -63,13 +62,10 @@ 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'
@@ -82,7 +78,6 @@ jobs:
- 'docker/volumes/sandbox/conf/**'
cli:
- 'cli/**'
- 'skills/**'
- 'packages/contracts/**'
- 'packages/tsconfig/**'
- 'package.json'
@@ -164,14 +159,10 @@ 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:
@@ -188,22 +179,17 @@ jobs:
- 'pnpm-workspace.yaml'
- 'knip.config.ts'
- 'scripts/check-web-production-unused-after-knip-fix.mjs'
- '.npmrc'
- '.github/workflows/main-ci.yml'
- '.github/workflows/web-bundle-size.yml'
- '.github/workflows/style.yml'
- '.github/actions/setup-web/**'
ts-common-style:
- 'web/**'
- 'cli/**'
- 'skills/**'
- 'e2e/**'
- 'sdks/nodejs-client/**'
- 'packages/**'
- 'package.json'
- 'pnpm-lock.yaml'
- 'pnpm-workspace.yaml'
- 'tsconfig.json'
- 'vite.config.ts'
- 'lint.config.ts'
- 'eslint.config.mjs'
@@ -222,8 +208,7 @@ jobs:
- '.editorconfig'
- '.vite-hooks/**'
# Finalizers report unchanged paths directly, avoiding separate skip-only jobs.
# Keep each required check name stable and fail closed if classification fails.
# Run tests in parallel while always emitting stable required checks.
api-tests-run:
name: Run API Tests
needs:
@@ -233,6 +218,17 @@ 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,26 +236,21 @@ jobs:
- pre_job
- check-changes
- api-tests-run
runs-on: ubuntu-24.04
- api-tests-skip
runs-on: depot-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 }}
PRE_JOB_RESULT: ${{ needs.pre_job.result }}
CHANGES_RESULT: ${{ needs.check-changes.result }}
SKIP_RESULT: ${{ needs.api-tests-skip.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."
@@ -270,12 +261,12 @@ jobs:
exit 1
fi
if [[ "$TESTS_CHANGED" == 'false' && "$RUN_RESULT" == 'skipped' ]]; then
if [[ "$SKIP_RESULT" == 'success' ]]; then
echo "API tests were skipped because no API-related files changed."
exit 0
fi
echo "Unexpected test state: changed=$TESTS_CHANGED, result=$RUN_RESULT" >&2
echo "API tests were not required, but the skip job finished with result: $SKIP_RESULT" >&2
exit 1
cli-tests-run:
@@ -287,6 +278,17 @@ 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() }}
@@ -294,26 +296,21 @@ jobs:
- pre_job
- check-changes
- cli-tests-run
runs-on: ubuntu-24.04
- cli-tests-skip
runs-on: depot-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 }}
PRE_JOB_RESULT: ${{ needs.pre_job.result }}
CHANGES_RESULT: ${{ needs.check-changes.result }}
SKIP_RESULT: ${{ needs.cli-tests-skip.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."
@@ -324,12 +321,12 @@ jobs:
exit 1
fi
if [[ "$TESTS_CHANGED" == 'false' && "$RUN_RESULT" == 'skipped' ]]; then
if [[ "$SKIP_RESULT" == 'success' ]]; then
echo "CLI tests were skipped because no CLI-related files changed."
exit 0
fi
echo "Unexpected test state: changed=$TESTS_CHANGED, result=$RUN_RESULT" >&2
echo "CLI tests were not required, but the skip job finished with result: $SKIP_RESULT" >&2
exit 1
web-tests-run:
@@ -341,6 +338,17 @@ 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() }}
@@ -348,26 +356,21 @@ jobs:
- pre_job
- check-changes
- web-tests-run
runs-on: ubuntu-24.04
- web-tests-skip
runs-on: depot-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 }}
PRE_JOB_RESULT: ${{ needs.pre_job.result }}
CHANGES_RESULT: ${{ needs.check-changes.result }}
SKIP_RESULT: ${{ needs.web-tests-skip.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."
@@ -378,12 +381,12 @@ jobs:
exit 1
fi
if [[ "$TESTS_CHANGED" == 'false' && "$RUN_RESULT" == 'skipped' ]]; then
if [[ "$SKIP_RESULT" == 'success' ]]; then
echo "Web tests were skipped because no web-related files changed."
exit 0
fi
echo "Unexpected test state: changed=$TESTS_CHANGED, result=$RUN_RESULT" >&2
echo "Web tests were not required, but the skip job finished with result: $SKIP_RESULT" >&2
exit 1
web-e2e-run:
@@ -397,6 +400,17 @@ 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() }}
@@ -404,26 +418,21 @@ jobs:
- pre_job
- check-changes
- web-e2e-run
runs-on: ubuntu-24.04
- web-e2e-skip
runs-on: depot-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 }}
PRE_JOB_RESULT: ${{ needs.pre_job.result }}
CHANGES_RESULT: ${{ needs.check-changes.result }}
SKIP_RESULT: ${{ needs.web-e2e-skip.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."
@@ -434,12 +443,12 @@ jobs:
exit 1
fi
if [[ "$TESTS_CHANGED" == 'false' && "$RUN_RESULT" == 'skipped' ]]; then
if [[ "$SKIP_RESULT" == 'success' ]]; then
echo "Web full-stack E2E was skipped because no E2E-related files changed."
exit 0
fi
echo "Unexpected test state: changed=$TESTS_CHANGED, result=$RUN_RESULT" >&2
echo "Web full-stack E2E was not required, but the skip job finished with result: $SKIP_RESULT" >&2
exit 1
style-check:
@@ -467,6 +476,17 @@ 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() }}
@@ -474,26 +494,21 @@ jobs:
- pre_job
- check-changes
- vdb-tests-run
runs-on: ubuntu-24.04
- vdb-tests-skip
runs-on: depot-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 }}
PRE_JOB_RESULT: ${{ needs.pre_job.result }}
CHANGES_RESULT: ${{ needs.check-changes.result }}
SKIP_RESULT: ${{ needs.vdb-tests-skip.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."
@@ -504,12 +519,12 @@ jobs:
exit 1
fi
if [[ "$TESTS_CHANGED" == 'false' && "$RUN_RESULT" == 'skipped' ]]; then
if [[ "$SKIP_RESULT" == 'success' ]]; then
echo "VDB tests were skipped because no VDB-related files changed."
exit 0
fi
echo "Unexpected test state: changed=$TESTS_CHANGED, result=$RUN_RESULT" >&2
echo "VDB tests were not required, but the skip job finished with result: $SKIP_RESULT" >&2
exit 1
db-migration-test-run:
@@ -520,6 +535,17 @@ 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() }}
@@ -527,26 +553,21 @@ jobs:
- pre_job
- check-changes
- db-migration-test-run
runs-on: ubuntu-24.04
- db-migration-test-skip
runs-on: depot-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 }}
PRE_JOB_RESULT: ${{ needs.pre_job.result }}
CHANGES_RESULT: ${{ needs.check-changes.result }}
SKIP_RESULT: ${{ needs.db-migration-test-skip.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."
@@ -557,12 +578,12 @@ jobs:
exit 1
fi
if [[ "$TESTS_CHANGED" == 'false' && "$RUN_RESULT" == 'skipped' ]]; then
if [[ "$SKIP_RESULT" == 'success' ]]; then
echo "DB migration tests were skipped because no migration-related files changed."
exit 0
fi
echo "Unexpected test state: changed=$TESTS_CHANGED, result=$RUN_RESULT" >&2
echo "DB migration tests were not required, but the skip job finished with result: $SKIP_RESULT" >&2
exit 1
sandbox-runtime-tests-run:
@@ -574,6 +595,17 @@ 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() }}
@@ -581,26 +613,21 @@ jobs:
- pre_job
- check-changes
- sandbox-runtime-tests-run
runs-on: ubuntu-24.04
- sandbox-runtime-tests-skip
runs-on: depot-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 }}
PRE_JOB_RESULT: ${{ needs.pre_job.result }}
CHANGES_RESULT: ${{ needs.check-changes.result }}
SKIP_RESULT: ${{ needs.sandbox-runtime-tests-skip.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."
@@ -611,12 +638,12 @@ jobs:
exit 1
fi
if [[ "$TESTS_CHANGED" == 'false' && "$RUN_RESULT" == 'skipped' ]]; then
if [[ "$SKIP_RESULT" == 'success' ]]; then
echo "Sandbox runtime tests were skipped because no sandbox-runtime-related files changed."
exit 0
fi
echo "Unexpected test state: changed=$TESTS_CHANGED, result=$RUN_RESULT" >&2
echo "Sandbox runtime tests were not required, but the skip job finished with result: $SKIP_RESULT" >&2
exit 1
dify-agent-tests-run:
@@ -628,6 +655,17 @@ 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() }}
@@ -635,26 +673,21 @@ jobs:
- pre_job
- check-changes
- dify-agent-tests-run
runs-on: ubuntu-24.04
- dify-agent-tests-skip
runs-on: depot-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 }}
PRE_JOB_RESULT: ${{ needs.pre_job.result }}
CHANGES_RESULT: ${{ needs.check-changes.result }}
SKIP_RESULT: ${{ needs.dify-agent-tests-skip.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."
@@ -665,10 +698,10 @@ jobs:
exit 1
fi
if [[ "$TESTS_CHANGED" == 'false' && "$RUN_RESULT" == 'skipped' ]]; then
if [[ "$SKIP_RESULT" == 'success' ]]; then
echo "Dify agent tests were skipped because no dify-agent-related files changed."
exit 0
fi
echo "Unexpected test state: changed=$TESTS_CHANGED, result=$RUN_RESULT" >&2
echo "Dify agent tests were not required, but the skip job finished with result: $SKIP_RESULT" >&2
exit 1

View File

@@ -36,6 +36,7 @@ jobs:
- 'e2e/package.json'
- 'e2e/test-env.ts'
- 'e2e/tsconfig.json'
- 'e2e/tsx-register.js'
- 'package.json'
- 'pnpm-lock.yaml'
- '.github/workflows/post-merge.yml'

View File

@@ -22,7 +22,7 @@ jobs:
fetch-depth: 0
- name: Setup Python & UV
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
with:
enable-cache: true

View File

@@ -24,7 +24,7 @@ jobs:
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Setup Python & UV
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
with:
enable-cache: true

View File

@@ -22,7 +22,7 @@ jobs:
fetch-depth: 0
- name: Setup Python & UV
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
with:
enable-cache: true

View File

@@ -61,7 +61,7 @@ jobs:
fetch-depth: 0
- name: Setup UV and Python
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
with:
enable-cache: false
python-version: '3.12'
@@ -89,14 +89,6 @@ 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:
@@ -115,18 +107,8 @@ jobs:
if: inputs.run-python-style
run: uv run --project api dotenv-linter ./api/.env.example ./web/.env.example
web-build:
name: Web Build and Bundle Analysis
if: ${{ !inputs.skip-checks && inputs.classification-succeeded && inputs.run-web-style }}
permissions:
contents: read
pull-requests: write
uses: ./.github/workflows/web-bundle-size.yml
with:
base-rev: ${{ inputs.base-rev }}
web-style-checks:
name: Web Static Checks
web-style:
name: Web Style
if: ${{ !inputs.skip-checks && (!inputs.classification-succeeded || inputs.run-web-style) }}
runs-on: depot-ubuntu-24.04
defaults:
@@ -152,9 +134,6 @@ jobs:
- name: Setup web environment
uses: ./.github/actions/setup-web
- name: Check generated i18n types
run: vp run i18n:check-types
- name: Web tsslint
env:
NODE_OPTIONS: --max-old-space-size=4096
@@ -164,6 +143,11 @@ jobs:
working-directory: .
run: vp run knip
- name: Web build and unused translations check
env:
NODE_OPTIONS: --max-old-space-size=8192
run: vp run build:vinext
- name: Web dead code check production
working-directory: .
run: vp run knip:production
@@ -172,24 +156,6 @@ jobs:
working-directory: .
run: vp run knip:production-unused-check
# Preserve the existing required check while build and static checks run in parallel.
web-style:
name: Web Style
if: ${{ always() && !inputs.skip-checks && (!inputs.classification-succeeded || inputs.run-web-style) }}
needs: [web-build, web-style-checks]
runs-on: ubuntu-24.04
permissions: {}
steps:
- name: Verify web checks
env:
BUILD_RESULT: ${{ needs.web-build.result }}
STYLE_RESULT: ${{ needs.web-style-checks.result }}
run: |
if [[ "$BUILD_RESULT" != 'success' || "$STYLE_RESULT" != 'success' ]]; then
echo "Web checks failed: build=$BUILD_RESULT, static checks=$STYLE_RESULT" >&2
exit 1
fi
ts-common-style:
name: TS Common
if: ${{ !inputs.skip-checks && (!inputs.classification-succeeded || inputs.run-ts-common-style) }}
@@ -213,25 +179,9 @@ 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) }}
@@ -251,7 +201,7 @@ jobs:
persist-credentials: false
- name: Super-linter
uses: super-linter/super-linter/slim@2da136927bd4a73596db63044b504547c62cb854 # v9.0.0
uses: super-linter/super-linter/slim@4ce20838b8ab83717e78138c5b3a1407148e0918 # v8.7.0
env:
BASH_SEVERITY: warning
DEFAULT_BRANCH: origin/main

View File

@@ -9,8 +9,6 @@ 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 }}
@@ -30,13 +28,15 @@ jobs:
with:
persist-credentials: false
- name: Setup web environment
uses: ./.github/actions/setup-web
- 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: 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')"

View File

@@ -47,7 +47,6 @@ jobs:
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
ref: ${{ github.event.client_payload.head_sha || github.sha }}
token: ${{ secrets.GITHUB_TOKEN }}
- name: Configure Git
@@ -163,7 +162,7 @@ jobs:
- name: Run Claude Code for Translation Sync
if: steps.context.outputs.CHANGED_FILES != ''
uses: anthropics/claude-code-action@ed670b4cf9de2a5a570d130d2f6197b9e543cd64 # v1.0.240
uses: anthropics/claude-code-action@4036a180cf690f49529f5d8c79c998855287f590 # v1.0.230
with:
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
github_token: ${{ secrets.GITHUB_TOKEN }}
@@ -286,7 +285,6 @@ jobs:
BASE_SHA: ${{ steps.context.outputs.BASE_SHA }}
HEAD_SHA: ${{ steps.context.outputs.HEAD_SHA }}
REPO_NAME: ${{ github.repository }}
EVENT_NAME: ${{ github.event_name }}
shell: bash
run: |
PR_BODY_FILE=/tmp/i18n-pr-body.md
@@ -297,7 +295,6 @@ jobs:
export LANG_COUNT
node <<'NODE' > "$PR_BODY_FILE"
const { execFileSync } = require('node:child_process')
const fs = require('node:fs')
const changesPath = '/tmp/i18n-changes.json'
@@ -306,25 +303,7 @@ jobs:
: { changes: {} }
const filesInScope = (process.env.FILES_IN_SCOPE || '').split(/\s+/).filter(Boolean)
const sourceCommit = process.env.HEAD_SHA
let sourcePr
if (process.env.EVENT_NAME === 'repository_dispatch') {
try {
const pulls = JSON.parse(execFileSync('gh', [
'api', `repos/${process.env.REPO_NAME}/commits/${sourceCommit}/pulls`,
], { encoding: 'utf8' }))
sourcePr = pulls.find(pr => pr.merged_at && pr.merge_commit_sha === sourceCommit)
} catch (error) {
console.error(`Could not resolve source PR: ${error.message}`)
}
}
const lines = [
'## Source',
'',
...(sourcePr ? [`- **PR**: [#${sourcePr.number}](${sourcePr.html_url})`] : []),
`- **Commit**: [\`${sourceCommit.slice(0, 12)}\`](https://github.com/${process.env.REPO_NAME}/commit/${sourceCommit})`,
'',
'## Summary',
'',
`- **Files synced**: \`${process.env.FILES_IN_SCOPE || '<none>'}\``,

View File

@@ -36,7 +36,7 @@ jobs:
remove_tool_cache: true
- name: Setup UV and Python
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
with:
enable-cache: true
python-version: ${{ matrix.python-version }}

View File

@@ -33,7 +33,7 @@ jobs:
remove_tool_cache: true
- name: Setup UV and Python
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
with:
enable-cache: true
python-version: ${{ matrix.python-version }}

View File

@@ -1,162 +0,0 @@
name: Web Bundle Analysis
on:
workflow_call:
inputs:
base-rev:
description: Exact base revision selected by the main CI event.
required: true
type: string
permissions:
contents: read
pull-requests: write
jobs:
measure:
name: Measure ${{ matrix.revision }}
runs-on: ubuntu-24.04
timeout-minutes: 30
permissions:
contents: read
strategy:
fail-fast: false
matrix:
include:
- revision: base
sha: ${{ inputs.base-rev }}
- revision: merge
sha: ${{ github.sha }}
env:
NEXT_TELEMETRY_DISABLED: '1'
NODE_OPTIONS: --max-old-space-size=6144
steps:
# Keep one collector for both revisions, including when it does not exist on base yet.
- name: Checkout reporting tools
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Preserve collector
run: cp web/scripts/bundle-analysis.ts "$RUNNER_TEMP/bundle-analysis.ts"
- name: Checkout measured revision
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ matrix.sha }}
persist-credentials: false
- name: Setup web environment
uses: ./.github/actions/setup-web
- name: Instrument selected revision
run: node "$RUNNER_TEMP/bundle-analysis.ts" instrument web
- name: Full Vinext build with official Rolldown analysis
working-directory: web
shell: bash
run: |
set -o pipefail
started=$SECONDS
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
env:
COMMIT_SHA: ${{ matrix.sha }}
run: node "$RUNNER_TEMP/bundle-analysis.ts" collect web "$COMMIT_SHA" "$RUNNER_TEMP/bundle-size/summary.json"
- name: Upload size snapshot
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: web-bundle-summary-${{ matrix.revision }}
path: ${{ runner.temp }}/bundle-size/summary.json
if-no-files-found: error
retention-days: 14
- name: Upload official Rolldown analysis
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: web-bundle-analyzer-${{ matrix.revision }}
path: |
web/dist/client/bundle-analysis.json
web/dist/client/bundle-analysis.md
${{ runner.temp }}/vinext-build.log
include-hidden-files: true
if-no-files-found: error
retention-days: 14
report:
name: Compare bundle sizes
needs: measure
runs-on: ubuntu-24.04
timeout-minutes: 5
permissions:
contents: read
pull-requests: write
steps:
- name: Checkout reporting tools
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Setup Node.js
uses: pnpm/setup@fbda4c85fc2e1e08721cd8763afea8f48d60f024 # v3.0.0
with:
install: false
- name: Download size snapshots
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
pattern: web-bundle-summary-*
path: bundle-results
- name: Compare and summarize
id: compare
env:
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
node web/scripts/bundle-analysis.ts compare \
bundle-results/web-bundle-summary-base/summary.json \
bundle-results/web-bundle-summary-merge/summary.json \
bundle-report.md
printf '\n[Download base/merge snapshots and official Rolldown analysis reports](%s#artifacts).\n' "$RUN_URL" >> bundle-report.md
printf '\nComparison: event base SHA vs the tested merge commit at GITHUB_SHA (PR or merge queue).\n' >> bundle-report.md
cat bundle-report.md >> "$GITHUB_STEP_SUMMARY"
# Forks still receive the job summary and artifacts, without a privileged PR build.
- name: Update PR report
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
env:
SHOULD_COMMENT: ${{ steps.compare.outputs.should-comment }}
with:
script: |
const fs = require('node:fs');
const { owner, repo } = context.repo;
const pull_number = context.payload.pull_request.number;
const { data: pr } = await github.rest.pulls.get({ owner, repo, pull_number });
if (pr.state !== 'open' || pr.head.sha !== context.payload.pull_request.head.sha ||
pr.base.sha !== context.payload.pull_request.base.sha) {
core.info('Skipping a superseded bundle report.');
return;
}
const body = fs.readFileSync('bundle-report.md', 'utf8');
const comments = await github.paginate(github.rest.issues.listComments, {
owner, repo, issue_number: pull_number, per_page: 100,
});
const existing = comments.find(comment =>
comment.user?.login === 'github-actions[bot]' &&
comment.body?.startsWith('<!-- dify-vinext-bundle-size -->'));
if (process.env.SHOULD_COMMENT !== 'true') {
if (existing) {
await github.rest.issues.deleteComment({ owner, repo, comment_id: existing.id });
}
core.info('Bundle changes are below the PR comment thresholds. See the job summary.');
return;
}
if (existing) {
await github.rest.issues.updateComment({ owner, repo, comment_id: existing.id, body });
} else {
await github.rest.issues.createComment({ owner, repo, issue_number: pull_number, body });
}

View File

@@ -26,7 +26,7 @@ jobs:
persist-credentials: false
- name: Test Web build synchronization
run: node --test .github/actions/wait-e2e-web-build/*.test.cjs
run: node --test .github/actions/wait-e2e-web-build/wait.test.cjs
- name: Compute build source hash
id: build-source
@@ -119,12 +119,24 @@ jobs:
with:
browser: chromium
# The runner prepares the backend while waiting for and downloading the build.
# 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
- name: Run Chromium full shard
working-directory: ./e2e
env:
GH_TOKEN: ${{ github.token }}
run: vp run e2e:full --download-web-build -- --shard ${{ matrix.shard }}/3
run: vp run e2e:full -- --shard ${{ matrix.shard }}/3
- name: Upload Cucumber report
if: ${{ !cancelled() }}
@@ -171,12 +183,24 @@ jobs:
with:
browser: webkit
# Keep the same overlap and teardown ownership for the WebKit smoke lane.
# 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
- name: Run WebKit browser smoke
working-directory: ./e2e
env:
GH_TOKEN: ${{ github.token }}
run: vp run e2e:full --download-web-build -- --tags '@browser-smoke'
run: vp run e2e:full -- --tags '@browser-smoke'
- name: Upload Cucumber report
if: ${{ !cancelled() }}
@@ -215,7 +239,7 @@ jobs:
uses: ./.github/actions/setup-web
- name: Setup UV and Python
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
with:
enable-cache: true
python-version: '3.12'

View File

@@ -40,7 +40,7 @@ jobs:
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: blob-report-${{ matrix.shardIndex }}
path: web/.vitest/blob/*
path: web/.vitest-reports/*
include-hidden-files: true
retention-days: 1
@@ -103,7 +103,7 @@ jobs:
- name: Download blob reports
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
path: web/.vitest/blob
path: web/.vitest-reports
pattern: blob-report-*
merge-multiple: true

9
.vscode/README.md vendored
View File

@@ -1,11 +1,14 @@
# Debugging with VS Code
[`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.
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.
## How to Use
1. Copy `.vscode/launch.json.template` to `.vscode/launch.json`, or merge the configurations into an existing file.
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. **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.
Start the frontend separately using the [frontend guide](../web/README.md).
## 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.

View File

@@ -81,13 +81,24 @@ 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](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](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.
**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.
**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.
#### Backend
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.
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.
## Getting Help

View File

@@ -80,7 +80,6 @@ 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"
@@ -91,13 +90,15 @@ api-contract-lint:
@echo "✅ Response contract lint complete"
type-check:
@echo "📝 Running type checks (pyrefly)..."
@echo "📝 Running type checks (pyrefly + mypy)..."
@./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)..."
@echo "📝 Running core type checks (pyrefly + mypy)..."
@./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:
@@ -213,8 +214,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)"
@echo " make type-check-core - Run core type checks (pyrefly)"
@echo " make type-check - Run type checks (pyrefly, mypy)"
@echo " make type-check-core - Run core type checks (pyrefly, mypy)"
@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 ""

View File

@@ -58,7 +58,6 @@
<a href="./docs/sl-SI/README.md"><img alt="README Slovenščina" src="https://img.shields.io/badge/Sloven%C5%A1%C4%8Dina-d9d9d9"></a>
<a href="./docs/bn-BD/README.md"><img alt="README in বাংলা" src="https://img.shields.io/badge/বাংলা-d9d9d9"></a>
<a href="./docs/hi-IN/README.md"><img alt="README in हिन्दी" src="https://img.shields.io/badge/Hindi-d9d9d9"></a>
<a href="./docs/id-ID/README.md"><img alt="README dalam Bahasa Indonesia" src="https://img.shields.io/badge/Bahasa%20Indonesia-d9d9d9"></a>
</p>
Dify is an open-source LLM app development platform. Its intuitive interface combines AI workflow, RAG pipeline, agent capabilities, model management, observability features (including [Opik](https://www.comet.com/docs/opik/integrations/dify), [Langfuse](https://docs.langfuse.com), and [Arize Phoenix](https://docs.arize.com/phoenix)) and more, letting you quickly go from prototype to production. Here's a list of the core features:
@@ -83,7 +82,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 +115,7 @@ All of Dify's offerings come with corresponding APIs, so you could effortlessly
## Using Dify
- **Cloud <br/>**
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).
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).
- **Self-hosting Dify Community Edition<br/>**
Quickly get Dify running in your environment with this [starter guide](#quick-start).
@@ -171,7 +170,7 @@ Choose the channel that best fits your question:
## Security disclosure
Report vulnerabilities privately through GitHub Security Advisories, following our [security policy](SECURITY.md). Do not disclose them in public issues, discussions, or pull requests.
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.
## License

View File

@@ -156,7 +156,6 @@ 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

View File

@@ -1,14 +1,12 @@
[importlinter]
root_packages =
core
core.rag.extractor.entity
constants
context
configs
controllers
extensions
factories
fields
libs
machinery
models
@@ -18,7 +16,7 @@ root_packages =
include_external_packages = True
[importlinter:contract:backend-layers]
name = Backend modules follow the transport, application, execution-kernel, and shared-library layers
name = Backend modules follow the transport, application, domain, and shared-library layers
type = layers
layers =
controllers
@@ -29,7 +27,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.knowledge.dataset_service
core.app.app_config.easy_ui_based_app.dataset.manager -> services.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
@@ -50,41 +48,45 @@ 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.knowledge.dataset_service
core.app.apps.pipeline.pipeline_generator -> services.data_source.provider_service
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.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.knowledge.dataset_service
core.app.features.annotation_reply.annotation_reply -> services.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.knowledge.external.service
core.rag.datasource.retrieval_service -> services.external_knowledge_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.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.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.retrieval.dataset_retrieval -> services.feature_service
core.rag.summary_index.summary_index -> services.knowledge.summaries.adapters
core.rag.summary_index.summary_index -> services.summary_index_service
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
@@ -95,9 +97,12 @@ ignore_imports =
core.workflow.nodes.agent_v2.session_store -> services.agent.workspace_service
core.workflow.nodes.agent_v2.validators -> services.agent.knowledge_datasets
core.workflow.nodes.agent_v2.workspace_retirement_layer -> tasks.collect_agent_resources_task
libs.device_flow_security -> controllers.openapi._models
libs.device_flow_security -> services.entities.feature_entities
libs.device_flow_security -> services.system_feature_service
libs.email_i18n -> services.entities.feature_entities
libs.email_i18n -> services.system_feature_service
libs.external_api -> core.plugin
libs.external_api -> core
libs.external_api -> core.errors.error
libs.external_api -> extensions.ext_logging
libs.flask_utils -> models
@@ -112,6 +117,8 @@ 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]
@@ -151,159 +158,18 @@ forbidden_modules =
werkzeug
[importlinter:contract:workspace-query-service-boundary]
name = Workspace application services and contracts are framework and persistence neutral
name = Workspace query application service is framework and persistence neutral
type = forbidden
source_modules =
services.workspace.service
services.workspace.provisioning_service
services.workspace.contracts
services.workspace_query_service
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
@@ -333,97 +199,6 @@ forbidden_modules =
sqlalchemy
werkzeug
[importlinter:contract:installed-app-access-boundary]
name = Installed app access application service is framework and persistence neutral
type = forbidden
source_modules =
services.installed_app_access_service
forbidden_modules =
controllers
extensions
flask
models
repositories
sqlalchemy
werkzeug
[importlinter:contract:installed-app-management-boundary]
name = Installed app management service is framework and persistence neutral
type = forbidden
source_modules =
services.installed_app_service
forbidden_modules =
controllers
extensions
flask
models
repositories
sqlalchemy
werkzeug
[importlinter:contract:installed-app-conversation-boundary]
name = Installed app conversation service is framework and persistence neutral
type = forbidden
source_modules =
services.installed_app_conversation_service
forbidden_modules =
controllers
extensions
flask
models
repositories
sqlalchemy
werkzeug
[importlinter:contract:installed-app-message-boundary]
name = Installed app message service is framework and persistence neutral
type = forbidden
source_modules =
services.installed_app_message_service
forbidden_modules =
controllers
extensions
flask
models
repositories
sqlalchemy
werkzeug
[importlinter:contract:installed-app-message-persistence-boundary]
name = Installed app message persistence does not call the legacy message service
type = forbidden
source_modules =
repositories.installed_app_message_repository
forbidden_modules =
services.message_service
allow_indirect_imports = True
[importlinter:contract:conversation-persistence-boundary]
name = Conversation persistence does not call legacy conversation or workspace services
type = forbidden
source_modules =
repositories.installed_app_conversation_repository
repositories.conversation_lifecycle
repositories.agent_workspace_repository
forbidden_modules =
services.conversation_service
services.agent.workspace_service
allow_indirect_imports = True
[importlinter:contract:installed-app-generation-boundary]
name = Installed app generation service is framework and persistence neutral
type = forbidden
source_modules =
services.installed_app_generation_service
forbidden_modules =
controllers
extensions
flask
models
repositories
sqlalchemy
werkzeug
[importlinter:contract:account-application-boundary]
name = Account application services and contracts are framework and persistence neutral
type = forbidden
@@ -432,16 +207,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
@@ -489,37 +264,6 @@ forbidden_modules =
sqlalchemy
werkzeug
[importlinter:contract:end-user-query-service-boundary]
name = App-scoped end user query service is framework and persistence neutral
type = forbidden
source_modules =
services.app_scoped_end_user_query_service
services.entities.app_scoped_end_user_entities
forbidden_modules =
configs
controllers
extensions
flask
models
repositories
sqlalchemy
werkzeug
[importlinter:contract:end-user-command-service-boundary]
name = App-scoped end user command service is framework and persistence neutral
type = forbidden
source_modules =
services.app_scoped_end_user_service
forbidden_modules =
configs
controllers
extensions
flask
models.model
repositories
sqlalchemy
werkzeug
[importlinter:contract:app-tracing-config-service-boundary]
name = App tracing configuration service is framework and persistence neutral
type = forbidden
@@ -580,35 +324,6 @@ 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
@@ -645,7 +360,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
@@ -675,29 +390,11 @@ forbidden_modules =
sqlalchemy
werkzeug
[importlinter:contract:oauth-device-application-service-boundary]
name = OAuth device application service and contracts are framework and persistence neutral
type = forbidden
source_modules =
services.oauth_device_application_service
services.oauth_device_contracts
forbidden_modules =
configs
controllers
extensions
flask
httpx
libs
models
repositories
sqlalchemy
werkzeug
[importlinter:contract:workspace-member-query-service-boundary]
name = Workspace member query application service is framework and persistence neutral
type = forbidden
source_modules =
services.workspace.member_service
services.workspace_member_query_service
forbidden_modules =
configs
controllers
@@ -706,7 +403,7 @@ forbidden_modules =
models
repositories
services.enterprise
services.workspace.gateways
services.workspace_member_role_resolver
sqlalchemy
werkzeug
@@ -832,36 +529,6 @@ 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
@@ -893,20 +560,6 @@ 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
@@ -995,116 +648,3 @@ forbidden_modules =
sqlalchemy
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
source_modules =
services.app_audio_service
services.audio_types
forbidden_modules =
controllers
extensions
flask
models
repositories
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

View File

@@ -15,8 +15,7 @@ Run direct Python commands through `uv run --project api`. Docker-backed integra
## Architecture And Boundaries
- 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.
- 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.
- 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.

View File

@@ -2,11 +2,19 @@
## Setup and Run
[`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.
> [!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.
### Using scripts (recommended)
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.
The scripts resolve paths relative to their location, so you can run them from anywhere.
1. Run setup (copies env files and installs dependencies).
```bash
./dev/setup
@@ -36,7 +44,7 @@
1. Set up your application by visiting `http://localhost:3000`.
1. Start the worker service (executes queued async tasks).
1. Start the worker service (async and scheduler tasks, runs from `api`).
```bash
./dev/start-worker
@@ -54,35 +62,51 @@
>
> 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.
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`.
- 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
```
## Testing
Run from the repository root:
1. Install dependencies for both the backend and the test environment
```bash
uv sync --project api --group dev
make test
make test TARGET_TESTS=./api/tests/unit_tests/<path>
make lint
make type-check
```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
```
`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.
use https://jsontotable.org/openapi-to-typescript to convert to typescript

View File

@@ -1,11 +1,15 @@
import base64
import secrets
import click
from sqlalchemy.orm import Session
from constants.languages import languages
from extensions.ext_application_services import application_services
from extensions.ext_database import db
from libs.helper import email as email_validate
from libs.password import password_pattern, valid_password
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
@click.command("reset-password", help="Reset the account password.")
@@ -22,7 +26,7 @@ def reset_password(email, new_password, password_confirm):
return
normalized_email = email.strip().lower()
account = application_services().accounts.lifecycle.get_account_by_email_with_case_fallback(email.strip())
account = AccountService.get_account_by_email_with_case_fallback(email.strip(), session=db.session())
if not account:
click.echo(click.style(f"Account not found for email: {email}", fg="red"))
@@ -34,7 +38,19 @@ def reset_password(email, new_password, password_confirm):
click.echo(click.style(f"Invalid password. Must match {password_pattern}", fg="red"))
return
application_services().accounts.lifecycle.reset_password(account.id, new_password, email=normalized_email)
# 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)
click.echo(click.style("Password reset successfully.", fg="green"))
@@ -52,7 +68,7 @@ def reset_email(email, new_email, email_confirm):
return
normalized_new_email = new_email.strip().lower()
account = application_services().accounts.lifecycle.get_account_by_email_with_case_fallback(email.strip())
account = AccountService.get_account_by_email_with_case_fallback(email.strip(), session=db.session())
if not account:
click.echo(click.style(f"Account not found for email: {email}", fg="red"))
@@ -64,7 +80,11 @@ def reset_email(email, new_email, email_confirm):
click.echo(click.style(f"Invalid email: {new_email}", fg="red"))
return
application_services().accounts.lifecycle.set_email(account.id, normalized_new_email)
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()
click.echo(click.style("Email updated successfully.", fg="green"))
@@ -109,14 +129,15 @@ def create_tenant(email: str, language: str | None = None, name: str | None = No
return
# register account
account = application_services().accounts.lifecycle.register(
account = RegisterService.register(
email=email,
name=account_name,
password=new_password,
language=language,
create_workspace_required=False,
session=db.session(),
)
application_services().workspaces.provisioning.create_owner_workspace(account.id, name=name, if_missing=True)
TenantService.create_owner_tenant_if_not_exist(account, name, session=db.session())
click.echo(
click.style(

View File

@@ -463,36 +463,6 @@ 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,
*,
@@ -755,7 +725,7 @@ def migrate_resource_whitelist_scopes_to_automatic_include(
migrated_count += 1
if scope is RBACResourceWhitelistScope.ALL:
for batch in _workspace_non_admin_account_id_batches(workspace_id, operator_account_id, member_batch_size):
for batch in _workspace_member_account_id_batches(workspace_id, member_batch_size):
_replace_resource_default_access_policies(
current_resource_type,
tenant_id=workspace_id,
@@ -907,7 +877,7 @@ def migrate_only_me_resource_whitelist_scopes_to_automatic_include(
)
migrated_count += 1
for batch in _workspace_non_admin_account_id_batches(workspace_id, operator_account_id, member_batch_size):
for batch in _workspace_member_account_id_batches(workspace_id, member_batch_size):
_replace_resource_default_access_policies(
current_resource_type,
tenant_id=workspace_id,
@@ -1217,11 +1187,7 @@ def _report_backing_app_specific_whitelist(options: _AgentAccessBootstrapOptions
def _write_agent_access_rows(options: _AgentAccessBootstrapOptions) -> None:
for batch in _workspace_non_admin_account_id_batches(
options.tenant_id,
options.operator_account_id,
options.member_batch_size,
):
for batch in _workspace_member_account_id_batches(options.tenant_id, options.member_batch_size):
RBACService.AgentAccess.replace_user_access_policies(
tenant_id=options.tenant_id,
account_id=options.operator_account_id,

View File

@@ -19,7 +19,6 @@ 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.")
@@ -279,7 +278,7 @@ def migrate_knowledge_vector_database():
},
)
if dataset_document.doc_form == IndexStructureType.PARENT_CHILD_INDEX:
child_chunks = get_segment_child_chunks(segment, session=db_session)
child_chunks = segment.get_child_chunks(session=db_session)
if child_chunks:
child_documents = []
for child_chunk in child_chunks:

View File

@@ -1,5 +1,3 @@
from typing import Literal
from pydantic import Field
from pydantic_settings import BaseSettings
@@ -43,8 +41,3 @@ 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",
)

View File

@@ -1,65 +0,0 @@
"""Stable OAuth bearer vocabulary shared across application layers."""
from __future__ import annotations
from enum import StrEnum
class Scope(StrEnum):
"""Catalog of bearer scopes recognised by the openapi surface.
`FULL` is the catch-all carried by `dfoa_` account tokens — it satisfies
any per-route scope requirement. `dfoe_` tokens carry the per-feature
scopes (`APPS_RUN`, `APPS_READ_PERMITTED_EXTERNAL`).
"""
FULL = "full"
APPS_READ = "apps:read"
APPS_READ_PERMITTED_EXTERNAL = "apps:read:permitted-external"
APPS_RUN = "apps:run"
WORKSPACE_READ = "workspace:read"
WORKSPACE_WRITE = "workspace:write"
class SubjectType(StrEnum):
# Annotation-only names are not members; they declare what `__new__` attaches.
scopes: frozenset[Scope]
# Whether the subject's token rows carry an `account_id`. A subject that does
# not is keyed by its external issuer instead.
bound_to_account: bool
ACCOUNT = ("account", frozenset({Scope.FULL}), True)
EXTERNAL_SSO = ("external_sso", frozenset({Scope.APPS_RUN, Scope.APPS_READ_PERMITTED_EXTERNAL}), False)
def __new__(cls, value: str, scopes: frozenset[Scope], bound_to_account: bool) -> SubjectType:
obj = str.__new__(cls, value)
obj._value_ = value
obj.scopes = scopes
obj.bound_to_account = bound_to_account
return obj
class TokenType(StrEnum):
"""Many token types may serve one subject; the prefix is the only thing a
bearer string reveals, so it lives here and nowhere else.
"""
prefix: str
subject: SubjectType
OAUTH_ACCOUNT = ("oauth_account", "dfoa_", SubjectType.ACCOUNT)
OAUTH_EXTERNAL_SSO = ("oauth_external_sso", "dfoe_", SubjectType.EXTERNAL_SSO)
def __new__(cls, value: str, prefix: str, subject: SubjectType) -> TokenType:
obj = str.__new__(cls, value)
obj._value_ = value
obj.prefix = prefix
obj.subject = subject
return obj
@classmethod
def for_token(cls, token: str) -> TokenType | None:
return next((token_type for token_type in cls if token.startswith(token_type.prefix)), None)
TOKEN_CACHE_KEY_FMT = "auth:token:{hash}"

View File

@@ -0,0 +1,109 @@
from __future__ import annotations
from collections.abc import Sequence
from dataclasses import dataclass
from typing import TYPE_CHECKING
from sqlalchemy.orm import Session
from services.enterprise import rbac_service as enterprise_rbac_service
if TYPE_CHECKING:
from services.app_service import AppListBaseParams
from services.enterprise.rbac_service import MyPermissionsResponse
# Permission keys (dot-notation, from MyPermissionsResponse) that grant
# list/preview access to an app. Keep this the single source of truth for both
# the console and OpenAPI app-list endpoints.
APP_LIST_PERMISSION_KEYS: frozenset[str] = frozenset({"app.preview", "app.acl.preview", "app.full_access"})
# Workspace permission key that lets a caller see apps they maintain when the
# app whitelist is unrestricted.
_MANAGE_OWN_APPS_PERMISSION_KEY = "app.create_and_management"
def has_app_list_permission(permission_keys: Sequence[str]) -> bool:
"""Return True if any of ``permission_keys`` grants app list/preview access."""
return any(permission_key in APP_LIST_PERMISSION_KEYS for permission_key in permission_keys)
@dataclass(frozen=True)
class AppAccessFilter:
"""Resolved RBAC visibility for app list/read endpoints.
``accessible_app_ids`` of ``None`` means the caller can see every app in the
workspace (unrestricted). Otherwise it is the exact set of app ids the
caller may preview; combined with ``can_manage_own_apps`` it can also cover
apps the caller maintains.
"""
accessible_app_ids: set[str] | None
can_manage_own_apps: bool
@classmethod
def unrestricted(cls) -> AppAccessFilter:
"""Filter that imposes no restriction (RBAC disabled / not applicable)."""
return cls(accessible_app_ids=None, can_manage_own_apps=False)
def is_app_accessible(self, app_id: str, maintainer: str | None, account_id: str) -> bool:
"""Whether a single app is visible to the caller under this filter.
Mirrors the service-layer query gate: an app is visible when the filter
is unrestricted, the app id is whitelisted, or the caller maintains it
and holds ``app.create_and_management``.
"""
if self.accessible_app_ids is None:
return True
if app_id in self.accessible_app_ids:
return True
return self.can_manage_own_apps and maintainer is not None and maintainer == account_id
def apply_to_params(self, params: AppListBaseParams) -> None:
if self.accessible_app_ids is None:
return
params.accessible_app_ids = sorted(self.accessible_app_ids)
params.include_own_apps = self.can_manage_own_apps
def resolve_app_access_filter(
tenant_id: str,
account_id: str,
*,
session: Session,
permissions: MyPermissionsResponse | None = None,
) -> AppAccessFilter:
"""Compute the RBAC app-access filter for ``account_id`` in ``tenant_id``.
Pass ``permissions`` when the caller has already fetched the snapshot (the
console controller reuses it for per-app permission keys) to avoid a second
inner-API round trip; otherwise it is fetched here.
"""
if permissions is None:
permissions = enterprise_rbac_service.RBACService.MyPermissions.get(tenant_id, account_id, session=session)
whitelist_scope = enterprise_rbac_service.RBACService.AppAccess.whitelist_resources(tenant_id, account_id)
can_manage_own_apps = _MANAGE_OWN_APPS_PERMISSION_KEY in permissions.workspace.permission_keys
has_default_preview = has_app_list_permission(permissions.app.default_permission_keys) or has_app_list_permission(
permissions.workspace.permission_keys
)
permission_app_ids: set[str] | None = None
if not has_default_preview:
# Collect apps the caller can preview via per-app permission overrides.
permission_app_ids = {
override.resource_id
for override in permissions.app.overrides
if has_app_list_permission(override.permission_keys)
}
accessible_app_ids: set[str] | None
if getattr(whitelist_scope, "unrestricted", False):
accessible_app_ids = permission_app_ids
else:
# A restricted app whitelist is the highest-priority visibility gate:
# default preview, per-app permission overrides, and own-app management
# must not expose apps outside this set.
accessible_app_ids = set(whitelist_scope.resource_ids)
can_manage_own_apps = False
return AppAccessFilter(accessible_app_ids=accessible_app_ids, can_manage_own_apps=can_manage_own_apps)

View File

@@ -1,27 +0,0 @@
"""Create binary audio responses and own their stream cleanup."""
from flask import Response, stream_with_context
from core.base.tts.audio_mime import inspect_audio_stream, resolve_audio_mime_type
from services.audio_types import AudioOutput
def audio_binary_response(output: AudioOutput | None) -> Response | None:
"""Preserve audio MIME and request context while releasing provider streams."""
if output is None:
return None
if isinstance(output.data, (bytes, bytearray, memoryview)):
data = bytes(output.data)
return Response(data, content_type=resolve_audio_mime_type(data, output.mime_type))
audio_stream, mime_type = inspect_audio_stream(output.data, output.mime_type)
try:
response = Response(
stream_with_context(audio_stream), # pyrefly: ignore[no-matching-overload]
content_type=mime_type,
)
response.call_on_close(audio_stream.close)
return response
except BaseException:
audio_stream.close()
raise

View File

@@ -1,12 +1,4 @@
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):
@@ -92,39 +84,6 @@ 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)

View File

@@ -4,7 +4,6 @@ from .checks import RBAC_CHECKS_ATTR, RBACCheck, enforce_rbac_checks
from .locators import (
AgentBehindApp,
AgentId,
DatasetByDocument,
DatasetByPipeline,
DatasetId,
PlainApp,
@@ -17,7 +16,6 @@ __all__ = [
"RBAC_CHECKS_ATTR",
"AgentBehindApp",
"AgentId",
"DatasetByDocument",
"DatasetByPipeline",
"DatasetId",
"PlainApp",

View File

@@ -15,7 +15,6 @@ if TYPE_CHECKING:
__all__ = [
"AgentBehindApp",
"AgentId",
"DatasetByDocument",
"DatasetByPipeline",
"DatasetId",
"PlainApp",
@@ -117,7 +116,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, binding.id)
return ResourceIdentity(self.scope, str(binding.id))
def owner_id(self, tenant_id: str, identity: ResourceIdentity) -> str | None:
return None
@@ -134,18 +133,6 @@ 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"

View File

@@ -6,7 +6,6 @@ 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
@@ -18,7 +17,6 @@ api = ExternalApi(
title="Console API",
description="Console management APIs for app configuration, monitoring, and administration",
)
register_permission_error_handler(api)
@api.errorhandler(ActiveWorkspaceRequiredError)

View File

@@ -6,6 +6,11 @@ from models.model import App
from services.agent.roster_service import AgentRosterService
def resolve_agent_app_model(*, session: Session, tenant_id: str, agent_id: UUID) -> App:
"""Resolve a roster Agent's public Agent App."""
return AgentRosterService(session).get_agent_app_model(tenant_id=tenant_id, agent_id=str(agent_id))
def resolve_agent_runtime_app_model(*, session: Session, tenant_id: str, agent_id: UUID) -> App:
"""Resolve the App that backs an Agent runtime surface.

View File

@@ -18,7 +18,7 @@ from controllers.common.schema import (
)
from controllers.common.session import with_session
from controllers.console import console_ns
from controllers.console.apikey import API_KEY_DELETE_ROLES, API_KEY_EDIT_ROLES, api_key_errors
from controllers.console.apikey import ApiKeyItem, ApiKeyList, BaseApiKeyListResource, BaseApiKeyResource
from controllers.console.app.app import (
APP_LIST_QUERY_ARRAY_FIELDS,
AppListQuery,
@@ -35,7 +35,6 @@ from controllers.console.app.app import (
from controllers.console.app.app import (
UpdateAppPayload as GenericUpdateAppPayload,
)
from controllers.console.flask_admission import console_account_admission
from controllers.console.wraps import (
RBACPermission,
account_initialization_required,
@@ -50,7 +49,6 @@ from controllers.console.wraps import (
)
from core.agent.publish_visibility import agent_has_workflow_callable_active_snapshot
from enums import DeploymentEdition
from extensions.ext_application_services import application_services
from fields.agent_fields import (
AgentConfigDraftSummaryResponse,
AgentConfigSnapshotDetailResponse,
@@ -65,12 +63,10 @@ from fields.agent_fields import (
AgentRosterListResponse,
AgentStatisticSummaryEnvelopeResponse,
)
from fields.api_key_fields import ApiKeyItem, ApiKeyList
from fields.base import ResponseModel
from libs.datetime_utils import parse_time_range
from libs.helper import dump_response
from libs.login import login_required
from machinery.context import RequestContext
from models import Account
from models.agent import Agent, AgentStatus
from models.agent_config_entities import AgentSoulConfig
@@ -84,7 +80,7 @@ from services.agent.observability_service import (
AgentStatisticsQueryParams,
)
from services.agent.roster_service import AgentRosterService
from services.app_service import AgentAppPublicationCounts, AppListParams, AppResponseView, AppService, CreateAppParams
from services.app_service import AgentAppPublicationCounts, AppListParams, AppService, CreateAppParams
from services.enterprise import rbac_service as enterprise_rbac_service
from services.enterprise.enterprise_service import EnterpriseService
from services.entities.agent_entities import ComposerSavePayload, RosterListQuery
@@ -274,7 +270,6 @@ class AgentAppPartial(GenericAppPartial):
class AgentAppDetailWithSite(GenericAppDetailWithSite):
permission_keys: list[str]
bound_agent_id: str | None = None
app_id: str | None = None
backing_app_id: str | None = None
hidden_app_backed: bool = False
@@ -297,13 +292,6 @@ class AgentPublishPayload(BaseModel):
class AgentPublishResponse(ResponseModel):
result: str
publication_kind: Literal["first", "update"] = Field(
description=(
"Classifies the publication by whether the Agent had a publish-visible active snapshot before publishing: "
"'first' if none existed, otherwise 'update'. This is not a historical first-publish indicator. "
"It does not depend on draft edits, Web App or API enablement, or the requesting user's access permissions."
)
)
active_config_snapshot_id: str
active_config_snapshot: AgentConfigSnapshotSummaryResponse | None = None
draft: AgentConfigDraftSummaryResponse | None = None
@@ -405,15 +393,16 @@ def _serialize_agent_app_detail(
roster persona fields without widening the shared /apps detail schema.
"""
access_mode = None
app_model = AppService().get_app(app_model, session=session)
if SystemFeatureService.is_webapp_auth_enabled():
app_setting = EnterpriseService.WebAppAuth.get_app_access_mode_by_id(app_id=str(app_model.id))
access_mode = app_setting.access_mode
app_model.access_mode = app_setting.access_mode # type: ignore[attr-defined]
roster_service = _agent_roster_service(session)
payload = GenericAppDetailWithSite.model_validate(
AppResponseView(app_model, session=session, account=current_user, access_mode=access_mode),
app_model,
from_attributes=True,
context={"session": session},
).model_dump(mode="json")
agent = (
session.scalar(
@@ -506,8 +495,9 @@ def _serialize_agent_app_pagination(
"limit": app_pagination.per_page,
"total": app_pagination.total,
"has_more": app_pagination.has_next,
"data": [AppResponseView(app, session=session) for app in app_pagination.items],
"data": app_pagination.items,
},
context={"session": session},
).model_dump(mode="json")
payload["publication_counts"] = {
"published": publication_counts.published,
@@ -1042,44 +1032,56 @@ class AgentApiStatusApi(Resource):
@console_ns.route("/agent/<uuid:agent_id>/api-keys")
class AgentApiKeyListApi(Resource):
class AgentApiKeyListApi(BaseApiKeyListResource):
resource_type = ApiTokenType.APP
resource_model = App
resource_id_field = "app_id"
token_prefix = "app-"
@console_ns.response(200, "Agent service API keys", console_ns.models[ApiKeyList.__name__])
@console_account_admission(
allowed_roles=API_KEY_EDIT_ROLES,
rbac_checks=[RBACCheck(RBACPermission.AGENT_ACCESS_POINT_VIEW, AgentId())],
)
def get(self, request_context: RequestContext, agent_id: UUID) -> dict[str, object]:
with api_key_errors():
keys = application_services().app_api_keys.list_agent_keys(request_context, str(agent_id))
return dump_response(ApiKeyList, {"data": keys})
@rbac_permission_required(RBACCheck(RBACPermission.AGENT_ACCESS_POINT_VIEW, AgentId()))
@with_current_tenant_id
@edit_permission_required
@with_session(write=False)
def get(self, session: Session, tenant_id: str, agent_id: UUID) -> dict[str, object]:
app_model = _resolve_agent_app_model(session, tenant_id=tenant_id, agent_id=agent_id)
return dump_response(ApiKeyList, self._get_api_key_list(str(app_model.id), tenant_id, session=session))
@console_ns.response(201, "Agent service API key created", console_ns.models[ApiKeyItem.__name__])
@console_ns.response(400, "Maximum keys exceeded")
@console_account_admission(
allowed_roles=API_KEY_EDIT_ROLES,
rbac_checks=[RBACCheck(RBACPermission.AGENT_ACCESS_POINT_MANAGE, AgentId())],
)
def post(self, request_context: RequestContext, agent_id: UUID) -> tuple[dict[str, object], int]:
with api_key_errors():
key = application_services().app_api_keys.create_agent_key(request_context, str(agent_id))
return dump_response(ApiKeyItem, key), 201
@with_current_tenant_id
@edit_permission_required
@rbac_permission_required(RBACCheck(RBACPermission.AGENT_ACCESS_POINT_MANAGE, AgentId()))
@with_session
def post(self, session: Session, tenant_id: str, agent_id: UUID) -> tuple[dict[str, object], int]:
app_model = _resolve_agent_app_model(session, tenant_id=tenant_id, agent_id=agent_id)
return dump_response(
ApiKeyItem,
self._create_api_key(str(app_model.id), tenant_id, session=session),
), 201
@console_ns.route("/agent/<uuid:agent_id>/api-keys/<uuid:api_key_id>")
class AgentApiKeyApi(Resource):
class AgentApiKeyApi(BaseApiKeyResource):
resource_type = ApiTokenType.APP
resource_model = App
resource_id_field = "app_id"
@console_ns.response(204, "Agent service API key deleted")
@console_account_admission(
allowed_roles=API_KEY_DELETE_ROLES,
rbac_checks=[RBACCheck(RBACPermission.AGENT_ACCESS_POINT_MANAGE, AgentId())],
)
@with_current_user
@with_current_tenant_id
@rbac_permission_required(RBACCheck(RBACPermission.AGENT_ACCESS_POINT_MANAGE, AgentId()))
@with_session
def delete(
self,
request_context: RequestContext,
session: Session,
tenant_id: str,
current_user: Account,
agent_id: UUID,
api_key_id: UUID,
) -> tuple[str, int]:
with api_key_errors():
application_services().app_api_keys.delete_agent_key(request_context, str(agent_id), str(api_key_id))
app_model = _resolve_agent_app_model(session, tenant_id=tenant_id, agent_id=agent_id)
self._delete_api_key(str(app_model.id), str(api_key_id), tenant_id, current_user, session=session)
return "", 204

View File

@@ -1,151 +1,390 @@
"""Console transport adapters for resource-scoped API key management."""
from collections.abc import Generator
from contextlib import contextmanager
from collections.abc import Iterable, Mapping
from datetime import datetime
from uuid import UUID
import flask_restx
from flask_restx import Resource
from flask_restx._http import HTTPStatus
from pydantic import field_validator
from sqlalchemy import func, or_, select
from sqlalchemy.orm import Session
from werkzeug.exceptions import Forbidden
from configs import dify_config
from controllers.common.rbac import AgentBehindApp, DatasetId, PlainApp, RBACCheck
from controllers.common.schema import register_response_schema_models
from controllers.console import console_ns
from controllers.console.flask_admission import console_account_admission
from controllers.console.wraps import RBACPermission
from extensions.ext_application_services import application_services
from fields.api_key_fields import ApiKeyItem, ApiKeyList, build_masked_api_key_list
from libs.helper import dump_response
from machinery.context import RequestContext
from models.account import TenantAccountRole
from services.agent.errors import AgentAccessNotReadyError
from services.app.api_key_service import AppApiKeyNotReadyError
from services.auth.api_key_contracts import (
ApiKeyLimitExceededError,
ApiKeyNotFoundError,
ApiKeyResourceNotFoundError,
)
from services.errors.base import NoPermissionError
from services.knowledge.api_key_service import UnknownDatasetIdsError
from controllers.common.session import with_session
from fields.base import ResponseModel
from libs.helper import dump_response, to_timestamp
from libs.login import login_required
from models import Account
from models.dataset import Dataset
from models.enums import ApiTokenType
from models.model import ApiToken, App
from services.api_token_service import ApiTokenCache
from services.app_service import AppService
from . import console_ns
from .wraps import (
RBACPermission,
account_initialization_required,
edit_permission_required,
rbac_permission_required,
setup_required,
with_current_tenant_id,
with_current_user,
)
class ApiKeyItem(ResponseModel):
id: str
type: str
token: str
# Dataset keys only: the knowledge bases this key is bound to. Empty = the key can
# access every dataset in the tenant (default). App keys are always empty.
dataset_ids: list[str] = []
last_used_at: int | None = None
created_at: int | None = None
@field_validator("last_used_at", "created_at", mode="before")
@classmethod
def _normalize_timestamp(cls, value: datetime | int | None) -> int | None:
return to_timestamp(value)
class ApiKeyList(ResponseModel):
data: list[ApiKeyItem]
API_KEY_EDIT_ROLES = frozenset({TenantAccountRole.OWNER, TenantAccountRole.ADMIN, TenantAccountRole.EDITOR})
API_KEY_DELETE_ROLES = frozenset({TenantAccountRole.OWNER, TenantAccountRole.ADMIN})
register_response_schema_models(console_ns, ApiKeyItem, ApiKeyList)
@contextmanager
def api_key_errors() -> Generator[None]:
"""Translate application failures without changing the Console error contract."""
try:
yield
except (ApiKeyResourceNotFoundError, ApiKeyNotFoundError) as error:
flask_restx.abort(404, message=str(error))
except ApiKeyLimitExceededError as error:
flask_restx.abort(400, message=str(error), custom="max_keys_exceeded")
except UnknownDatasetIdsError as error:
flask_restx.abort(400, message=str(error))
except NoPermissionError as error:
flask_restx.abort(403, message=str(error))
except AppApiKeyNotReadyError as error:
raise AgentAccessNotReadyError from error
def mask_api_token(token: str) -> str:
"""Mask a secret token for list responses.
Reveal-once: the full secret is only returned by the create endpoint. List
endpoints expose just enough (prefix + last 4) to identify a key, never the
full value, so an existing key's secret cannot be retrieved after creation.
"""
if len(token) <= 8:
return "***"
return f"{token[:5]}...{token[-4:]}"
def build_masked_api_key_list(
api_tokens: Iterable[ApiToken],
bindings_by_token: Mapping[str, list[str]] | None = None,
) -> ApiKeyList:
"""Build an ApiKeyList from ORM tokens with their secrets masked.
``bindings_by_token`` maps an api_token id to the dataset ids it is bound to
(from DatasetApiTokenBinding); tokens absent from the map are unbound (empty =
access all). App-key lists omit it entirely.
"""
bindings_by_token = bindings_by_token or {}
items: list[ApiKeyItem] = []
for api_token in api_tokens:
item = ApiKeyItem.model_validate(api_token, from_attributes=True)
item.token = mask_api_token(item.token)
item.dataset_ids = bindings_by_token.get(str(api_token.id), [])
items.append(item)
return ApiKeyList(data=items)
def _get_resource(resource_id, tenant_id, resource_model, *, session: Session):
resource = session.execute(
select(resource_model).filter_by(id=resource_id, tenant_id=tenant_id)
).scalar_one_or_none()
if resource is None:
flask_restx.abort(HTTPStatus.NOT_FOUND, message=f"{resource_model.__name__} not found.")
return resource
class BaseApiKeyListResource(Resource):
method_decorators = [account_initialization_required, login_required, setup_required]
resource_type: ApiTokenType | None = None
resource_model: type | None = None
resource_id_field: str | None = None
token_prefix: str | None = None
max_keys = 10
@with_session(write=False)
def get(self, session: Session, resource_id: str, current_tenant_id: str) -> dict[str, object]:
return dump_response(
ApiKeyList,
self._get_api_key_list(resource_id, current_tenant_id, session=session),
)
def _get_api_key_list(self, resource_id: str, current_tenant_id: str, *, session: Session) -> ApiKeyList:
assert self.resource_id_field is not None, "resource_id_field must be set"
_get_resource(resource_id, current_tenant_id, self.resource_model, session=session)
keys = session.scalars(
select(ApiToken).where(
or_(ApiToken.tenant_id == current_tenant_id, ApiToken.tenant_id.is_(None)),
ApiToken.type == self.resource_type,
getattr(ApiToken, self.resource_id_field) == resource_id,
)
).all()
# App and agent keys keep their existing (unmasked) list behavior; reveal-once
# masking is scoped to dataset keys, which build their list in datasets.py.
return ApiKeyList(data=[ApiKeyItem.model_validate(key, from_attributes=True) for key in keys])
@edit_permission_required
@with_session
def post(self, session: Session, resource_id: str, current_tenant_id: str) -> tuple[dict[str, object], int]:
return dump_response(
ApiKeyItem,
self._create_api_key(resource_id, current_tenant_id, session=session),
), 201
def _create_api_key(self, resource_id: str, current_tenant_id: str, *, session: Session) -> ApiToken:
assert self.resource_id_field is not None, "resource_id_field must be set"
resource = _get_resource(resource_id, current_tenant_id, self.resource_model, session=session)
if isinstance(resource, App):
AppService.ensure_agent_app_access_ready(resource, session=session)
current_key_count: int = (
session.scalar(
select(func.count(ApiToken.id)).where(
or_(ApiToken.tenant_id == current_tenant_id, ApiToken.tenant_id.is_(None)),
ApiToken.type == self.resource_type,
getattr(ApiToken, self.resource_id_field) == resource_id,
)
)
or 0
)
if current_key_count >= self.max_keys:
flask_restx.abort(
HTTPStatus.BAD_REQUEST,
message=f"Cannot create more than {self.max_keys} API keys for this resource type.",
custom="max_keys_exceeded",
)
key = ApiToken.generate_api_key(self.token_prefix or "", 24, session=session)
assert self.resource_type is not None, "resource_type must be set"
api_token = ApiToken()
setattr(api_token, self.resource_id_field, resource_id)
api_token.tenant_id = current_tenant_id
api_token.token = key
api_token.type = self.resource_type
session.add(api_token)
session.commit()
return api_token
class BaseApiKeyResource(Resource):
method_decorators = [account_initialization_required, login_required, setup_required]
resource_type: ApiTokenType | None = None
resource_model: type | None = None
resource_id_field: str | None = None
@with_session
def delete(
self,
session: Session,
resource_id: str,
api_key_id: str,
current_tenant_id: str,
current_user: Account,
) -> tuple[str, int]:
self._delete_api_key(resource_id, api_key_id, current_tenant_id, current_user, session=session)
return "", 204
def _delete_api_key(
self,
resource_id: str,
api_key_id: str,
current_tenant_id: str,
current_user: Account,
*,
session: Session,
) -> None:
assert self.resource_id_field is not None, "resource_id_field must be set"
_get_resource(resource_id, current_tenant_id, self.resource_model, session=session)
if not dify_config.RBAC_ENABLED and not current_user.is_admin_or_owner:
raise Forbidden()
key = session.scalar(
select(ApiToken)
.where(
or_(ApiToken.tenant_id == current_tenant_id, ApiToken.tenant_id.is_(None)),
getattr(ApiToken, self.resource_id_field) == resource_id,
ApiToken.type == self.resource_type,
ApiToken.id == api_key_id,
)
.limit(1)
)
if key is None:
flask_restx.abort(HTTPStatus.NOT_FOUND, message="API key not found")
# Invalidate cache before deleting from database
# Type assertion: key is guaranteed to be non-None here because abort() raises
assert key is not None # nosec - for type checker only
ApiTokenCache.delete(key.token, key.type)
session.delete(key)
session.commit()
@console_ns.route("/apps/<uuid:resource_id>/api-keys")
class AppApiKeyListResource(Resource):
class AppApiKeyListResource(BaseApiKeyListResource):
@console_ns.doc("get_app_api_keys")
@console_ns.doc(description="Get all API keys for an app")
@console_ns.doc(params={"resource_id": "App ID"})
@console_ns.response(200, "API keys retrieved successfully", console_ns.models[ApiKeyList.__name__])
@console_account_admission(
allowed_roles=API_KEY_EDIT_ROLES,
rbac_checks=[
RBACCheck(RBACPermission.APP_RELEASE_AND_VERSION, PlainApp("resource_id")),
RBACCheck(RBACPermission.AGENT_ACCESS_POINT_VIEW, AgentBehindApp("resource_id")),
],
@with_current_tenant_id
@edit_permission_required
@rbac_permission_required(
RBACCheck(RBACPermission.APP_RELEASE_AND_VERSION, PlainApp("resource_id")),
RBACCheck(RBACPermission.AGENT_ACCESS_POINT_VIEW, AgentBehindApp("resource_id")),
)
def get(self, request_context: RequestContext, resource_id: UUID) -> dict[str, object]:
with api_key_errors():
keys = application_services().app_api_keys.list_keys(request_context, str(resource_id))
return dump_response(ApiKeyList, {"data": keys})
@with_session(write=False)
def get(self, session: Session, current_tenant_id: str, resource_id: UUID) -> dict[str, object]:
"""Get all API keys for an app"""
return dump_response(
ApiKeyList,
self._get_api_key_list(str(resource_id), current_tenant_id, session=session), # pyrefly: ignore[unnecessary-type-conversion]
)
@console_ns.doc("create_app_api_key")
@console_ns.doc(description="Create a new API key for an app")
@console_ns.doc(params={"resource_id": "App ID"})
@console_ns.response(201, "API key created successfully", console_ns.models[ApiKeyItem.__name__])
@console_ns.response(400, "Maximum keys exceeded")
@console_account_admission(
allowed_roles=API_KEY_EDIT_ROLES,
rbac_checks=[
RBACCheck(RBACPermission.APP_RELEASE_AND_VERSION, PlainApp("resource_id")),
RBACCheck(RBACPermission.AGENT_ACCESS_POINT_MANAGE, AgentBehindApp("resource_id")),
],
@with_current_tenant_id
@edit_permission_required
@rbac_permission_required(
RBACCheck(RBACPermission.APP_RELEASE_AND_VERSION, PlainApp("resource_id")),
RBACCheck(RBACPermission.AGENT_ACCESS_POINT_MANAGE, AgentBehindApp("resource_id")),
)
def post(self, request_context: RequestContext, resource_id: UUID) -> tuple[dict[str, object], int]:
with api_key_errors():
key = application_services().app_api_keys.create_key(request_context, str(resource_id))
return dump_response(ApiKeyItem, key), 201
@with_session
def post(self, session: Session, current_tenant_id: str, resource_id: UUID) -> tuple[dict[str, object], int]:
"""Create a new API key for an app"""
return dump_response(
ApiKeyItem,
self._create_api_key(str(resource_id), current_tenant_id, session=session), # pyrefly: ignore[unnecessary-type-conversion]
), 201
resource_type = ApiTokenType.APP
resource_model = App
resource_id_field = "app_id"
token_prefix = "app-"
@console_ns.route("/apps/<uuid:resource_id>/api-keys/<uuid:api_key_id>")
class AppApiKeyResource(Resource):
class AppApiKeyResource(BaseApiKeyResource):
@console_ns.doc("delete_app_api_key")
@console_ns.doc(description="Delete an API key for an app")
@console_ns.doc(params={"resource_id": "App ID", "api_key_id": "API key ID"})
@console_ns.response(204, "API key deleted successfully")
@console_account_admission(
allowed_roles=API_KEY_DELETE_ROLES,
rbac_checks=[
RBACCheck(RBACPermission.APP_RELEASE_AND_VERSION, PlainApp("resource_id")),
RBACCheck(RBACPermission.AGENT_ACCESS_POINT_MANAGE, AgentBehindApp("resource_id")),
],
@with_current_user
@with_current_tenant_id
@rbac_permission_required(
RBACCheck(RBACPermission.APP_RELEASE_AND_VERSION, PlainApp("resource_id")),
RBACCheck(RBACPermission.AGENT_ACCESS_POINT_MANAGE, AgentBehindApp("resource_id")),
)
def delete(self, request_context: RequestContext, resource_id: UUID, api_key_id: UUID) -> tuple[str, int]:
with api_key_errors():
application_services().app_api_keys.delete_key(request_context, str(resource_id), str(api_key_id))
@with_session
def delete(
self,
session: Session,
current_tenant_id: str,
current_user: Account,
resource_id: UUID,
api_key_id: UUID,
) -> tuple[str, int]:
"""Delete an API key for an app"""
self._delete_api_key(
str(resource_id), # pyrefly: ignore[unnecessary-type-conversion]
str(api_key_id),
current_tenant_id,
current_user,
session=session,
)
return "", 204
resource_type = ApiTokenType.APP
resource_model = App
resource_id_field = "app_id"
# Dataset service-API keys are also managed at the workspace level (create with a set of
# knowledge bases, list, delete) by DatasetApiKeyApi in
# controllers/console/datasets/datasets.py, using DatasetApiTokenBinding for scoping.
# The per-dataset routes below remain for callers that key an API token to a single dataset.
@console_ns.route("/datasets/<uuid:resource_id>/api-keys")
class DatasetApiKeyListResource(Resource):
class DatasetApiKeyListResource(BaseApiKeyListResource):
@console_ns.doc("get_dataset_api_keys")
@console_ns.doc(description="Get all API keys for a dataset")
@console_ns.doc(params={"resource_id": "Dataset ID"})
@console_ns.response(200, "API keys retrieved successfully", console_ns.models[ApiKeyList.__name__])
@console_account_admission(
allowed_roles=API_KEY_EDIT_ROLES,
rbac_checks=[RBACCheck(RBACPermission.DATASET_API_KEY_MANAGE, DatasetId("resource_id"))],
)
def get(self, request_context: RequestContext, resource_id: UUID) -> dict[str, object]:
with api_key_errors():
keys = application_services().dataset_api_keys.list_keys(request_context, str(resource_id))
return dump_response(ApiKeyList, build_masked_api_key_list(keys))
@with_current_tenant_id
@edit_permission_required
@rbac_permission_required(RBACCheck(RBACPermission.DATASET_API_KEY_MANAGE, DatasetId("resource_id")))
@with_session(write=False)
def get(self, session: Session, current_tenant_id: str, resource_id: UUID) -> dict[str, object]:
"""Get all API keys for a dataset"""
return dump_response(
ApiKeyList,
self._get_api_key_list(str(resource_id), current_tenant_id, session=session), # pyrefly: ignore[unnecessary-type-conversion]
)
@console_ns.doc("create_dataset_api_key")
@console_ns.doc(description="Create a new API key for a dataset")
@console_ns.doc(params={"resource_id": "Dataset ID"})
@console_ns.response(201, "API key created successfully", console_ns.models[ApiKeyItem.__name__])
@console_ns.response(400, "Maximum keys exceeded")
@console_account_admission(
allowed_roles=API_KEY_EDIT_ROLES,
rbac_checks=[RBACCheck(RBACPermission.DATASET_API_KEY_MANAGE, DatasetId("resource_id"))],
)
def post(self, request_context: RequestContext, resource_id: UUID) -> tuple[dict[str, object], int]:
with api_key_errors():
key = application_services().dataset_api_keys.create_key(request_context, str(resource_id))
return dump_response(ApiKeyItem, key), 201
@with_current_tenant_id
@edit_permission_required
@rbac_permission_required(RBACCheck(RBACPermission.DATASET_API_KEY_MANAGE, DatasetId("resource_id")))
@with_session
def post(self, session: Session, current_tenant_id: str, resource_id: UUID) -> tuple[dict[str, object], int]:
"""Create a new API key for a dataset"""
return dump_response(
ApiKeyItem,
self._create_api_key(str(resource_id), current_tenant_id, session=session), # pyrefly: ignore[unnecessary-type-conversion]
), 201
resource_type = ApiTokenType.DATASET
resource_model = Dataset
resource_id_field = "dataset_id"
token_prefix = "ds-"
@console_ns.route("/datasets/<uuid:resource_id>/api-keys/<uuid:api_key_id>")
class DatasetApiKeyResource(Resource):
class DatasetApiKeyResource(BaseApiKeyResource):
@console_ns.doc("delete_dataset_api_key")
@console_ns.doc(description="Delete an API key for a dataset")
@console_ns.doc(params={"resource_id": "Dataset ID", "api_key_id": "API key ID"})
@console_ns.response(204, "API key deleted successfully")
@console_account_admission(
allowed_roles=API_KEY_DELETE_ROLES,
rbac_checks=[RBACCheck(RBACPermission.DATASET_API_KEY_MANAGE, DatasetId("resource_id"))],
)
def delete(self, request_context: RequestContext, resource_id: UUID, api_key_id: UUID) -> tuple[str, int]:
with api_key_errors():
application_services().dataset_api_keys.delete_key(request_context, str(resource_id), str(api_key_id))
@with_current_user
@with_current_tenant_id
@rbac_permission_required(RBACCheck(RBACPermission.DATASET_API_KEY_MANAGE, DatasetId("resource_id")))
@with_session
def delete(
self,
session: Session,
current_tenant_id: str,
current_user: Account,
resource_id: UUID,
api_key_id: UUID,
) -> tuple[str, int]:
"""Delete an API key for a dataset"""
self._delete_api_key(
str(resource_id), # pyrefly: ignore[unnecessary-type-conversion]
str(api_key_id),
current_tenant_id,
current_user,
session=session,
)
return "", 204
resource_type = ApiTokenType.DATASET
resource_model = Dataset
resource_id_field = "dataset_id"

View File

@@ -9,11 +9,10 @@ from controllers.common.schema import (
register_response_schema_models,
)
from controllers.console import console_ns
from controllers.console.flask_admission import console_account_admission
from controllers.console.wraps import validate_request
from extensions.ext_application_services import application_services
from controllers.console.wraps import account_initialization_required, model_validate, setup_required
from fields.base import ResponseModel
from machinery.context import RequestContext
from libs.login import login_required
from services.advanced_prompt_template_service import AdvancedPromptTemplateArgs, AdvancedPromptTemplateService
class AdvancedPromptTemplateQuery(BaseModel):
@@ -46,12 +45,15 @@ class AdvancedPromptTemplateList(Resource):
console_ns.models[AdvancedPromptTemplateResponse.__name__],
)
@console_ns.response(400, "Invalid request parameters")
@console_account_admission()
def get(self, context: RequestContext):
req_data = validate_request(AdvancedPromptTemplateQuery)
result = application_services().advanced_prompt_templates.get_prompt(
app_mode=req_data.app_mode,
model_mode=req_data.model_mode,
has_context=req_data.has_context,
)
return AdvancedPromptTemplateResponse.model_validate(result).model_dump(mode="json", exclude_unset=True)
@setup_required
@login_required
@account_initialization_required
@model_validate(AdvancedPromptTemplateQuery)
def get(self, req_data: AdvancedPromptTemplateQuery):
prompt_args: AdvancedPromptTemplateArgs = {
"app_mode": req_data.app_mode,
"model_mode": req_data.model_mode,
"model_name": req_data.model_name,
"has_context": req_data.has_context,
}
return AdvancedPromptTemplateService.get_prompt(prompt_args)

View File

@@ -9,18 +9,23 @@ from uuid import UUID
from flask_restx import Resource
from pydantic import Field
from sqlalchemy.orm import Session
from controllers.common.rbac import AgentId, RBACCheck
from controllers.common.schema import register_response_schema_models
from controllers.common.session import with_session
from controllers.console import console_ns
from controllers.console.flask_admission import console_account_admission
from controllers.console.wraps import RBACPermission
from extensions.ext_application_services import application_services
from controllers.console.agent.app_helpers import resolve_agent_app_model
from controllers.console.wraps import (
RBACPermission,
account_initialization_required,
rbac_permission_required,
setup_required,
with_current_tenant_id,
)
from fields.base import ResponseModel
from libs.helper import dump_response
from machinery.context import RequestContext
from services.agent.errors import AgentNotFoundError
from services.app.agent_app_contracts import AgentAppNotFoundError
from libs.login import login_required
from services.agent.roster_service import AgentRosterService
class AgentReferencingWorkflowResponse(ResponseModel):
@@ -54,10 +59,17 @@ class AgentAppReferencingWorkflowsResource(Resource):
console_ns.models[AgentReferencingWorkflowsResponse.__name__],
)
@console_ns.response(404, "Agent not found")
@console_account_admission(rbac_checks=(RBACCheck(RBACPermission.AGENT_PREVIEW, AgentId()),))
def get(self, context: RequestContext, agent_id: UUID):
try:
workflows = application_services().agent_apps.access.list_referencing_workflows(context, str(agent_id))
except AgentAppNotFoundError as exc:
raise AgentNotFoundError from exc
return dump_response(AgentReferencingWorkflowsResponse, {"data": workflows})
@setup_required
@login_required
@account_initialization_required
@rbac_permission_required(RBACCheck(RBACPermission.AGENT_PREVIEW, AgentId()))
@with_current_tenant_id
@with_session(write=False)
def get(self, session: Session, tenant_id: str, agent_id: UUID):
app_model = resolve_agent_app_model(session=session, tenant_id=tenant_id, agent_id=agent_id)
workflows = AgentRosterService(session).list_workflows_referencing_app_agent(
tenant_id=tenant_id, app_id=app_model.id
)
return AgentReferencingWorkflowsResponse(
data=[AgentReferencingWorkflowResponse.model_validate(workflow) for workflow in workflows]
).model_dump(mode="json")

View File

@@ -13,24 +13,34 @@ from uuid import UUID
from flask_restx import Resource
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from controllers.common.fields import SimpleResultResponse
from controllers.common.rbac import AgentId, 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.flask_admission import console_account_admission
from controllers.console.wraps import RBACPermission, validate_request
from extensions.ext_application_services import application_services
from machinery.context import RequestContext
from models.account import TenantAccountRole
from controllers.console.agent.app_helpers import resolve_agent_runtime_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,
with_current_user,
)
from events.app_event import app_model_config_was_updated
from libs.login import login_required
from models import Account
from models.agent_config_entities import (
AgentFeatureToggleConfig,
AgentSensitiveWordAvoidanceFeatureConfig,
AgentSuggestedQuestionsAfterAnswerFeatureConfig,
AgentTextToSpeechFeatureConfig,
)
from services.agent.errors import AgentNotFoundError
from services.app.agent_app_contracts import AgentAppNotFoundError
from services.agent_app_feature_service import AgentAppFeatureConfigService
class AgentAppFeaturesPayload(BaseModel):
@@ -70,16 +80,37 @@ class AgentAppFeatureConfigResource(Resource):
@console_ns.response(200, "Features updated successfully", console_ns.models[SimpleResultResponse.__name__])
@console_ns.response(400, "Invalid configuration")
@console_ns.response(404, "Agent not found")
@console_account_admission(
allowed_roles=frozenset({TenantAccountRole.OWNER, TenantAccountRole.ADMIN, TenantAccountRole.EDITOR}),
rbac_checks=(RBACCheck(RBACPermission.AGENT_EDIT, AgentId()),),
)
def post(self, context: RequestContext, agent_id: UUID):
payload = validate_request(AgentAppFeaturesPayload)
try:
application_services().agent_apps.features.update_features(
context, str(agent_id), payload.model_dump(exclude_none=True)
)
except AgentAppNotFoundError as exc:
raise AgentNotFoundError from exc
@setup_required
@login_required
@edit_permission_required
@rbac_permission_required(RBACCheck(RBACPermission.AGENT_EDIT, AgentId()))
@account_initialization_required
@with_current_user
@with_current_tenant_id
@with_session
@model_validate(AgentAppFeaturesPayload)
def post(
self,
req_data: AgentAppFeaturesPayload,
session: Session,
tenant_id: str,
current_user: Account,
agent_id: UUID,
):
app_model = resolve_agent_runtime_app_model(session=session, tenant_id=tenant_id, agent_id=agent_id)
new_app_model_config = AgentAppFeatureConfigService.update_features(
app_model=app_model,
account=current_user,
config=req_data.model_dump(exclude_none=True),
session=session,
)
app_model_config_was_updated.send(
app_model,
app_model_config=new_app_model_config,
session=session,
)
session.commit()
return SimpleResultResponse(result="success").model_dump(mode="json")

View File

@@ -24,21 +24,25 @@ from controllers.common.schema import (
)
from controllers.console import console_ns
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 controllers.console.app.wraps import get_app_model
from controllers.console.wraps import (
RBACPermission,
account_initialization_required,
model_validate,
rbac_permission_required,
setup_required,
with_current_tenant_id,
with_current_user,
)
from extensions.ext_database import db
from fields.base import ResponseModel
from libs.helper import dump_response
from machinery.context import RequestContext
from services.agent.errors import AgentNotFoundError
from services.app.agent_app_contracts import (
AgentAppNotFoundError,
AgentSandboxBindingNotFoundError,
AgentSandboxCaller,
AgentSandboxDownloadUnavailableError,
AgentSandboxUnavailableError,
WorkflowSandboxAppNotFoundError,
WorkflowSandboxCaller,
from libs.login import login_required
from models import Account
from models.model import App, AppMode
from services.agent_app_sandbox_service import (
AgentAppSandboxService,
AgentSandboxInspectorError,
WorkflowAgentSandboxService,
)
_BINDING_PATH_DESCRIPTION = (
@@ -130,16 +134,8 @@ register_response_schema_models(
def _handle(exc: Exception) -> tuple[dict[str, object], int]:
if isinstance(exc, AgentAppNotFoundError):
raise AgentNotFoundError from exc
if isinstance(exc, WorkflowSandboxAppNotFoundError):
raise AppNotFoundError from exc
if isinstance(exc, AgentSandboxBindingNotFoundError):
return {"code": "no_active_binding", "message": str(exc)}, 404
if isinstance(exc, AgentSandboxUnavailableError):
return {"code": "inspector_unavailable", "message": str(exc)}, 503
if isinstance(exc, AgentSandboxDownloadUnavailableError):
return {"code": "binding_file_download_unavailable", "message": str(exc)}, 502
if isinstance(exc, AgentSandboxInspectorError):
return {"code": exc.code, "message": exc.message}, exc.status_code
if isinstance(exc, DifyAgentHTTPError) and backend_reported_failure(exc):
code, message = backend_error_detail(exc)
return {"code": code, "message": message}, exc.status_code
@@ -154,16 +150,28 @@ class AgentAppSandboxInfoResource(Resource):
@console_ns.doc(description="Get basic information for an Agent App conversation sandbox")
@console_ns.doc(params={"agent_id": "Agent ID", **query_params_from_model(AgentSandboxInfoQuery)})
@console_ns.response(200, "Sandbox information returned", console_ns.models[SandboxInfoResponse.__name__])
@console_account_admission(rbac_checks=(RBACCheck(RBACPermission.AGENT_PREVIEW, AgentId()),))
def get(self, context: RequestContext, agent_id: UUID):
@setup_required
@login_required
@account_initialization_required
@rbac_permission_required(RBACCheck(RBACPermission.AGENT_PREVIEW, AgentId()))
@with_current_tenant_id
@with_current_user
def get(self, current_user: Account, tenant_id: str, agent_id: UUID):
service = AgentAppSandboxService()
app_id = service.resolve_app_id(tenant_id=tenant_id, agent_id=str(agent_id))
query = query_params_from_request(AgentSandboxInfoQuery)
try:
result = application_services().agent_apps.sandbox.get_info(
context, AgentSandboxCaller(str(agent_id), query.caller_type, query.caller_id)
result = service.get_info(
tenant_id=tenant_id,
app_id=app_id,
agent_id=str(agent_id),
caller_type=query.caller_type,
caller_id=query.caller_id,
account_id=current_user.id,
)
except Exception as exc:
return _handle(exc)
return dump_response(SandboxInfoResponse, result)
return result.model_dump()
@console_ns.route("/agent/<uuid:agent_id>/sandbox/files")
@@ -172,16 +180,29 @@ class AgentAppSandboxListResource(Resource):
@console_ns.doc(description="List a directory in an Agent App conversation sandbox")
@console_ns.doc(params={"agent_id": "Agent ID", **query_params_from_model(AgentSandboxListQuery)})
@console_ns.response(200, "Listing returned", console_ns.models[SandboxListResponse.__name__])
@console_account_admission(rbac_checks=(RBACCheck(RBACPermission.AGENT_PREVIEW, AgentId()),))
def get(self, context: RequestContext, agent_id: UUID):
@setup_required
@login_required
@account_initialization_required
@rbac_permission_required(RBACCheck(RBACPermission.AGENT_PREVIEW, AgentId()))
@with_current_tenant_id
@with_current_user
def get(self, current_user: Account, tenant_id: str, agent_id: UUID):
service = AgentAppSandboxService()
app_id = service.resolve_app_id(tenant_id=tenant_id, agent_id=str(agent_id))
query = query_params_from_request(AgentSandboxListQuery)
try:
result = application_services().agent_apps.sandbox.list_files(
context, AgentSandboxCaller(str(agent_id), query.caller_type, query.caller_id), query.path
result = service.list_files(
tenant_id=tenant_id,
app_id=app_id,
agent_id=str(agent_id),
caller_type=query.caller_type,
caller_id=query.caller_id,
account_id=current_user.id,
path=query.path,
)
except Exception as exc:
return _handle(exc)
return dump_response(SandboxListResponse, result)
return result.model_dump()
@console_ns.route("/agent/<uuid:agent_id>/sandbox/files/read")
@@ -190,16 +211,29 @@ class AgentAppSandboxReadResource(Resource):
@console_ns.doc(description="Read a text/binary preview file in an Agent App conversation sandbox")
@console_ns.doc(params={"agent_id": "Agent ID", **query_params_from_model(AgentSandboxFileQuery)})
@console_ns.response(200, "Preview returned", console_ns.models[SandboxReadResponse.__name__])
@console_account_admission(rbac_checks=(RBACCheck(RBACPermission.AGENT_PREVIEW, AgentId()),))
def get(self, context: RequestContext, agent_id: UUID):
@setup_required
@login_required
@account_initialization_required
@rbac_permission_required(RBACCheck(RBACPermission.AGENT_PREVIEW, AgentId()))
@with_current_tenant_id
@with_current_user
def get(self, current_user: Account, tenant_id: str, agent_id: UUID):
service = AgentAppSandboxService()
app_id = service.resolve_app_id(tenant_id=tenant_id, agent_id=str(agent_id))
query = query_params_from_request(AgentSandboxFileQuery)
try:
result = application_services().agent_apps.sandbox.read_file(
context, AgentSandboxCaller(str(agent_id), query.caller_type, query.caller_id), query.path
result = service.read_file(
tenant_id=tenant_id,
app_id=app_id,
agent_id=str(agent_id),
caller_type=query.caller_type,
caller_id=query.caller_id,
account_id=current_user.id,
path=query.path,
)
except Exception as exc:
return _handle(exc)
return dump_response(SandboxReadResponse, result)
return result.model_dump()
@console_ns.route("/agent/<uuid:agent_id>/sandbox/files/download")
@@ -208,16 +242,35 @@ class AgentAppSandboxDownloadResource(Resource):
@console_ns.doc(description="Create a ToolFile from one Agent App Binding file and return its download URL")
@console_ns.expect(console_ns.models[AgentSandboxDownloadPayload.__name__])
@console_ns.response(200, "Download URL returned", console_ns.models[SandboxDownloadResponse.__name__])
@console_account_admission(rbac_checks=(RBACCheck(RBACPermission.AGENT_EDIT, AgentId()),))
def post(self, context: RequestContext, agent_id: UUID):
query = validate_request(AgentSandboxDownloadPayload)
@setup_required
@login_required
@account_initialization_required
@rbac_permission_required(RBACCheck(RBACPermission.AGENT_EDIT, AgentId()))
@with_current_tenant_id
@with_current_user
@model_validate(AgentSandboxDownloadPayload)
def post(
self,
req_data: AgentSandboxDownloadPayload,
current_user: Account,
tenant_id: str,
agent_id: UUID,
):
service = AgentAppSandboxService()
app_id = service.resolve_app_id(tenant_id=tenant_id, agent_id=str(agent_id))
try:
result = application_services().agent_apps.sandbox.download_file(
context, AgentSandboxCaller(str(agent_id), query.caller_type, query.caller_id), query.path
result = service.download_file(
tenant_id=tenant_id,
app_id=app_id,
agent_id=str(agent_id),
caller_type=req_data.caller_type,
caller_id=req_data.caller_id,
account_id=current_user.id,
path=req_data.path,
)
except Exception as exc:
return _handle(exc)
return dump_response(SandboxDownloadResponse, result)
return result.model_dump()
@console_ns.route("/apps/<uuid:app_id>/workflow-runs/<uuid:workflow_run_id>/agent-nodes/<string:node_id>/sandbox/files")
@@ -233,18 +286,27 @@ class WorkflowAgentSandboxListResource(Resource):
}
)
@console_ns.response(200, "Listing returned", console_ns.models[SandboxListResponse.__name__])
@console_account_admission(rbac_checks=(RBACCheck(RBACPermission.APP_VIEW_LAYOUT, PlainApp()),))
def get(self, context: RequestContext, app_id: UUID, workflow_run_id: UUID, node_id: str):
@setup_required
@login_required
@account_initialization_required
@rbac_permission_required(RBACCheck(RBACPermission.APP_VIEW_LAYOUT, PlainApp()))
@get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW])
@with_current_tenant_id
def get(self, tenant_id: str, app_model: App, workflow_run_id: UUID, node_id: str):
query = query_params_from_request(WorkflowAgentSandboxListQuery)
try:
result = application_services().agent_apps.sandbox.list_files(
context,
WorkflowSandboxCaller(str(app_id), str(workflow_run_id), node_id, query.node_execution_id),
query.path,
result = WorkflowAgentSandboxService().list_files(
tenant_id=tenant_id,
app_id=app_model.id,
workflow_run_id=str(workflow_run_id),
node_id=node_id,
node_execution_id=query.node_execution_id,
path=query.path,
session=db.session(),
)
except Exception as exc:
return _handle(exc)
return dump_response(SandboxListResponse, result)
return result.model_dump()
@console_ns.route(
@@ -262,18 +324,27 @@ class WorkflowAgentSandboxReadResource(Resource):
}
)
@console_ns.response(200, "Preview returned", console_ns.models[SandboxReadResponse.__name__])
@console_account_admission(rbac_checks=(RBACCheck(RBACPermission.APP_VIEW_LAYOUT, PlainApp()),))
def get(self, context: RequestContext, app_id: UUID, workflow_run_id: UUID, node_id: str):
@setup_required
@login_required
@account_initialization_required
@rbac_permission_required(RBACCheck(RBACPermission.APP_VIEW_LAYOUT, PlainApp()))
@get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW])
@with_current_tenant_id
def get(self, tenant_id: str, app_model: App, workflow_run_id: UUID, node_id: str):
query = query_params_from_request(WorkflowAgentSandboxFileQuery)
try:
result = application_services().agent_apps.sandbox.read_file(
context,
WorkflowSandboxCaller(str(app_id), str(workflow_run_id), node_id, query.node_execution_id),
query.path,
result = WorkflowAgentSandboxService().read_file(
tenant_id=tenant_id,
app_id=app_model.id,
workflow_run_id=str(workflow_run_id),
node_id=node_id,
node_execution_id=query.node_execution_id,
path=query.path,
session=db.session(),
)
except Exception as exc:
return _handle(exc)
return dump_response(SandboxReadResponse, result)
return result.model_dump()
@console_ns.route(
@@ -284,15 +355,36 @@ class WorkflowAgentSandboxDownloadResource(Resource):
@console_ns.doc(description="Create a ToolFile from one workflow Agent Binding file and return its download URL")
@console_ns.expect(console_ns.models[WorkflowAgentSandboxDownloadPayload.__name__])
@console_ns.response(200, "Download URL returned", console_ns.models[SandboxDownloadResponse.__name__])
@console_account_admission(rbac_checks=(RBACCheck(RBACPermission.APP_VIEW_LAYOUT, PlainApp()),))
def post(self, context: RequestContext, app_id: UUID, workflow_run_id: UUID, node_id: str):
query = validate_request(WorkflowAgentSandboxDownloadPayload)
@setup_required
@login_required
@account_initialization_required
@rbac_permission_required(RBACCheck(RBACPermission.APP_VIEW_LAYOUT, PlainApp()))
@with_current_user
@with_current_tenant_id
@model_validate(WorkflowAgentSandboxDownloadPayload)
def post(
self,
req_data: WorkflowAgentSandboxDownloadPayload,
tenant_id: str,
current_user: Account,
app_id: UUID,
workflow_run_id: UUID,
node_id: str,
):
service = WorkflowAgentSandboxService()
resolved_app_id = service.resolve_app_id(tenant_id=tenant_id, app_id=str(app_id))
if resolved_app_id is None:
raise AppNotFoundError()
try:
result = application_services().agent_apps.sandbox.download_file(
context,
WorkflowSandboxCaller(str(app_id), str(workflow_run_id), node_id, query.node_execution_id),
query.path,
result = service.download_file(
tenant_id=tenant_id,
app_id=resolved_app_id,
workflow_run_id=str(workflow_run_id),
node_id=node_id,
node_execution_id=req_data.node_execution_id,
account_id=current_user.id,
path=req_data.path,
)
except Exception as exc:
return _handle(exc)
return dump_response(SandboxDownloadResponse, result)
return result.model_dump()

View File

@@ -7,16 +7,14 @@ import io
import json
from collections.abc import Callable, Mapping
from dataclasses import dataclass
from functools import wraps
from typing import Any, Concatenate, Literal
from typing import Any, Literal
from uuid import UUID
from flask import Response, make_response, request, send_file, url_for
from flask import Response, request, send_file, url_for
from flask_restx import Resource
from pydantic import BaseModel, ConfigDict, Field
from pydantic import BaseModel, 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,
@@ -27,8 +25,6 @@ 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,
@@ -40,11 +36,7 @@ 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
@@ -58,8 +50,6 @@ 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):
@@ -225,7 +215,6 @@ class AgentConfigDeleteResponse(ResponseModel):
register_schema_models(console_ns, AgentConfigFileUploadPayload)
register_response_schema_models(
console_ns,
BinaryFileResponse,
AgentConfigDeleteResponse,
AgentConfigDownloadResponse,
AgentConfigFileItemResponse,
@@ -250,7 +239,7 @@ register_response_schema_models(
class _ResolvedConsoleTarget:
tenant_id: str
agent_id: str
account_id: str | None
account_id: str
version_id: str
version_kind: AgentConfigVersionKind
@@ -258,51 +247,6 @@ 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()
@@ -537,7 +481,6 @@ 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,
@@ -574,7 +517,6 @@ 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,
@@ -674,7 +616,6 @@ 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,
@@ -713,7 +654,6 @@ 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,
@@ -1420,124 +1360,5 @@ 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)]

File diff suppressed because it is too large Load Diff

View File

@@ -1,37 +1,92 @@
from http import HTTPStatus
from typing import BinaryIO, cast
from typing import BinaryIO, Literal, cast
from uuid import uuid4
from flask import request
from flask_restx import Resource
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from werkzeug.exceptions import Forbidden
from configs import dify_config
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
from machinery.context import RequestContext
from models.account import TenantAccountRole
from controllers.common.schema import register_enum_models, register_response_schema_models, register_schema_models
from controllers.console.app.wraps import get_app_model
from controllers.console.wraps import (
RBACPermission,
account_initialization_required,
cloud_edition_billing_resource_check,
edit_permission_required,
rbac_permission_required,
setup_required,
validate_request,
with_current_user,
)
from core.plugin.entities.plugin import PluginDependency
from extensions.ext_database import db
from extensions.ext_redis import redis_client
from fields.base import ResponseModel
from libs.login import current_account_with_tenant, login_required
from models.account import Account
from models.model import App, AppMode
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.base import NoPermissionError
from services.recommended_app_query_service import RecommendedAppNotFoundError
from services.agent.roster_package_importer import RosterAgentPackageImporter
from services.app_dsl_service import (
IMPORT_INFO_REDIS_KEY_PREFIX,
AppDslService,
Import,
PendingData,
)
from services.app_import_source import download_app_import_source, try_read_yaml
from services.app_package_service import AppPackageService, PreparedAppPackage
from services.enterprise.enterprise_service import EnterpriseService
from services.entities.dsl_entities import CheckDependenciesResult, ImportStatus
from services.errors.account import NoPermissionError
from services.system_feature_service import SystemFeatureService
from .. import console_ns
from .permission_keys import get_app_permission_keys
class AppImportPayload(AppImportParams):
pass
class AppImportPayload(BaseModel):
mode: str = Field(..., description="Import mode")
yaml_content: str | None = Field(None)
yaml_url: str | None = Field(None)
name: str | None = Field(None)
description: str | None = Field(None)
icon_type: str | None = Field(None)
icon: str | None = Field(None)
icon_background: str | None = Field(None)
app_id: str | None = Field(None)
class RosterAgentPackageConflictResponse(ResponseModel):
code: str
message: str
status: Literal[409] = 409
leaked_dependencies: list[PluginDependency] = Field(default_factory=list)
register_enum_models(console_ns, ImportStatus)
register_schema_models(console_ns, AppImportPayload, Import, CheckDependenciesResult)
register_response_schema_models(console_ns, RosterAgentPackageConflictResponse)
_EDIT_ROLES = frozenset({TenantAccountRole.OWNER, TenantAccountRole.ADMIN, TenantAccountRole.EDITOR})
def _current_user_and_tenant_id(current_user: Account | None) -> tuple[Account, str | None]:
if current_user is None:
account, tenant_id = current_account_with_tenant()
return account, str(tenant_id) if tenant_id else None
current_tenant_id = getattr(current_user, "current_tenant_id", None)
if current_tenant_id:
return current_user, str(current_tenant_id)
current_tenant = getattr(current_user, "current_tenant", None)
current_tenant_object_id = getattr(current_tenant, "id", None)
if current_tenant_object_id:
return current_user, str(current_tenant_object_id)
account, fallback_tenant_id = current_account_with_tenant()
return account, str(fallback_tenant_id) if fallback_tenant_id else None
@console_ns.route("/apps/imports")
@@ -65,68 +120,218 @@ class AppImportApi(Resource):
}
},
)
@console_ns.response(HTTPStatus.OK, "Import completed", console_ns.models[Import.__name__])
@console_ns.response(HTTPStatus.ACCEPTED, "Import pending confirmation", console_ns.models[Import.__name__])
@console_ns.response(HTTPStatus.BAD_REQUEST, "Import failed", console_ns.models[Import.__name__])
@console_ns.response(HTTPStatus.FORBIDDEN, "Insufficient import permissions")
@console_ns.response(HTTPStatus.CONFLICT, "Agent name conflict")
@console_ns.response(HTTPStatus.REQUEST_ENTITY_TOO_LARGE, "Roster Agent package exceeds the size limit")
@console_account_admission(allowed_roles=_EDIT_ROLES)
def post(self, context: RequestContext):
source: BinaryIO | None = None
@console_ns.response(200, "Import completed", console_ns.models[Import.__name__])
@console_ns.response(202, "Import pending confirmation", console_ns.models[Import.__name__])
@console_ns.response(400, "Import failed", console_ns.models[Import.__name__])
@console_ns.response(403, "Insufficient import or plugin installation permissions")
@console_ns.response(
409, "Agent name conflict or missing plugins", console_ns.models[RosterAgentPackageConflictResponse.__name__]
)
@console_ns.response(413, "Roster Agent package exceeds the size limit")
@setup_required
@login_required
@account_initialization_required
@edit_permission_required
@with_current_user
def post(self, current_user: Account):
if request.mimetype == "multipart/form-data":
return self._import_package(current_user)
payload = validate_request(AppImportPayload)
if payload.mode == "yaml-url" and payload.yaml_url:
return self._import_url(payload, current_user)
return self._import_dsl(payload, current_user)
@rbac_permission_required(
RBACCheck(RBACPermission.APP_IMPORT_EXPORT_DSL, Workspace()),
RBACCheck(RBACPermission.AGENT_IMPORT_EXPORT_DSL, Workspace()),
)
def _import_url(self, payload: AppImportPayload, current_user: Account):
with download_app_import_source(payload.yaml_url or "") as source:
content = try_read_yaml(source)
if content is not None:
return self._import_dsl(
payload.model_copy(update={"mode": "yaml-content", "yaml_content": content, "yaml_url": None}),
current_user,
)
return self._import_package(current_user, payload, source)
def _import_package(
self, current_user: Account, payload: AppImportPayload | None = None, source: BinaryIO | None = None
):
if source is None:
uploaded = request.files.get("file")
if uploaded is None or not uploaded.filename:
raise InvalidRosterAgentPackageError("App package file is required")
if not uploaded.filename.lower().endswith(".ifpkg"):
raise InvalidRosterAgentPackageError("App package file must use the .ifpkg extension")
source = cast(BinaryIO, uploaded.stream)
payload = AppImportPayload.model_validate({**request.form.to_dict(), "mode": "yaml-content"})
else:
payload = validate_request(AppImportPayload)
try:
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
)
return result.model_dump(mode="json"), status_code
package = AppPackageService().read_package(source)
if package is not None:
with package:
if payload is None:
payload = AppImportPayload.model_validate({**request.form.to_dict(), "mode": "yaml-content"})
return self._import_dsl(
payload.model_copy(update={"mode": "yaml-content", "yaml_content": package.dsl, "yaml_url": None}),
current_user,
package=package,
)
return self._import_agent_package(current_user, source, payload)
@rbac_permission_required(RBACCheck(RBACPermission.AGENT_CREATE, Workspace()))
@rbac_permission_required(RBACCheck(RBACPermission.AGENT_IMPORT_EXPORT_DSL, Workspace()))
def _import_agent_package(self, current_user: Account, source: BinaryIO, payload: AppImportPayload | None = None):
app_id = payload.app_id if payload is not None else request.form.get("app_id")
if app_id:
raise InvalidRosterAgentPackageError("Roster Agent package import does not support overwriting an App")
account, tenant_id = _current_user_and_tenant_id(current_user)
if tenant_id is None:
raise Forbidden("Current workspace is required")
importer = RosterAgentPackageImporter()
result = importer.import_package(source=source, tenant_id=tenant_id, account=account)
return Import(
id=str(uuid4()),
status=ImportStatus.COMPLETED_WITH_WARNINGS if result.warnings else ImportStatus.COMPLETED,
app_id=result.app_id,
app_mode=AppMode.AGENT,
warnings=result.warnings,
).model_dump(mode="json"), 200
@cloud_edition_billing_resource_check("apps")
@rbac_permission_required(RBACCheck(RBACPermission.APP_IMPORT_EXPORT_DSL, Workspace()))
def _import_dsl(
self,
req_data: AppImportPayload,
current_user: Account | None = None,
*,
package: PreparedAppPackage | None = None,
):
current_user = current_user if current_user is not None else _current_user_and_tenant_id(None)[0]
# AppDslService performs internal commits for some creation paths, so use a plain
# Session here instead of nesting it inside sessionmaker(...).begin().
with Session(db.engine, expire_on_commit=False) as session:
import_service = AppDslService(session)
# Import app
account = current_user
try:
result = import_service.import_app(
account=account,
import_mode=req_data.mode,
yaml_content=req_data.yaml_content,
yaml_url=req_data.yaml_url,
name=req_data.name,
description=req_data.description,
icon_type=req_data.icon_type,
icon=req_data.icon,
icon_background=req_data.icon_background,
app_id=req_data.app_id,
package=package,
)
except NoPermissionError as e:
raise Forbidden(str(e))
if result.status == ImportStatus.FAILED:
session.rollback()
else:
session.commit()
is_created_app = req_data.app_id is None and result.status in {
ImportStatus.COMPLETED,
ImportStatus.COMPLETED_WITH_WARNINGS,
}
if dify_config.RBAC_ENABLED and is_created_app and result.app_id:
current_user, current_tenant_id = _current_user_and_tenant_id(current_user)
if current_tenant_id:
result.permission_keys = get_app_permission_keys(
current_tenant_id,
current_user.id,
result.app_id,
)
if result.app_id and SystemFeatureService.is_webapp_auth_enabled():
# update web app setting as private
EnterpriseService.WebAppAuth.update_app_access_mode(result.app_id, "private")
# Return appropriate status code based on result
status = result.status
match status:
case ImportStatus.FAILED:
return result.model_dump(mode="json"), 400
case ImportStatus.PENDING:
return result.model_dump(mode="json"), 202
case ImportStatus.COMPLETED | ImportStatus.COMPLETED_WITH_WARNINGS:
return result.model_dump(mode="json"), 200
@console_ns.route("/apps/imports/<string:import_id>/confirm")
class AppImportConfirmApi(Resource):
@console_ns.response(HTTPStatus.OK, "Import confirmed", console_ns.models[Import.__name__])
@console_ns.response(HTTPStatus.BAD_REQUEST, "Import failed", console_ns.models[Import.__name__])
@console_account_admission(
allowed_roles=_EDIT_ROLES,
rbac_checks=[RBACCheck(RBACPermission.APP_IMPORT_EXPORT_DSL, Workspace())],
)
def post(self, context: RequestContext, import_id: str):
try:
result = application_services().apps.console.confirm_import(context, import_id)
except NoPermissionError as exc:
raise Forbidden(str(exc)) from exc
status_code = HTTPStatus.BAD_REQUEST if result.status == ImportStatus.FAILED else HTTPStatus.OK
return result.model_dump(mode="json"), status_code
@console_ns.response(200, "Import confirmed", console_ns.models[Import.__name__])
@console_ns.response(400, "Import failed", console_ns.models[Import.__name__])
@setup_required
@login_required
@account_initialization_required
@edit_permission_required
@rbac_permission_required(RBACCheck(RBACPermission.APP_IMPORT_EXPORT_DSL, Workspace()))
@with_current_user
def post(self, current_user: Account | None = None, import_id: str = ""):
current_user = current_user if current_user is not None else _current_user_and_tenant_id(None)[0]
redis_key = f"{IMPORT_INFO_REDIS_KEY_PREFIX}{import_id}"
pending_data_raw = redis_client.get(redis_key)
pending_data: PendingData | None = None
if pending_data_raw:
pending_data = PendingData.model_validate_json(pending_data_raw)
with Session(db.engine, expire_on_commit=False) as session:
import_service = AppDslService(session)
# Confirm import
account = current_user
try:
result = import_service.confirm_import(import_id=import_id, account=account)
except NoPermissionError as e:
raise Forbidden(str(e))
if result.status == ImportStatus.FAILED:
session.rollback()
else:
session.commit()
is_created_app = bool(
pending_data
and pending_data.app_id is None
and result.status
in {
ImportStatus.COMPLETED,
ImportStatus.COMPLETED_WITH_WARNINGS,
}
)
if dify_config.RBAC_ENABLED and is_created_app and result.app_id:
current_user, current_tenant_id = _current_user_and_tenant_id(current_user)
if current_tenant_id:
result.permission_keys = get_app_permission_keys(
current_tenant_id,
current_user.id,
result.app_id,
)
# Return appropriate status code based on result
if result.status == ImportStatus.FAILED:
return result.model_dump(mode="json"), 400
return result.model_dump(mode="json"), 200
@console_ns.route("/apps/imports/<string:app_id>/check-dependencies")
class AppImportCheckDependenciesApi(Resource):
@console_ns.response(
HTTPStatus.OK,
200,
"Dependencies checked",
console_ns.models[CheckDependenciesResult.__name__],
)
@console_account_admission(
allowed_roles=_EDIT_ROLES,
rbac_checks=[RBACCheck(RBACPermission.APP_VIEW_LAYOUT, PlainApp())],
)
def get(self, context: RequestContext, app_id: str):
try:
result = application_services().apps.console.check_import_dependencies(context, app_id)
except ConsoleAppNotFoundError as exc:
raise AppNotFoundError() from exc
return result.model_dump(mode="json"), HTTPStatus.OK
@setup_required
@login_required
@get_app_model
@account_initialization_required
@edit_permission_required
@rbac_permission_required(RBACCheck(RBACPermission.APP_VIEW_LAYOUT, PlainApp()))
def get(self, app_model: App):
with Session(db.engine, expire_on_commit=False) as session:
import_service = AppDslService(session)
result = import_service.check_dependencies(app_model=app_model)
return result.model_dump(mode="json"), 200

View File

@@ -455,15 +455,14 @@ 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,
@@ -475,4 +474,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=req_data.language)
return _get_text_to_speech_voices(tenant_id=app_model.tenant_id, language=query.language)

View File

@@ -132,7 +132,6 @@ class ConversationVariablesApi(Resource):
"created_at": row.created_at,
"updated_at": row.updated_at,
**row.to_variable().model_dump(),
"id": row.id,
}
)
for row in rows

View File

@@ -89,12 +89,6 @@ class ProviderNotSupportSpeechToTextError(BaseHTTPException):
code = 400
class ProviderNotSupportTextToSpeechError(BaseHTTPException):
error_code = "provider_not_support_text_to_speech"
description = "Provider does not support text to speech."
code = 400
class SpeechToTextDisabledError(BaseHTTPException):
error_code = "speech_to_text_disabled"
description = "Speech to text is disabled."
@@ -131,12 +125,6 @@ class UnsupportedTracingProviderError(BaseHTTPException):
code = 400
class TracingProviderUnavailableError(BaseHTTPException):
error_code = "tracing_provider_unavailable"
description = "The tracing provider is not available in this deployment."
code = 400
class InvalidTracingConfigError(BaseHTTPException):
error_code = "invalid_tracing_config"
description = "The tracing configuration is invalid."

View File

@@ -401,7 +401,6 @@ 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(

View File

@@ -1,31 +1,32 @@
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 werkzeug.exceptions import Conflict, NotFound
from sqlalchemy import select
from werkzeug.exceptions import 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.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 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 fields.base import ResponseModel
from libs.helper import dump_response, to_timestamp
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})
from libs.login import login_required
from models.enums import AppMCPServerStatus
from models.model import App, AppMCPServer
from services.app_ref_service import AppRefService
class MCPServerCreatePayload(BaseModel):
@@ -37,13 +38,13 @@ class MCPServerCreatePayload(BaseModel):
class MCPServerUpdatePayload(BaseModel):
id: UUID = Field(..., description="Server ID")
id: str = Field(..., description="Server ID")
description: str | None = Field(default=None, description="Server description")
parameters: dict[str, Any] = Field(
...,
description="Server parameters configuration",
)
status: AppMCPServerStatus | None = Field(default=None, description="Server status")
status: str | None = Field(default=None, description="Server status")
class AppMCPServerResponse(ResponseModel):
@@ -83,12 +84,13 @@ class AppMCPServerController(Resource):
@console_ns.response(
200, "MCP server configuration retrieved successfully", console_ns.models[AppMCPServerResponse.__name__]
)
@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
@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))
if server is None:
return {}
return dump_response(AppMCPServerResponse, server)
@@ -101,18 +103,30 @@ class AppMCPServerController(Resource):
201, "MCP server configuration created successfully", console_ns.models[AppMCPServerResponse.__name__]
)
@console_ns.response(403, "Insufficient permissions")
@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
@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()
return dump_response(AppMCPServerResponse, server), 201
@console_ns.doc("update_app_mcp_server")
@@ -124,22 +138,43 @@ class AppMCPServerController(Resource):
)
@console_ns.response(403, "Insufficient permissions")
@console_ns.response(404, "Server not found")
@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,
@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,
)
except AppMCPServerAppNotFoundError as error:
raise AppNotFoundError() from error
except AppMCPServerNotFoundError as error:
raise NotFound from error
.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()
return dump_response(AppMCPServerResponse, server)
@@ -151,12 +186,21 @@ 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")
@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
@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()
return dump_response(AppMCPServerResponse, server)

View File

@@ -1,5 +1,4 @@
import logging
from http import HTTPStatus
from typing import Literal
from uuid import UUID
@@ -7,7 +6,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 HTTPException, InternalServerError, NotFound, Unauthorized
from werkzeug.exceptions import InternalServerError, NotFound
from controllers.common.controller_schemas import MessageFeedbackPayload as _MessageFeedbackPayloadBase
from controllers.common.fields import SimpleResultResponse, TextFileResponse
@@ -17,8 +16,6 @@ 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,
@@ -26,7 +23,6 @@ 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,
@@ -37,9 +33,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 (
@@ -50,19 +46,13 @@ 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 attach_message_extra_contents
from services.message_suggested_questions_service import SuggestedQuestionsAccount, SuggestedQuestionsActorNotFoundError
from services.message_service import MessageService, attach_message_extra_contents
logger = logging.getLogger(__name__)
@@ -306,25 +296,21 @@ 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(
HTTPStatus.OK,
200,
"Suggested questions retrieved successfully",
console_ns.models[SuggestedQuestionsResponse.__name__],
)
@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")
@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):
return _get_message_suggested_questions(
context=context, app_id=app.id, app_mode=app.mode, message_id=message_id
session=session, current_user=current_user, app_model=app_model, message_id=message_id
)
@@ -334,23 +320,26 @@ 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(
HTTPStatus.OK,
200,
"Suggested questions retrieved successfully",
console_ns.models[SuggestedQuestionsResponse.__name__],
)
@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
@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,
)
return _get_message_suggested_questions(
context=context, app_id=app_id, app_mode=AppMode.AGENT, message_id=message_id
session=session, current_user=current_user, app_model=app_model, message_id=message_id
)
@@ -563,22 +552,17 @@ def _update_message_feedback(
return SimpleResultResponse(result="success").model_dump(mode="json")
def _get_message_suggested_questions(
*, context: RequestContext, app_id: str, app_mode: str, message_id: UUID
) -> dict[str, object]:
def _get_message_suggested_questions(*, session: Session, current_user: Account, app_model: App, message_id: UUID):
message_id_str = str(message_id)
try:
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),
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,
)
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:
@@ -593,8 +577,6 @@ def _get_message_suggested_questions(
raise CompletionRequestError(e.description)
except SuggestedQuestionsAfterAnswerDisabledError:
raise AppSuggestedQuestionsAfterAnswerDisabledError()
except HTTPException:
raise
except Exception:
logger.exception("internal server error.")
raise InternalServerError()

View File

@@ -1,6 +1,9 @@
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
@@ -8,7 +11,6 @@ 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,
@@ -28,7 +30,47 @@ from libs.login import login_required
from models.model import App, AppMode, AppModelConfig
from services.app_model_config_service import AppModelConfigService
register_schema_models(console_ns, AppModelConfigPayload)
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_response_schema_models(console_ns, SimpleResultResponse)
@@ -37,7 +79,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[AppModelConfigPayload.__name__])
@console_ns.expect(console_ns.models[ModelConfigRequest.__name__])
@console_ns.response(
200,
"Model configuration updated successfully",
@@ -56,11 +98,10 @@ 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."""
payload = AppModelConfigPayload.model_validate(console_ns.payload or {})
# validate config
model_configuration = AppModelConfigService.validate_configuration(
tenant_id=current_tenant_id,
# Preserve omission so feature validators retain their existing defaults.
config=payload.model_dump(exclude_unset=True),
config=cast(dict, request.json),
app_mode=AppMode.value_of(app_model.mode),
session=session,
)

View File

@@ -1,384 +0,0 @@
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

View File

@@ -14,7 +14,6 @@ from controllers.console.app.error import (
TracingConfigNotFoundError,
TracingConfigProcessingError,
TracingConfigVerificationFailedError,
TracingProviderUnavailableError,
UnsupportedTracingProviderError,
)
from controllers.console.flask_admission import console_account_admission
@@ -34,7 +33,6 @@ from services.app_tracing_config_service import (
AppTracingConfigInvalidProviderError,
AppTracingConfigNotFoundError,
AppTracingConfigProcessingError,
AppTracingConfigProviderUnavailableError,
AppTracingConfigRecord,
AppTracingConfigVerificationFailedError,
)
@@ -127,8 +125,6 @@ class TraceAppConfigApi(Resource):
raise AppNotFoundError() from error
except AppTracingConfigInvalidProviderError as error:
raise UnsupportedTracingProviderError() from error
except AppTracingConfigProviderUnavailableError as error:
raise TracingProviderUnavailableError() from error
except AppTracingConfigProcessingError as error:
raise TracingConfigProcessingError() from error
except ValueError as error:
@@ -181,8 +177,6 @@ class TraceAppConfigApi(Resource):
raise TracingConfigAlreadyExistsError() from error
except AppTracingConfigInvalidProviderError as error:
raise UnsupportedTracingProviderError() from error
except AppTracingConfigProviderUnavailableError as error:
raise TracingProviderUnavailableError() from error
except AppTracingConfigInvalidConfigurationError as error:
raise InvalidTracingConfigError() from error
except AppTracingConfigVerificationFailedError as error:
@@ -232,8 +226,6 @@ class TraceAppConfigApi(Resource):
raise TracingConfigNotFoundError() from error
except AppTracingConfigInvalidProviderError as error:
raise UnsupportedTracingProviderError() from error
except AppTracingConfigProviderUnavailableError as error:
raise TracingProviderUnavailableError() from error
except AppTracingConfigInvalidConfigurationError as error:
raise InvalidTracingConfigError() from error
except AppTracingConfigVerificationFailedError as error:

View File

@@ -2,7 +2,7 @@ import json
import logging
from collections.abc import Sequence
from datetime import datetime
from typing import Any, Literal, NotRequired, Self, TypedDict
from typing import Any, NotRequired, Self, TypedDict
from flask import abort, request
from flask_restx import Resource
@@ -21,6 +21,7 @@ from werkzeug.exceptions import BadRequest, Forbidden, InternalServerError, NotF
import services
from configs import dify_config
from controllers.common.app_access import resolve_app_access_filter
from controllers.common.controller_schemas import DefaultBlockConfigQuery, WorkflowListQuery, WorkflowUpdatePayload
from controllers.common.errors import InvalidArgumentError
from controllers.common.fields import GeneratedAppResponse, NewAppResponse, SimpleResultResponse
@@ -95,16 +96,11 @@ from models.model import AppMode
from models.workflow import Workflow
from repositories.workflow_collaboration_repository import WORKFLOW_ONLINE_USERS_PREFIX
from services.agent.retirement_service import WorkflowAgentRetirementService
from services.app.access import resolve_app_access_filter
from services.app_generate_service import AppGenerateService
from services.errors.app import IsDraftWorkflowError, WorkflowHashNotEqualError, WorkflowNotFoundError
from services.errors.llm import InvokeRateLimitError
from services.workflow_ref_service import WorkflowRefService
from services.workflow_service import DraftWorkflowDeletionError, WorkflowInUseError, WorkflowService
from services.workflow_variable_reference_validator import (
format_variable_reference_errors,
validate_variable_references,
)
logger = logging.getLogger(__name__)
@@ -220,7 +216,7 @@ class WorkflowSuggestedQuestionsAfterAnswerPayload(WorkflowFeatureTogglePayload)
class WorkflowTextToSpeechPayload(WorkflowFeatureTogglePayload):
language: str | None = None
voice: str | None = None
autoPlay: Literal["enabled", "disabled"] | None = None
autoPlay: str | None = None
class WorkflowSensitiveWordAvoidancePayload(WorkflowFeatureTogglePayload):
@@ -405,10 +401,6 @@ class WorkflowOnlineUsersResponse(ResponseModel):
class WorkflowPublishResponse(ResponseModel):
result: str
created_at: int
warning: str | None = Field(
default=None,
description="Advisory warning for variable references that can read a skipped branch. Publish still succeeds.",
)
class SyncDraftWorkflowResponse(ResponseModel):
@@ -1274,21 +1266,6 @@ class DraftWorkflowNodeRunApi(Resource):
).model_dump(mode="json")
def _advisory_variable_reference_warning(graph_text: str | None) -> str | None:
"""Return a non-blocking publish warning. A checker failure must not fail publish."""
if not graph_text:
return None
try:
graph = json.loads(graph_text)
if not isinstance(graph, dict):
return None
issues = validate_variable_references(graph)
return format_variable_reference_errors(issues) if issues else None
except Exception:
logger.warning("Skipped advisory variable reference check", exc_info=True)
return None
@console_ns.route("/apps/<uuid:app_id>/workflows/publish")
class PublishedWorkflowApi(Resource):
@console_ns.doc("get_published_workflow")
@@ -1353,16 +1330,11 @@ class PublishedWorkflowApi(Resource):
app_model_in_session.updated_at = naive_utc_now()
workflow_created_at = TimestampField().format(workflow.created_at)
graph_text = workflow.graph
warning = _advisory_variable_reference_warning(graph_text)
payload: dict[str, object] = {
return {
"result": "success",
"created_at": workflow_created_at,
}
if warning:
payload["warning"] = warning
return payload
@console_ns.route("/apps/<uuid:app_id>/workflows/default-workflow-block-configs")

View File

@@ -24,7 +24,9 @@ from models.model import AppMode
class WorkflowAppLogQuery(BaseModel):
keyword: str | None = Field(default=None, description="Search keyword for filtering logs")
status: WorkflowExecutionStatus | None = Field(default=None, description="Execution status filter")
status: WorkflowExecutionStatus | None = Field(
default=None, description="Execution status filter (succeeded, failed, stopped, partial-succeeded)"
)
created_at__before: datetime | None = Field(default=None, description="Filter logs created before this timestamp")
created_at__after: datetime | None = Field(default=None, description="Filter logs created after this timestamp")
created_by_end_user_session_id: str | None = Field(default=None, description="Filter by end user session ID")

View File

@@ -16,7 +16,6 @@ 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
@@ -32,6 +31,7 @@ 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 = application_services().workspaces.member_queries.list_members(current_tenant.id)
members = TenantService.get_tenant_members(current_tenant, session=db.session())
users = TypeAdapter(list[AccountWithRole]).validate_python(members, from_attributes=True)
response = WorkflowCommentMentionUsersPayload(users=users)
return response.model_dump(mode="json"), 200

View File

@@ -18,7 +18,6 @@ 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",
@@ -26,28 +25,31 @@ __all__ = [
]
def _load_app_model(session: Session | scoped_session, app_id: str) -> App | None:
"""Load the tenant-scoped Console app row through the given session."""
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`."""
_, 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 == AppStatus.NORMAL,
)
.limit(1)
select(App).where(App.id == app_id, App.tenant_id == current_tenant_id, App.status == "normal").limit(1)
)
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
if app_model is not None and _is_hidden_backing_app(app_model, session):
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."""
return _load_app_model(db.session, app_id)
_, 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
def _get_injected_session(args: tuple[object, ...]) -> Session | None:

View File

@@ -12,8 +12,14 @@ 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_errors import AccountEmailDomainSuspendedError as EmailDomainSuspendedRegistrationError
from services.account_errors import FrozenAccountError, InvalidInvitationError, InvitationAccountMismatchError
from services.account_activation_service import (
EmailDomainSuspendedError as EmailDomainSuspendedRegistrationError,
)
from services.account_activation_service import (
FrozenAccountError,
InvalidInvitationError,
InvitationAccountMismatchError,
)
from services.entities.account_activation_entities import ActivationCommand, InvitationLookup
@@ -85,7 +91,7 @@ class ActivateCheckApi(Resource):
)
@model_validate(ActivateCheckQuery)
def get(self, args: ActivateCheckQuery):
result = application_services().accounts.activation.check(
result = application_services().account_activation.check(
InvitationLookup(
workspace_id=args.workspace_id,
email=args.email,
@@ -109,20 +115,20 @@ class ActivateApi(Resource):
console_ns.models[ActivationResponse.__name__],
)
@console_ns.response(400, "Already activated or invalid token")
@model_validate(ActivatePayload)
def post(self, args: ActivatePayload):
def post(self):
"""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().accounts.activation.activate(
application_services().account_activation.activate(
ActivationCommand(
invitation=InvitationLookup(
workspace_id=args.workspace_id,

View File

@@ -27,7 +27,7 @@ from services.auth.errors import (
InvalidDataSourceApiKeyAuthCredentialsError,
UnsupportedDataSourceApiKeyAuthProviderError,
)
from services.data_source.entities.api_key_auth import (
from services.entities.data_source_api_key_auth_entities 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_sources.api_key_auth.list_bindings(request_context)
bindings = application_services().data_source_api_key_auth.list_bindings(request_context)
return dump_response(
ApiKeyAuthDataSourceListResponse,
{
@@ -120,7 +120,7 @@ class ApiKeyAuthDataSourceBinding(Resource):
),
)
try:
application_services().data_sources.api_key_auth.create_binding(request_context, command)
application_services().data_source_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_sources.api_key_auth.delete_binding(request_context, str(binding_id))
application_services().data_source_api_key_auth.delete_binding(request_context, str(binding_id))
return "", HTTPStatus.NO_CONTENT

View File

@@ -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.entities.oauth import DataSourceOAuthCallback
from services.data_source.oauth_service import (
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().data_sources.resolve_oauth(provider)
service = application_services().resolve_data_source_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().data_sources.resolve_oauth(provider)
service = application_services().resolve_data_source_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().data_sources.resolve_oauth(provider)
service = application_services().resolve_data_source_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().data_sources.resolve_oauth(provider)
service = application_services().resolve_data_source_oauth(provider)
service.sync(
request_context,
binding_id=str(binding_id),

View File

@@ -154,7 +154,7 @@ class EmailRegisterResetApi(Resource):
@model_validate(EmailRegisterResetPayload)
def post(self, args: EmailRegisterResetPayload):
try:
tokens = application_services().accounts.email_registration.register(
token_pair = 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": tokens},
{"result": "success", "data": token_pair},
)

View File

@@ -60,8 +60,12 @@ from libs.token import (
set_refresh_token_to_cookie,
)
from services import account_errors
from services.entities.account_entities import AccountSessionTokens
from services.entities.account_login_entities import EmailCodeLoginCommand, EmailCodeSendCommand, PasswordLoginCommand
from services.entities.account_login_entities import (
AuthTokenPair,
EmailCodeLoginCommand,
EmailCodeSendCommand,
PasswordLoginCommand,
)
from services.entities.auth_entities import LoginPayloadBase
@@ -137,7 +141,7 @@ class LoginApi(Resource):
except account_errors.AccountApplicationError as error:
_raise_request_error(error)
if not result.workspace_found or result.tokens is None:
if not result.workspace_found or result.token_pair is None:
return dump_response(
SimpleResultOptionalDataResponse,
{
@@ -145,7 +149,7 @@ class LoginApi(Resource):
"data": "workspace not found, please contact system admin to invite you to join in a workspace",
},
)
return _token_response(result.tokens, SimpleResultOptionalDataResponse, {"result": "success"})
return _token_response(result.token_pair, SimpleResultOptionalDataResponse, {"result": "success"})
@console_ns.route("/logout")
@@ -214,7 +218,7 @@ class EmailCodeLoginApi(Resource):
@model_validate(EmailCodeLoginPayload)
def post(self, req_data: EmailCodeLoginPayload):
try:
tokens = application_services().accounts.authentication.login_with_email_code(
token_pair = application_services().accounts.authentication.login_with_email_code(
EmailCodeLoginCommand(
email=req_data.email,
code=req_data.code,
@@ -227,7 +231,7 @@ class EmailCodeLoginApi(Resource):
)
except account_errors.AccountApplicationError as error:
_raise_request_error(error)
return _token_response(tokens, SimpleResultResponse, {"result": "success"})
return _token_response(token_pair, SimpleResultResponse, {"result": "success"})
@console_ns.route("/refresh-token")
@@ -243,21 +247,21 @@ class RefreshTokenApi(Resource):
), HTTPStatus.UNAUTHORIZED
try:
tokens = application_services().accounts.authentication.refresh(refresh_token)
token_pair = 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(tokens, SimpleResultResponse, {"result": "success"})
return _token_response(token_pair, SimpleResultResponse, {"result": "success"})
def _token_response(tokens: AccountSessionTokens, response_model: type[BaseModel], data: object):
def _token_response(token_pair: AuthTokenPair, 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, tokens.csrf_token)
set_access_token_to_cookie(request, response, tokens.access_token)
set_refresh_token_to_cookie(request, response, tokens.refresh_token)
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)
return response

View File

@@ -1,59 +1,53 @@
import json
from collections.abc import Generator
from datetime import datetime
from typing import Never
from typing import Any, Literal, cast
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.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 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.rbac import RBACPermission
from extensions.ext_application_services import application_services
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 fields.dataset_fields import NotionEstimateWorkspacePayload
from libs.datetime_utils import naive_utc_now
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,
)
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
_ADMIN_OR_OWNER_ROLES = frozenset({TenantAccountRole.ADMIN, TenantAccountRole.OWNER})
_DATA_SOURCE_OAUTH_BASE_PATH = "/console/api/oauth/data-source"
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,
)
class NotionEstimatePayload(BaseModel):
notion_info_list: list[NotionEstimateWorkspacePayload] = Field(min_length=1)
process_rule: dict[str, object]
notion_info_list: list[dict[str, Any]]
process_rule: dict[str, Any]
doc_form: str = Field(default="text_model")
doc_language: str = Field(default="English")
@@ -78,7 +72,7 @@ class DataSourceIntegratePageResponse(ResponseModel):
page_id: str
page_icon: DataSourceIntegrateIconResponse | None
parent_id: str
type: NotionPageType
type: str
class DataSourceIntegrateWorkspaceResponse(ResponseModel):
@@ -112,7 +106,7 @@ class NotionIntegratePageResponse(ResponseModel):
page_id: str
page_icon: DataSourceIntegrateIconResponse | None
parent_id: str | None
page_type: NotionPageType = Field(alias="type")
type: str
is_bound: bool
@@ -138,204 +132,325 @@ register_response_schema_models(
)
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.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
@console_ns.response(200, "Success", console_ns.models[DataSourceIntegrateListResponse.__name__])
@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
@console_ns.route("/data-source/integrates/<uuid:binding_id>/<string:action>")
class DataSourceIntegrationApi(Resource):
@console_ns.response(200, "Success", console_ns.models[SimpleResultResponse.__name__])
@console_account_admission(
allowed_roles=_ADMIN_OR_OWNER_ROLES,
rbac_checks=(RBACCheck(RBACPermission.CREDENTIAL_MANAGE, Workspace()),),
)
def patch(
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,
@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,
)
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
).all()
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.response(200, "Success", console_ns.models[SimpleResultResponse.__name__])
@with_current_tenant_id
@with_session
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
)
)
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
@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__])
@console_account_admission()
@with_current_user
@with_current_tenant_id
@with_session(write=False)
@model_validate(DataSourceNotionListQuery)
def get(
self,
req_data: DataSourceNotionListQuery,
request_context: RequestContext,
) -> tuple[dict[str, object], int]:
try:
result = application_services().data_sources.notion_imports.list_pages(
request_context,
credential_id=req_data.credential_id,
dataset_id=req_data.dataset_id,
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(),
)
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
)
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
@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__])
@console_account_admission()
@with_current_tenant_id
@model_validate(DataSourceNotionPreviewQuery)
def get(
self,
req_data: DataSourceNotionPreviewQuery,
request_context: RequestContext,
current_tenant_id: str,
page_id: UUID,
page_type: str,
) -> 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
) -> 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
@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__])
@console_account_admission()
@with_current_tenant_id
@with_session
@model_validate(NotionEstimatePayload)
def post(
self,
req_data: NotionEstimatePayload,
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,
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"],
)
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,
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,
)
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__])
@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
@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_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__])
@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
@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

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

View File

@@ -25,12 +25,6 @@ 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."

View File

@@ -1,13 +1,16 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime
from typing import Any, Never
from typing import Any
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 (
@@ -15,42 +18,31 @@ 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,
)
from fields.dataset_fields import DatasetDetailResponse, dataset_detail_response_source
from libs.helper import dump_response
from machinery.context import RequestContext
from models.account import TenantAccountRole
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 services.entities.external_knowledge_entities.external_knowledge_entities import ExternalDatasetCreatePayload
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
from services.external_knowledge_service import ExternalDatasetService
from services.hit_testing_service import HitTestingService
class ExternalKnowledgeApiPayload(BaseModel):
@@ -93,6 +85,28 @@ 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
@@ -147,13 +161,25 @@ class ExternalApiTemplateListApi(Resource):
"External API templates retrieved successfully",
console_ns.models[ExternalKnowledgeApiListResponse.__name__],
)
@console_account_admission()
@setup_required
@login_required
@with_current_tenant_id
@account_initialization_required
@with_session(write=False)
@model_validate(ExternalApiTemplateListQuery)
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
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
)
return dump_response(ExternalKnowledgeApiListResponse, result), 200
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
@console_ns.doc("create_external_api_template")
@console_ns.doc(description="Create external knowledge API template")
@@ -164,16 +190,48 @@ class ExternalApiTemplateListApi(Resource):
console_ns.models[ExternalKnowledgeApiResponse.__name__],
)
@console_ns.response(403, "Permission denied")
@console_account_admission(allowed_roles=_DATASET_EDIT_ROLES)
@setup_required
@login_required
@account_initialization_required
@with_current_user
@with_current_tenant_id
@with_session
@model_validate(ExternalKnowledgeApiPayload)
def post(self, req_data: ExternalKnowledgeApiPayload, request_context: RequestContext):
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()
try:
result = application_services().knowledge.external.create_template(
request_context, name=req_data.name, settings=req_data.settings
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,
)
except Exception as error:
_raise_external_error(error)
return dump_response(ExternalKnowledgeApiResponse, result), 201
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
@console_ns.route("/datasets/external-knowledge-api/<uuid:external_knowledge_api_id>")
@@ -187,15 +245,20 @@ class ExternalApiTemplateApi(Resource):
console_ns.models[ExternalKnowledgeApiResponse.__name__],
)
@console_ns.response(404, "Template not found")
@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
@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_ns.doc("update_external_api_template")
@console_ns.doc(description="Update external knowledge API template")
@@ -207,31 +270,51 @@ class ExternalApiTemplateApi(Resource):
console_ns.models[ExternalKnowledgeApiResponse.__name__],
)
@console_ns.response(404, "Template not found")
@console_account_admission()
@setup_required
@login_required
@account_initialization_required
@with_current_user
@with_current_tenant_id
@with_session
@model_validate(ExternalKnowledgeApiPayload)
def patch(
self, req_data: ExternalKnowledgeApiPayload, request_context: RequestContext, external_knowledge_api_id: UUID
self,
req_data: ExternalKnowledgeApiPayload,
session: Session,
current_tenant_id: str,
current_user: Account,
external_knowledge_api_id: UUID,
):
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
external_knowledge_api_id_str = str(external_knowledge_api_id)
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")
@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)
@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
)
return "", 204
@@ -241,12 +324,18 @@ 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__])
@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)
@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
)
return dump_response(UsageCountResponse, {"is_using": is_using, "count": count}), 200
return UsageCountResponse(is_using=external_knowledge_api_is_using, count=count).model_dump(mode="json"), 200
@console_ns.route("/datasets/external")
@@ -259,17 +348,50 @@ class ExternalDatasetCreateApi(Resource):
)
@console_ns.response(400, "Invalid parameters")
@console_ns.response(403, "Permission denied")
@console_account_admission(
allowed_roles=_EXTERNAL_CONNECT_ROLES,
rbac_checks=(RBACCheck(RBACPermission.DATASET_EXTERNAL_CONNECT, Workspace()),),
)
@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
@model_validate(ExternalDatasetCreatePayload)
def post(self, req_data: ExternalDatasetCreatePayload, request_context: RequestContext):
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()
try:
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
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
@console_ns.route("/datasets/<uuid:dataset_id>/external-hit-testing")
@@ -285,17 +407,36 @@ class ExternalKnowledgeHitTestingApi(Resource):
)
@console_ns.response(404, "Dataset not found")
@console_ns.response(400, "Invalid parameters")
@console_account_admission(rbac_checks=(RBACCheck(RBACPermission.DATASET_PIPELINE_TEST, DatasetId()),))
@setup_required
@login_required
@account_initialization_required
@with_current_user
@rbac_permission_required(RBACCheck(RBACPermission.DATASET_PIPELINE_TEST, DatasetId()))
@with_session
@model_validate(ExternalHitTestingPayload)
def post(self, req_data: ExternalHitTestingPayload, request_context: RequestContext, dataset_id: UUID):
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.")
try:
result = application_services().knowledge.external.hit_testing(
request_context,
dataset_id=str(dataset_id),
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,
query=req_data.query,
retrieval_model=req_data.external_retrieval_model,
metadata_filters=req_data.metadata_filtering_conditions,
account=current_user,
external_retrieval_model=req_data.external_retrieval_model,
metadata_filtering_conditions=req_data.metadata_filtering_conditions,
)
except Exception as error:
_raise_external_error(error)
return dump_response(ExternalHitTestingResponse, result)
return dump_response(ExternalHitTestingResponse, response)
except Exception as e:
raise InternalServerError(str(e))

View File

@@ -5,7 +5,7 @@ from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from werkzeug.exceptions import Forbidden, InternalServerError, NotFound
import services.errors.base
import services
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.base.NoPermissionError as e:
except services.errors.account.NoPermissionError as e:
raise Forbidden(str(e))
return dataset

View File

@@ -1,47 +1,44 @@
from collections.abc import Callable
from functools import wraps
from typing import Literal
from uuid import UUID
from flask_restx import Resource
from sqlalchemy.orm import Session
from werkzeug.exceptions import Forbidden, NotFound
import services
from configs import dify_config
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.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 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 fields.dataset_fields import (
DatasetMetadataBuiltInFieldsResponse,
DatasetMetadataListResponse,
DatasetMetadataResponse,
)
from libs.helper import dump_response
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 (
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 (
DocumentMetadataOperation,
MetadataArgs,
MetadataDetail,
MetadataOperationData,
)
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
from services.errors.metadata import MetadataResourceNotFoundError
from services.metadata_service import MetadataService
register_schema_models(
console_ns, MetadataArgs, MetadataOperationData, MetadataUpdatePayload, DocumentMetadataOperation, MetadataDetail
@@ -56,111 +53,200 @@ 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__])
@console_account_admission(
rbac_checks=(RBACCheck(RBACPermission.DATASET_EDIT, DatasetId()),),
)
@metadata_errors
@with_current_user
@with_current_tenant_id
@rbac_permission_required(RBACCheck(RBACPermission.DATASET_EDIT, DatasetId()))
@with_session
@model_validate(MetadataArgs)
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)
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
)
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__]
)
@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
@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.")
if not dify_config.RBAC_ENABLED:
try:
DatasetService.check_dataset_permission(dataset, current_user, session)
except services.errors.account.NoPermissionError as e:
raise Forbidden(str(e))
metadata = MetadataService.get_dataset_metadatas(dataset, session)
return dump_response(DatasetMetadataListResponse, metadata), 200
@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__])
@console_account_admission(
rbac_checks=(RBACCheck(RBACPermission.DATASET_EDIT, DatasetId()),),
)
@metadata_errors
@with_current_user
@with_current_tenant_id
@rbac_permission_required(RBACCheck(RBACPermission.DATASET_EDIT, DatasetId()))
@with_session
@model_validate(MetadataUpdatePayload)
def patch(
self, req_data: MetadataUpdatePayload, request_context: RequestContext, dataset_id: UUID, metadata_id: UUID
self,
req_data: MetadataUpdatePayload,
session: Session,
current_tenant_id: str,
current_user: Account,
dataset_id: UUID,
metadata_id: UUID,
):
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
)
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)
return dump_response(DatasetMetadataResponse, metadata), 200
@setup_required
@login_required
@account_initialization_required
@enterprise_license_required
@console_ns.response(204, "Metadata deleted successfully")
@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))
@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.
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__],
)
@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
def get(self):
built_in_fields = MetadataService.get_built_in_fields()
return dump_response(DatasetMetadataBuiltInFieldsResponse, {"fields": built_in_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")
@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.")
@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.
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")
@console_account_admission(
rbac_checks=(RBACCheck(RBACPermission.DATASET_EDIT, DatasetId()),),
)
@metadata_errors
@with_current_user
@with_current_tenant_id
@rbac_permission_required(RBACCheck(RBACPermission.DATASET_EDIT, DatasetId()))
@with_session
@model_validate(MetadataOperationData)
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)
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.
return "", 204

View File

@@ -24,7 +24,6 @@ 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
@@ -33,6 +32,7 @@ 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 = application_services().data_sources.providers.get_oauth_client(
oauth_config = DatasourceProviderService().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 = application_services().data_sources.providers
datasource_provider_service = DatasourceProviderService()
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 = application_services().data_sources.providers
datasource_provider_service = DatasourceProviderService()
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 = application_services().data_sources.providers
datasource_provider_service = DatasourceProviderService()
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,
credential_query=application_services().credential_queries,
session=db.session(),
)
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 = application_services().data_sources.providers
datasource_provider_service = DatasourceProviderService()
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 = application_services().data_sources.providers
datasource_provider_service = DatasourceProviderService()
datasource_provider_service.update_datasource_credentials(
tenant_id=current_tenant_id,
auth_id=req_data.credential_id,
@@ -422,12 +422,9 @@ class DatasourceAuthListApi(Resource):
@with_current_user
@with_current_tenant_id
def get(self, current_tenant_id: str, user: Account):
datasource_provider_service = application_services().data_sources.providers
datasource_provider_service = DatasourceProviderService()
datasources = datasource_provider_service.get_all_datasource_credentials(
tenant_id=current_tenant_id,
session=db.session(),
user=user,
credential_query=application_services().credential_queries,
tenant_id=current_tenant_id, session=db.session(), user=user
)
return dump_response(DatasourceProviderAuthListResponse, {"result": datasources}), 200
@@ -445,12 +442,9 @@ class DatasourceHardCodeAuthListApi(Resource):
@with_current_user
@with_current_tenant_id
def get(self, current_tenant_id: str, user: Account):
datasource_provider_service = application_services().data_sources.providers
datasource_provider_service = DatasourceProviderService()
datasources = datasource_provider_service.get_hard_code_datasource_credentials(
tenant_id=current_tenant_id,
session=db.session(),
user=user,
credential_query=application_services().credential_queries,
tenant_id=current_tenant_id, session=db.session(), user=user
)
return dump_response(DatasourceProviderAuthListResponse, {"result": datasources}), 200
@@ -470,7 +464,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 = application_services().data_sources.providers
datasource_provider_service = DatasourceProviderService()
datasource_provider_service.setup_oauth_custom_client_params(
tenant_id=current_tenant_id,
datasource_provider_id=datasource_provider_id,
@@ -488,7 +482,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 = application_services().data_sources.providers
datasource_provider_service = DatasourceProviderService()
datasource_provider_service.remove_oauth_custom_client_params(
tenant_id=current_tenant_id,
datasource_provider_id=datasource_provider_id,
@@ -509,7 +503,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 = application_services().data_sources.providers
datasource_provider_service = DatasourceProviderService()
datasource_provider_service.set_default_datasource_provider(
tenant_id=current_tenant_id,
datasource_provider_id=datasource_provider_id,
@@ -531,7 +525,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 = application_services().data_sources.providers
datasource_provider_service = DatasourceProviderService()
datasource_provider_service.update_datasource_provider_name(
tenant_id=current_tenant_id,
datasource_provider_id=datasource_provider_id,

View File

@@ -9,7 +9,6 @@ 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
@@ -52,6 +51,5 @@ 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

View File

@@ -35,11 +35,10 @@ from libs.helper import dump_response
from libs.login import login_required
from models.account import Account
from models.dataset import Pipeline
from repositories.knowledge.dataset_read_repository import get_pipeline_dataset
from services.dataset_service import DatasetService
from services.entities.knowledge_entities.rag_pipeline_entities import IconInfo, PipelineTemplateInfoEntity
from services.errors.base import NoPermissionError
from services.errors.account 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__)
@@ -233,7 +232,7 @@ class PublishCustomizedPipelineTemplateApi(Resource):
pipeline: Pipeline,
) -> tuple[str, int]:
session = db.session()
dataset = get_pipeline_dataset(pipeline, session=session)
dataset = pipeline.retrieve_dataset(session=session)
if dataset is None:
raise NotFound("Dataset not found")

View File

@@ -17,17 +17,14 @@ from controllers.console.wraps import (
with_current_user,
)
from extensions.ext_database import db
from fields.dataset_fields import (
DatasetDetailResponse,
)
from fields.dataset_fields import DatasetDetailResponse, dataset_detail_response_source
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
@@ -100,12 +97,6 @@ class CreateRagPipelineDatasetApi(Resource):
dataset_id,
enterprise_rbac_service.ReplaceMemberBindings(automatic_include_workspace_members=False),
)
enterprise_rbac_service.try_sync_creator_access_policy_member_bindings(
current_tenant_id,
current_user.id,
enterprise_rbac_service.RBACResourceType.DATASET,
dataset_id,
)
return dump_response(RagPipelineImportResponse, import_info), 201
@@ -139,4 +130,4 @@ class CreateEmptyRagPipelineDatasetApi(Resource):
),
session=session,
)
return dump_response(DatasetDetailResponse, load_dataset_detail(dataset, session=session)), 201
return dump_response(DatasetDetailResponse, dataset_detail_response_source(dataset, session=session)), 201

View File

@@ -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.errors.base
import services
from configs import dify_config
from controllers.common.controller_schemas import DefaultBlockConfigQuery, WorkflowListQuery, WorkflowUpdatePayload
from controllers.common.fields import SimpleResultResponse
@@ -45,8 +45,6 @@ from controllers.web.error import InvokeRateLimitError as InvokeRateLimitHttpErr
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
@@ -59,6 +57,7 @@ from fields.workflow_run_fields import (
workflow_run_pagination_response_source,
workflow_run_response_source,
)
from graphon.model_runtime.utils.encoders import jsonable_encoder
from libs import helper
from libs.helper import TimestampField, UUIDStrOrEmpty, dump_response
from libs.login import login_required
@@ -67,10 +66,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
@@ -152,14 +151,6 @@ class RagPipelineOpaqueResponse(RootModel[Any]):
root: Any
class RagPipelineDatasourceProviderResponse(PluginDatasourceProviderEntity, ResponseModel):
pass
class RagPipelineDatasourceListResponse(RootModel[list[RagPipelineDatasourceProviderResponse]]):
pass
class RagPipelineStepParametersResponse(ResponseModel):
variables: Any
@@ -184,8 +175,6 @@ register_response_schema_models(
console_ns,
DefaultBlockConfigResponse,
DefaultBlockConfigsResponse,
RagPipelineDatasourceListResponse,
RagPipelineDatasourceProviderResponse,
RagPipelineOpaqueResponse,
RagPipelineStepParametersResponse,
RagPipelineWorkflowPublishResponse,
@@ -304,13 +293,7 @@ class RagPipelineDraftRunIterationNodeApi(Resource):
try:
response = PipelineGenerateService.generate_single_iteration(
generator=application_services().knowledge.pipeline_generator,
pipeline=pipeline,
user=current_user,
node_id=node_id,
args=args,
session=db.session(),
streaming=True,
pipeline=pipeline, user=current_user, node_id=node_id, args=args, session=db.session(), streaming=True
)
return helper.compact_generate_response(response)
@@ -345,13 +328,7 @@ class RagPipelineDraftRunLoopNodeApi(Resource):
try:
response = PipelineGenerateService.generate_single_loop(
generator=application_services().knowledge.pipeline_generator,
pipeline=pipeline,
user=current_user,
node_id=node_id,
args=args,
session=db.session(),
streaming=True,
pipeline=pipeline, user=current_user, node_id=node_id, args=args, session=db.session(), streaming=True
)
return helper.compact_generate_response(response)
@@ -387,7 +364,6 @@ class DraftRagPipelineRunApi(Resource):
try:
response = PipelineGenerateService.generate(
generator=application_services().knowledge.pipeline_generator,
session=session,
pipeline=pipeline,
user=current_user,
@@ -423,7 +399,6 @@ class PublishedRagPipelineRunApi(Resource):
try:
response = PipelineGenerateService.generate(
generator=application_services().knowledge.pipeline_generator,
session=session,
pipeline=pipeline,
user=current_user,
@@ -465,7 +440,6 @@ 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,
)
)
)
@@ -499,7 +473,6 @@ 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,
)
)
)
@@ -1027,16 +1000,13 @@ class RagPipelineWorkflowRunNodeExecutionListApi(Resource):
@console_ns.route("/rag/pipelines/datasource-plugins")
class DatasourceListApi(Resource):
@console_ns.response(200, "Success", console_ns.models[RagPipelineDatasourceListResponse.__name__])
@console_ns.response(200, "Success", console_ns.models[RagPipelineOpaqueResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@with_current_tenant_id
def get(self, current_tenant_id: str):
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")
return jsonable_encoder(RagPipelineManageService.list_rag_pipeline_datasources(current_tenant_id))
@console_ns.route("/rag/pipelines/<uuid:pipeline_id>/workflows/draft/nodes/<string:node_id>/last-run")
@@ -1088,7 +1058,7 @@ class RagPipelineTransformApi(Resource):
raise Forbidden()
try:
DatasetService.check_dataset_permission(dataset, current_user, session)
except services.errors.base.NoPermissionError as exc:
except services.errors.account.NoPermissionError as exc:
raise Forbidden(str(exc)) from exc
try:

View File

@@ -7,9 +7,8 @@ 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.data_source.website_service import WebsiteCrawlApiRequest, WebsiteCrawlStatusApiRequest, WebsiteService
from services.website_service import WebsiteCrawlApiRequest, WebsiteCrawlStatusApiRequest, WebsiteService
class WebsiteCrawlPayload(BaseModel):
@@ -50,7 +49,7 @@ class WebsiteCrawlApi(Resource):
# Crawl URL using typed request
try:
result = WebsiteService(providers=application_services().data_sources.providers).crawl_url(api_request)
result = WebsiteService.crawl_url(api_request)
except Exception as e:
raise WebsiteCrawlError(str(e))
return result, 200
@@ -78,9 +77,7 @@ class WebsiteCrawlStatusApi(Resource):
# Get crawl status using typed request
try:
result = WebsiteService(providers=application_services().data_sources.providers).get_crawl_status_typed(
api_request
)
result = WebsiteService.get_crawl_status_typed(api_request)
except Exception as e:
raise WebsiteCrawlError(str(e))
return result, 200

View File

@@ -1,19 +1,12 @@
"""Installed-app audio admission, error translation, and response serialization."""
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 flask import request
from werkzeug.exceptions import InternalServerError
from controllers.common.audio_response import audio_binary_response
import services
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
from controllers.console.app.error import (
AppUnavailableError,
AudioTooLargeError,
@@ -22,34 +15,28 @@ from controllers.console.app.error import (
ProviderModelCurrentlyNotSupportError,
ProviderNotInitializeError,
ProviderNotSupportSpeechToTextError,
ProviderNotSupportTextToSpeechError,
ProviderQuotaExceededError,
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.explore.wraps import InstalledAppResource
from controllers.console.wraps import model_validate
from core.errors.error import ModelCurrentlyNotSupportError, ProviderTokenNotInitError, QuotaExceededError
from extensions.ext_application_services import application_services
from extensions.ext_database import db
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
from libs.login import current_account_with_tenant
from models.model import InstalledApp
from services.app_ref_service import AppRefService
from services.audio_service import AudioService
from services.errors.audio import (
AudioTooLargeServiceError,
NoAudioUploadedServiceError,
ProviderNotSupportSpeechToTextServiceError,
ProviderNotSupportTextToSpeechServiceError,
SpeechToTextDisabledServiceError,
UnsupportedAudioTypeServiceError,
)
from services.installed_app_access_service import InstalledAppRef
from .. import console_ns
logger = logging.getLogger(__name__)
@@ -57,99 +44,111 @@ register_schema_model(console_ns, TextToAudioPayload)
register_response_schema_models(console_ns, AudioBinaryResponse, AudioTranscriptResponse)
def _audio_errors[**P, R](view: Callable[P, R]) -> Callable[P, R]:
@wraps(view)
def decorated(*args: P.args, **kwargs: P.kwargs) -> R:
try:
return view(*args, **kwargs)
except AppDefinitionUnavailableError as error:
raise AppUnavailableError() from error
except AppModelConfigBrokenError as error:
logger.exception("App model config broken")
raise AppUnavailableError() from error
except NoAudioUploadedServiceError as error:
raise NoAudioUploadedError() from error
except AudioTooLargeServiceError as error:
raise AudioTooLargeError(str(error)) from error
except UnsupportedAudioTypeServiceError as error:
raise UnsupportedAudioTypeError() from error
except ProviderNotSupportSpeechToTextServiceError as error:
raise ProviderNotSupportSpeechToTextError() from error
except ProviderNotSupportTextToSpeechServiceError as error:
raise ProviderNotSupportTextToSpeechError() from error
except SpeechToTextDisabledServiceError as error:
raise SpeechToTextDisabledError() from error
except ProviderTokenNotInitError as error:
raise ProviderNotInitializeError(error.description) from error
except QuotaExceededError as error:
raise ProviderQuotaExceededError() from error
except ModelCurrentlyNotSupportError as error:
raise ProviderModelCurrentlyNotSupportError() from error
except InvokeError as error:
raise CompletionRequestError(error.description) from error
except AgentVersionNotFoundError as error:
raise AgentVersionNotFoundHTTPError() from error
except (BaseHTTPException, ValueError):
raise
except Exception as error:
logger.exception("Installed-app audio operation failed")
raise InternalServerError() from error
return decorated
@console_ns.route(
"/installed-apps/<uuid:installed_app_id>/audio-to-text",
endpoint="installed_app_audio",
)
class ChatAudioApi(Resource):
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[AudioTranscriptResponse.__name__])
@console_account_admission()
@get_installed_app
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)
class ChatAudioApi(InstalledAppResource):
@console_ns.response(200, "Success", console_ns.models[AudioTranscriptResponse.__name__])
def post(self, installed_app: InstalledApp):
app_model = installed_app.app_with_session(session=db.session())
if app_model is None:
raise AppUnavailableError()
@_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,
tenant_id=installed_app.app_owner_tenant_id,
app_mode=installed_app.app_mode,
),
audio=audio,
)
return dump_response(AudioTranscriptResponse, transcript)
file = request.files["file"]
try:
response = AudioService.transcript_asr(
app_model=app_model,
file=file,
session=db.session(),
end_user=None,
)
return response
except services.errors.app_model_config.AppModelConfigBrokenError:
logger.exception("App model config broken.")
raise AppUnavailableError()
except NoAudioUploadedServiceError:
raise NoAudioUploadedError()
except AudioTooLargeServiceError as e:
raise AudioTooLargeError(str(e))
except UnsupportedAudioTypeServiceError:
raise UnsupportedAudioTypeError()
except ProviderNotSupportSpeechToTextServiceError:
raise ProviderNotSupportSpeechToTextError()
except SpeechToTextDisabledServiceError:
raise SpeechToTextDisabledError()
except ProviderTokenNotInitError as ex:
raise ProviderNotInitializeError(ex.description)
except QuotaExceededError:
raise ProviderQuotaExceededError()
except ModelCurrentlyNotSupportError:
raise ProviderModelCurrentlyNotSupportError()
except InvokeError as e:
raise CompletionRequestError(e.description)
except ValueError as e:
raise e
except Exception as e:
logger.exception("internal server error.")
raise InternalServerError()
@console_ns.route(
"/installed-apps/<uuid:installed_app_id>/text-to-audio",
endpoint="installed_app_text",
)
class ChatTextApi(Resource):
class ChatTextApi(InstalledAppResource):
@console_ns.expect(console_ns.models[TextToAudioPayload.__name__])
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[AudioBinaryResponse.__name__])
@console_account_admission()
@get_installed_app
@console_ns.response(200, "Success", console_ns.models[AudioBinaryResponse.__name__])
@model_validate(TextToAudioPayload)
@_audio_errors
def post(
self, payload: TextToAudioPayload, request_context: RequestContext, installed_app: InstalledAppRef
) -> Response | None:
output = application_services().app_audio.transcript_tts(
app=AudioAppRef(
app_id=installed_app.app_id,
tenant_id=installed_app.app_owner_tenant_id,
app_mode=installed_app.app_mode,
),
account_id=request_context.account_id,
text=payload.text,
voice=payload.voice,
message_id=payload.message_id,
)
# response-contract:ignore audio_binary_response
return audio_binary_response(output)
def post(self, req_data: TextToAudioPayload, installed_app: InstalledApp):
app_model = installed_app.app_with_session(session=db.session())
if app_model is None:
raise AppUnavailableError()
try:
message_id = req_data.message_id
text = req_data.text
voice = req_data.voice
message_ref = None
if message_id:
current_user, _ = current_account_with_tenant()
app_ref = AppRefService.create_app_ref(app_model)
message_ref = AppRefService.create_message_ref(
app_ref,
message_id,
account_id=current_user.id,
)
response = AudioService.transcript_tts(
app_model=app_model,
session=db.session(),
text=text,
voice=voice,
message_ref=message_ref,
)
return response
except services.errors.app_model_config.AppModelConfigBrokenError:
logger.exception("App model config broken.")
raise AppUnavailableError()
except NoAudioUploadedServiceError:
raise NoAudioUploadedError()
except AudioTooLargeServiceError as e:
raise AudioTooLargeError(str(e))
except UnsupportedAudioTypeServiceError:
raise UnsupportedAudioTypeError()
except ProviderNotSupportSpeechToTextServiceError:
raise ProviderNotSupportSpeechToTextError()
except ProviderTokenNotInitError as ex:
raise ProviderNotInitializeError(ex.description)
except QuotaExceededError:
raise ProviderQuotaExceededError()
except ModelCurrentlyNotSupportError:
raise ProviderModelCurrentlyNotSupportError()
except InvokeError as e:
raise CompletionRequestError(e.description)
except ValueError as e:
raise e
except Exception as e:
logger.exception("internal server error.")
raise InternalServerError()

View File

@@ -1,5 +1,4 @@
from datetime import datetime
from http import HTTPStatus
from typing import cast
from flask_restx import Namespace, Resource
@@ -61,7 +60,7 @@ class BannerApi(Resource):
"""Resource for banner list."""
@api.doc(params=query_params_from_model(BannerListQuery))
@api.response(HTTPStatus.OK, "Success", api.models[BannerListResponse.__name__])
@api.response(200, "Success", api.models[BannerListResponse.__name__])
@model_validate(BannerListQuery)
def get(self, req_data: BannerListQuery):
"""Get banner list."""

View File

@@ -1,14 +1,12 @@
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 sqlalchemy.orm import Session
from werkzeug.exceptions import InternalServerError, NotFound
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 (
@@ -19,10 +17,10 @@ from controllers.console.app.error import (
ProviderNotInitializeError,
ProviderQuotaExceededError,
)
from controllers.console.app.wraps import with_session
from controllers.console.explore.error import NotChatAppError, NotCompletionAppError
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 controllers.console.explore.wraps import InstalledAppResource
from controllers.console.wraps import model_validate, with_current_user, with_current_user_id
from controllers.web.error import InvokeRateLimitError as InvokeRateLimitHttpError
from core.app.entities.app_invoke_entities import InvokeFrom
from core.errors.error import (
@@ -30,16 +28,16 @@ from core.errors.error import (
ProviderTokenNotInitError,
QuotaExceededError,
)
from extensions.ext_application_services import application_services
from extensions.ext_database import db
from graphon.model_runtime.errors.invoke import InvokeError
from libs import helper
from machinery.context import RequestContext
from models.model import AppMode
from services.account_errors import AccountNotFoundError
from services.app_definition_query_service import AppDefinitionUnavailableError
from libs.datetime_utils import naive_utc_now
from models import Account
from models.model import AppMode, InstalledApp
from services.app_generate_service import AppGenerateService
from services.app_task_service import AppTaskService
from services.conversation_service import ConversationService
from services.errors.llm import InvokeRateLimitError
from services.installed_app_access_service import InstalledAppNotFoundError, InstalledAppRef
from services.installed_app_generation_service import InstalledAppNotChatError, InstalledAppNotCompletionError
from .. import console_ns
@@ -86,37 +84,47 @@ register_response_schema_models(console_ns, SimpleResultResponse)
"/installed-apps/<uuid:installed_app_id>/completion-messages",
endpoint="installed_app_completion",
)
class CompletionApi(Resource):
class CompletionApi(InstalledAppResource):
@console_ns.expect(console_ns.models[CompletionMessageExplorePayload.__name__])
@console_ns.response(HTTPStatus.OK, "Success")
@console_account_admission()
@get_installed_app
@console_ns.response(200, "Success")
@with_current_user
@with_session
@model_validate(CompletionMessageExplorePayload)
def post(
self,
req_data: CompletionMessageExplorePayload,
request_context: RequestContext,
installed_app: InstalledAppRef,
) -> Response:
session: Session,
current_user: Account,
installed_app: InstalledApp,
):
app_model = installed_app.app_with_session(session=session)
if app_model is None:
raise AppUnavailableError()
if app_model.mode != AppMode.COMPLETION:
raise NotCompletionAppError()
args = req_data.model_dump(exclude_none=True)
streaming = req_data.response_mode == "streaming"
args["auto_generate_name"] = False
installed_app.last_used_at = naive_utc_now()
db.session.commit()
try:
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),
response = AppGenerateService.generate(
session=session,
app_model=app_model,
user=current_user,
args=args,
invoke_from=InvokeFrom.EXPLORE,
streaming=streaming,
)
# response-contract:ignore compact_generate_response
return helper.compact_generate_response(response)
except AppDefinitionUnavailableError:
raise AppUnavailableError() from None
except InstalledAppNotCompletionError:
raise NotCompletionAppError() from None
except InstalledAppNotFoundError:
raise NotFoundError("Installed app not found") from None
except AccountNotFoundError:
raise UnauthorizedError("Account no longer exists.") from None
except services.errors.conversation.ConversationNotExistsError:
raise NotFoundError("Conversation Not Exists.")
raise NotFound("Conversation Not Exists.")
except services.errors.conversation.ConversationCompletedError:
raise ConversationCompletedError()
except services.errors.app_model_config.AppModelConfigBrokenError:
@@ -141,62 +149,75 @@ class CompletionApi(Resource):
"/installed-apps/<uuid:installed_app_id>/completion-messages/<string:task_id>/stop",
endpoint="installed_app_stop_completion",
)
class CompletionStopApi(Resource):
@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
) -> tuple[dict[str, object], int]:
app_mode = installed_app.app_mode
if app_mode != AppMode.COMPLETION:
class CompletionStopApi(InstalledAppResource):
@console_ns.response(200, "Success", console_ns.models[SimpleResultResponse.__name__])
@with_current_user_id
@with_session(write=False)
def post(self, session: Session, current_user_id: str, installed_app: InstalledApp, task_id: str):
app_model = installed_app.app_with_session(session=session)
if app_model is None:
raise AppUnavailableError()
if app_model.mode != AppMode.COMPLETION:
raise NotCompletionAppError()
application_services().app_tasks.stop_task(
AppTaskService.stop_task(
task_id=task_id,
invoke_from=InvokeFrom.EXPLORE,
user_id=request_context.account_id,
app_mode=AppMode.value_of(app_mode),
user_id=current_user_id,
app_mode=AppMode.value_of(app_model.mode),
)
return SimpleResultResponse(result="success").model_dump(mode="json"), HTTPStatus.OK
return SimpleResultResponse(result="success").model_dump(mode="json"), 200
@console_ns.route(
"/installed-apps/<uuid:installed_app_id>/chat-messages",
endpoint="installed_app_chat_completion",
)
class ChatApi(Resource):
class ChatApi(InstalledAppResource):
@console_ns.expect(console_ns.models[ChatMessagePayload.__name__])
@console_ns.response(HTTPStatus.OK, "Success")
@console_account_admission()
@get_installed_app
@console_ns.response(200, "Success")
@with_current_user
@with_session
@model_validate(ChatMessagePayload)
def post(
self,
req_data: ChatMessagePayload,
request_context: RequestContext,
installed_app: InstalledAppRef,
) -> Response:
def post(self, req_data: ChatMessagePayload, session: Session, current_user: Account, installed_app: InstalledApp):
app_model = installed_app.app_with_session(session=session)
if app_model is None:
raise AppUnavailableError()
app_mode = AppMode.value_of(app_model.mode)
if app_mode not in {AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT}:
raise NotChatAppError()
args = req_data.model_dump(exclude_none=True)
args["auto_generate_name"] = False
installed_app.last_used_at = naive_utc_now()
db.session.commit()
try:
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),
# Eagerly validate conversation to avoid hanging on invalid conversation_id
if req_data.conversation_id:
ConversationService.get_conversation(
app_model=app_model,
conversation_id=req_data.conversation_id,
user=current_user,
session=session,
)
response = AppGenerateService.generate(
session=session,
app_model=app_model,
user=current_user,
args=args,
invoke_from=InvokeFrom.EXPLORE,
streaming=True,
)
# response-contract:ignore compact_generate_response
return helper.compact_generate_response(response)
except AppDefinitionUnavailableError:
raise AppUnavailableError() from None
except InstalledAppNotChatError:
raise NotChatAppError() from None
except InstalledAppNotFoundError:
raise NotFoundError("Installed app not found") from None
except AccountNotFoundError:
raise UnauthorizedError("Account no longer exists.") from None
except services.errors.conversation.ConversationNotExistsError:
raise NotFoundError("Conversation Not Exists.")
raise NotFound("Conversation Not Exists.")
except services.errors.conversation.ConversationCompletedError:
raise ConversationCompletedError()
except services.errors.app_model_config.AppModelConfigBrokenError:
@@ -223,22 +244,23 @@ class ChatApi(Resource):
"/installed-apps/<uuid:installed_app_id>/chat-messages/<string:task_id>/stop",
endpoint="installed_app_stop_chat_completion",
)
class ChatStopApi(Resource):
@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
) -> tuple[dict[str, object], int]:
app_mode = AppMode.value_of(installed_app.app_mode)
class ChatStopApi(InstalledAppResource):
@console_ns.response(200, "Success", console_ns.models[SimpleResultResponse.__name__])
@with_current_user_id
@with_session(write=False)
def post(self, session: Session, current_user_id: str, installed_app: InstalledApp, task_id: str):
app_model = installed_app.app_with_session(session=session)
if app_model is None:
raise AppUnavailableError()
app_mode = AppMode.value_of(app_model.mode)
if app_mode not in {AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT}:
raise NotChatAppError()
application_services().app_tasks.stop_task(
AppTaskService.stop_task(
task_id=task_id,
invoke_from=InvokeFrom.EXPLORE,
user_id=request_context.account_id,
user_id=current_user_id,
app_mode=app_mode,
)
return SimpleResultResponse(result="success").model_dump(mode="json"), HTTPStatus.OK
return SimpleResultResponse(result="success").model_dump(mode="json"), 200

View File

@@ -1,35 +1,33 @@
"""HTTP admission and response contracts for installed-app conversations."""
from collections.abc import Callable
from functools import wraps
from http import HTTPStatus
from typing import Any
from uuid import UUID
from flask_restx import Resource
from pydantic import BaseModel, Field, field_validator
from flask import request
from pydantic import BaseModel, Field, TypeAdapter
from sqlalchemy.orm import sessionmaker
from werkzeug.exceptions import NotFound
from controllers.common.controller_schemas import ConversationRenamePayload
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 (
ConversationCursorNotFoundHTTPError,
ConversationFirstMessageNotFoundHTTPError,
ConversationNameRequiredHTTPError,
ConversationNotFoundHTTPError,
InstalledAppNotFoundHTTPError,
NotChatAppError,
from controllers.console.app.error import AppUnavailableError
from controllers.console.explore.error import NotChatAppError
from controllers.console.explore.wraps import InstalledAppResource
from controllers.console.wraps import model_validate, with_current_user
from core.app.entities.app_invoke_entities import InvokeFrom
from extensions.ext_database import db
from fields.conversation_fields import (
ConversationInfiniteScrollPagination,
ConversationResponseSource,
ResultResponse,
SimpleConversation,
)
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 extensions.ext_application_services import application_services
from fields.conversation_fields import ConversationInfiniteScrollPagination, ResultResponse, SimpleConversation
from libs.helper import UUIDStrOrEmpty, dump_response
from machinery.context import RequestContext
from libs.helper import UUIDStrOrEmpty
from models import Account
from models.model import AppMode, InstalledApp
from services.conversation_service import ConversationService
from services.errors.conversation import ConversationNotExistsError, LastConversationNotExistsError
from services.errors.message import MessageNotExistsError
from services.installed_app_access_service import InstalledAppNotFoundError, InstalledAppRef
from services.installed_app_conversation_service import ConversationNameRequiredError, ConversationNotChatAppError
from services.web_conversation_service import WebConversationService
from .. import console_ns
class ConversationListQuery(BaseModel):
@@ -37,149 +35,170 @@ class ConversationListQuery(BaseModel):
limit: int = Field(default=20, ge=1, le=100)
pinned: bool | None = None
@field_validator("limit", mode="before")
@classmethod
def parse_limit(cls, value: object) -> object:
# Preserve request.args.get(..., default=20, type=int) coercion.
if isinstance(value, str):
try:
return int(value)
except ValueError:
return 20
return value
@field_validator("pinned", mode="before")
@classmethod
def parse_pinned(cls, value: object) -> object:
# Existing callers use the literal query string "true" for pinned items.
return value == "true" if isinstance(value, str) else value
register_schema_models(console_ns, ConversationListQuery, ConversationRenamePayload)
register_response_schema_models(console_ns, ConversationInfiniteScrollPagination, ResultResponse, SimpleConversation)
def _conversation_errors[**P, R](view: Callable[P, R]) -> Callable[P, R]:
"""Translate the shared conversation use-case errors at the Console boundary."""
@wraps(view)
def decorated(*args: P.args, **kwargs: P.kwargs) -> R:
try:
return view(*args, **kwargs)
except InstalledAppNotFoundError as error:
raise InstalledAppNotFoundHTTPError() from error
except ConversationNotChatAppError as error:
raise NotChatAppError() from error
except ConversationNotExistsError as error:
raise ConversationNotFoundHTTPError() from error
except LastConversationNotExistsError as error:
raise ConversationCursorNotFoundHTTPError() from error
except MessageNotExistsError as error:
raise ConversationFirstMessageNotFoundHTTPError() from error
except ConversationNameRequiredError as error:
raise ConversationNameRequiredHTTPError() from error
return decorated
@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(HTTPStatus.OK, "Success", console_ns.models[ConversationInfiniteScrollPagination.__name__])
@console_account_admission()
@get_installed_app
@model_validate(ConversationListQuery)
@_conversation_errors
def get(
self, query: ConversationListQuery, request_context: RequestContext, installed_app: InstalledAppRef
) -> dict[str, object]:
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,
limit=query.limit,
pinned=query.pinned,
)
return dump_response(ConversationInfiniteScrollPagination, page)
register_response_schema_models(
console_ns,
ConversationInfiniteScrollPagination,
ResultResponse,
SimpleConversation,
)
@console_ns.route(
"/installed-apps/<uuid:installed_app_id>/conversations/<uuid:c_id>", endpoint="installed_app_conversation"
"/installed-apps/<uuid:installed_app_id>/conversations",
endpoint="installed_app_conversations",
)
class ConversationApi(Resource):
@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_apps.conversations.delete(
installed_app=installed_app, account_id=request_context.account_id, conversation_id=str(c_id)
)
return "", HTTPStatus.NO_CONTENT
class ConversationListApi(InstalledAppResource):
@console_ns.doc(params=query_params_from_model(ConversationListQuery))
@console_ns.response(200, "Success", console_ns.models[ConversationInfiniteScrollPagination.__name__])
@with_current_user
def get(self, current_user: Account, installed_app: InstalledApp):
app_model = installed_app.app_with_session(session=db.session())
if app_model is None:
raise AppUnavailableError()
app_mode = AppMode.value_of(app_model.mode)
if app_mode not in {AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT}:
raise NotChatAppError()
raw_args: dict[str, Any] = {
"last_id": request.args.get("last_id"),
"limit": request.args.get("limit", default=20, type=int),
"pinned": request.args.get("pinned"),
}
if raw_args["last_id"] is None:
raw_args["last_id"] = None
pinned_value = raw_args["pinned"]
if isinstance(pinned_value, str):
raw_args["pinned"] = pinned_value == "true"
args = ConversationListQuery.model_validate(raw_args)
try:
with sessionmaker(db.engine).begin() as session:
pagination = WebConversationService.pagination_by_last_id(
session=session,
app_model=app_model,
user=current_user,
last_id=args.last_id or None,
limit=args.limit,
invoke_from=InvokeFrom.EXPLORE,
pinned=args.pinned,
)
adapter = TypeAdapter(SimpleConversation)
conversations = [
adapter.validate_python(
ConversationResponseSource(item, session=session),
from_attributes=True,
)
for item in pagination.data
]
return ConversationInfiniteScrollPagination(
limit=pagination.limit,
has_more=pagination.has_more,
data=conversations,
).model_dump(mode="json")
except LastConversationNotExistsError:
raise NotFound("Last Conversation Not Exists.")
@console_ns.route(
"/installed-apps/<uuid:installed_app_id>/conversations/<uuid:c_id>",
endpoint="installed_app_conversation",
)
class ConversationApi(InstalledAppResource):
@console_ns.response(204, "Conversation deleted successfully")
@with_current_user
def delete(self, current_user: Account, installed_app: InstalledApp, c_id: UUID):
app_model = installed_app.app_with_session(session=db.session())
if app_model is None:
raise AppUnavailableError()
app_mode = AppMode.value_of(app_model.mode)
if app_mode not in {AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT}:
raise NotChatAppError()
conversation_id = str(c_id)
try:
ConversationService.delete(app_model, conversation_id, current_user, session=db.session())
except ConversationNotExistsError:
raise NotFound("Conversation Not Exists.")
return "", 204
@console_ns.route(
"/installed-apps/<uuid:installed_app_id>/conversations/<uuid:c_id>/name",
endpoint="installed_app_conversation_rename",
)
class ConversationRenameApi(Resource):
class ConversationRenameApi(InstalledAppResource):
@console_ns.expect(console_ns.models[ConversationRenamePayload.__name__])
@console_ns.response(
HTTPStatus.OK, "Conversation renamed successfully", console_ns.models[SimpleConversation.__name__]
)
@console_account_admission()
@get_installed_app
@console_ns.response(200, "Conversation renamed successfully", console_ns.models[SimpleConversation.__name__])
@with_current_user
@model_validate(ConversationRenamePayload)
@_conversation_errors
def post(
self,
payload: ConversationRenamePayload,
request_context: RequestContext,
installed_app: InstalledAppRef,
c_id: UUID,
) -> dict[str, object]:
conversation = application_services().installed_apps.conversations.rename(
installed_app=installed_app,
account_id=request_context.account_id,
conversation_id=str(c_id),
name=payload.name,
auto_generate=payload.auto_generate,
)
return dump_response(SimpleConversation, conversation)
def post(self, req_data: ConversationRenamePayload, current_user: Account, installed_app: InstalledApp, c_id: UUID):
app_model = installed_app.app_with_session(session=db.session())
if app_model is None:
raise AppUnavailableError()
app_mode = AppMode.value_of(app_model.mode)
if app_mode not in {AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT}:
raise NotChatAppError()
conversation_id = str(c_id)
try:
session = db.session()
conversation = ConversationService.rename(
app_model, conversation_id, current_user, req_data.name, req_data.auto_generate, session=session
)
return (
TypeAdapter(SimpleConversation)
.validate_python(ConversationResponseSource(conversation, session=session), from_attributes=True)
.model_dump(mode="json")
)
except ConversationNotExistsError:
raise NotFound("Conversation Not Exists.")
@console_ns.route(
"/installed-apps/<uuid:installed_app_id>/conversations/<uuid:c_id>/pin", endpoint="installed_app_conversation_pin"
"/installed-apps/<uuid:installed_app_id>/conversations/<uuid:c_id>/pin",
endpoint="installed_app_conversation_pin",
)
class ConversationPinApi(Resource):
@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_apps.conversations.set_pinned(
installed_app=installed_app,
account_id=request_context.account_id,
conversation_id=str(c_id),
is_pinned=True,
)
return dump_response(ResultResponse, {"result": "success"})
class ConversationPinApi(InstalledAppResource):
@console_ns.response(200, "Success", console_ns.models[ResultResponse.__name__])
@with_current_user
def patch(self, current_user: Account, installed_app: InstalledApp, c_id: UUID):
app_model = installed_app.app_with_session(session=db.session())
if app_model is None:
raise AppUnavailableError()
app_mode = AppMode.value_of(app_model.mode)
if app_mode not in {AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT}:
raise NotChatAppError()
conversation_id = str(c_id)
try:
WebConversationService.pin(app_model, conversation_id, current_user, db.session())
except ConversationNotExistsError:
raise NotFound("Conversation Not Exists.")
return ResultResponse(result="success").model_dump(mode="json")
@console_ns.route(
"/installed-apps/<uuid:installed_app_id>/conversations/<uuid:c_id>/unpin",
endpoint="installed_app_conversation_unpin",
)
class ConversationUnPinApi(Resource):
@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_apps.conversations.set_pinned(
installed_app=installed_app,
account_id=request_context.account_id,
conversation_id=str(c_id),
is_pinned=False,
)
return dump_response(ResultResponse, {"result": "success"})
class ConversationUnPinApi(InstalledAppResource):
@console_ns.response(200, "Success", console_ns.models[ResultResponse.__name__])
@with_current_user
def patch(self, current_user: Account, installed_app: InstalledApp, c_id: UUID):
app_model = installed_app.app_with_session(session=db.session())
if app_model is None:
raise AppUnavailableError()
app_mode = AppMode.value_of(app_model.mode)
if app_mode not in {AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT}:
raise NotChatAppError()
conversation_id = str(c_id)
WebConversationService.unpin(app_model, conversation_id, current_user, db.session())
return ResultResponse(result="success").model_dump(mode="json")

View File

@@ -1,136 +1,52 @@
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."""
def __init__(self) -> None:
super().__init__()
details: dict[str, object] = {"request_id": get_request_id()}
self.data = {"code": self.error_code, "message": self.description, "status": self.code, "details": details}
class InstalledAppNotFoundHTTPError(InstalledAppHTTPError):
error_code = "installed_app_not_found"
description = "The app was not found in this workspace."
code = HTTPStatus.NOT_FOUND
class InstalledAppUnavailableHTTPError(InstalledAppHTTPError):
error_code = "installed_app_unavailable"
description = "The app is not available in this app library."
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 = HTTPStatus.BAD_REQUEST
class WebAppAccessUnavailableHTTPError(InstalledAppHTTPError):
error_code = "web_app_access_unavailable"
description = "The app access service is unavailable. Try again later."
code = HTTPStatus.SERVICE_UNAVAILABLE
class ConversationNotFoundHTTPError(InstalledAppHTTPError):
error_code = "conversation_not_found"
description = "The conversation was not found for this account and app."
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 = 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 = HTTPStatus.NOT_FOUND
class ConversationNameRequiredHTTPError(InstalledAppHTTPError):
error_code = "conversation_name_required"
description = "A name is required when automatic naming is disabled."
code = HTTPStatus.BAD_REQUEST
class MessageNotFoundHTTPError(InstalledAppHTTPError):
error_code = "message_not_found"
description = "The message was not found for this account and app."
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 = 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 = HTTPStatus.BAD_REQUEST
class NotCompletionAppError(BaseHTTPException):
error_code = "not_completion_app"
description = "Not Completion App"
code = HTTPStatus.BAD_REQUEST
code = 400
class NotChatAppError(BaseHTTPException):
error_code = "not_chat_app"
description = "App mode is invalid."
code = HTTPStatus.BAD_REQUEST
code = 400
class NotWorkflowAppError(BaseHTTPException):
error_code = "not_workflow_app"
description = "Only support workflow app."
code = HTTPStatus.BAD_REQUEST
code = 400
class AppSuggestedQuestionsAfterAnswerDisabledError(BaseHTTPException):
error_code = "app_suggested_questions_after_answer_disabled"
description = "Function Suggested questions after answer disabled."
code = HTTPStatus.FORBIDDEN
code = 403
class AppAccessDeniedError(BaseHTTPException):
error_code = "access_denied"
description = "App access denied."
code = HTTPStatus.FORBIDDEN
code = 403
class RecommendedAppNotFoundError(BaseHTTPException):
error_code = "recommended_app_not_found"
description = "Recommended app not found."
code = HTTPStatus.NOT_FOUND
code = 404
class AppPreviewSiteUnavailableError(BaseHTTPException):
error_code = "app_site_unavailable"
description = "The app preview site is unavailable."
code = HTTPStatus.FORBIDDEN
code = 403
class AppPreviewOwnerUnavailableError(BaseHTTPException):
error_code = "app_owner_unavailable"
description = "The app preview owner is unavailable."
code = HTTPStatus.FORBIDDEN
code = 403
class TrialAppNotAllowed(BaseHTTPException):
@@ -140,7 +56,7 @@ class TrialAppNotAllowed(BaseHTTPException):
"""
error_code = "trial_app_not_allowed"
code = HTTPStatus.FORBIDDEN
code = 403
description = "the app is not allowed to be trial."
@@ -151,11 +67,11 @@ class TrialAppLimitExceeded(BaseHTTPException):
"""
error_code = "trial_app_limit_exceeded"
code = HTTPStatus.FORBIDDEN
code = 403
description = "The user has exceeded the trial app limit."
class TrialAppFeatureDisabledError(BaseHTTPException):
error_code = "trial_app_feature_disabled"
code = HTTPStatus.FORBIDDEN
code = 403
description = "Trial app feature is not enabled."

View File

@@ -1,36 +1,38 @@
import base64
import binascii
import logging
from http import HTTPStatus
from flask import request
from flask_restx import Resource
from pydantic import BaseModel, Field, computed_field
from sqlalchemy import and_, select
from werkzeug.exceptions import BadRequest, Forbidden, NotFound
from controllers.common.fields import SimpleResultMessageResponse
from controllers.common.fields import SimpleMessageResponse, SimpleResultMessageResponse
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 (
InstalledAppInvalidCursorError,
InstalledAppNotFoundHTTPError,
InstalledAppUnavailableHTTPError,
WebAppAccessUnavailableHTTPError,
from controllers.console.explore.wraps import InstalledAppResource
from controllers.console.wraps import (
account_initialization_required,
cloud_edition_billing_resource_check,
model_validate,
with_current_tenant_id,
with_current_user,
)
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 extensions.ext_application_services import application_services
from extensions.ext_database import db
from fields.base import ResponseModel
from graphon.file import helpers as file_helpers
from libs.datetime_utils import naive_utc_now
from libs.helper import dump_response, to_timestamp
from machinery.context import RequestContext
from libs.login import login_required
from models import Account, App, InstalledApp, RecommendedApp
from models.model import AppMode, IconType
from services.installed_app_access_service import InstalledAppNotFoundError, InstalledAppRef
from services.installed_app_service import (
InstalledAppCursor,
InstalledAppRecord,
InstalledAppUnavailableError,
)
from services.webapp_access_query_service import WebAppAccessUnavailableError
from services.account_service import TenantService
from services.installed_app_service import InstalledAppCursor, InstalledAppService
class InstalledAppCreatePayload(BaseModel):
app_id: str
class InstalledAppUpdatePayload(BaseModel):
@@ -73,8 +75,8 @@ def _decode_installed_app_cursor(cursor: str | None) -> InstalledAppCursor | Non
padded_cursor = cursor + "=" * (-len(cursor) % 4)
payload = base64.b64decode(padded_cursor, altchars=b"-_", validate=True)
return InstalledAppCursor.model_validate_json(payload)
except (binascii.Error, UnicodeDecodeError, ValueError) as error:
raise InstalledAppInvalidCursorError() from error
except (binascii.Error, UnicodeDecodeError, ValueError):
raise BadRequest("Invalid cursor") from None
class InstalledAppInfoResponse(ResponseModel):
@@ -100,6 +102,7 @@ class InstalledAppResponse(ResponseModel):
is_pinned: bool
last_used_at: int | None
editable: bool
uninstallable: bool
class InstalledAppListResponse(ResponseModel):
@@ -109,22 +112,26 @@ class InstalledAppListResponse(ResponseModel):
def _installed_app_response_data(
installed_app: InstalledAppRecord,
installed_app: InstalledApp,
app_model: App,
*,
editable: bool,
current_tenant_id: str,
current_user: Account,
) -> InstalledAppResponse:
return InstalledAppResponse(
id=installed_app.id,
app=InstalledAppInfoResponse.model_validate(installed_app.app, from_attributes=True),
app=InstalledAppInfoResponse.model_validate(app_model),
app_owner_tenant_id=installed_app.app_owner_tenant_id,
is_pinned=installed_app.is_pinned,
last_used_at=to_timestamp(installed_app.last_used_at),
editable=editable,
editable=current_user.role in {"owner", "admin"},
uninstallable=current_tenant_id == installed_app.app_owner_tenant_id,
)
register_schema_models(
console_ns,
InstalledAppCreatePayload,
InstalledAppUpdatePayload,
InstalledAppsListQuery,
)
@@ -133,95 +140,155 @@ register_response_schema_models(
InstalledAppInfoResponse,
InstalledAppResponse,
InstalledAppListResponse,
SimpleMessageResponse,
SimpleResultMessageResponse,
)
@console_ns.route("/installed-apps")
class InstalledAppsListApi(Resource):
@login_required
@account_initialization_required
@console_ns.doc(params=query_params_from_model(InstalledAppsListQuery))
@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]:
@console_ns.response(200, "Success", console_ns.models[InstalledAppListResponse.__name__])
@with_current_user
@with_current_tenant_id
def get(self, current_tenant_id: str, current_user: Account):
query = InstalledAppsListQuery.model_validate(request.args.to_dict())
cursor = _decode_installed_app_cursor(query.cursor)
try:
page = application_services().installed_apps.management.get_visible_page(
tenant_id=request_context.active_workspace_id,
user_id=request_context.account_id,
cursor=cursor,
limit=query.limit,
app_id=query.app_id,
name=query.name,
)
except WebAppAccessUnavailableError as error:
logger.exception(
"Installed app list access check failed: workspace_id=%s account_id=%s request_id=%s",
request_context.active_workspace_id,
request_context.account_id,
request_context.request_id,
)
raise WebAppAccessUnavailableHTTPError() from error
if current_user.current_tenant is None:
raise ValueError("current_user.current_tenant must not be None")
installed_apps, has_more, next_cursor = InstalledAppService.get_visible_page(
tenant_id=current_tenant_id,
user_id=str(current_user.id),
cursor=cursor,
limit=query.limit,
app_id=query.app_id,
name=query.name,
session=db.session,
)
current_user.role = TenantService.get_user_role(current_user, current_user.current_tenant, session=db.session())
installed_app_list = [
_installed_app_response_data(
installed_app,
editable=page.editable,
app_model,
current_tenant_id=current_tenant_id,
current_user=current_user,
)
for installed_app in page.data
for installed_app, app_model in installed_apps
]
logger.debug("installed_app_list: %s, user_id: %s", installed_app_list, request_context.account_id)
logger.debug("installed_app_list: %s, user_id: %s", installed_app_list, current_user.id)
return dump_response(
InstalledAppListResponse,
{
"installed_apps": installed_app_list,
"has_more": page.has_more,
"next_cursor": _encode_installed_app_cursor(page.next_cursor) if page.next_cursor else None,
"has_more": has_more,
"next_cursor": _encode_installed_app_cursor(next_cursor) if next_cursor else None,
},
)
@login_required
@account_initialization_required
@cloud_edition_billing_resource_check("apps")
@console_ns.expect(console_ns.models[InstalledAppCreatePayload.__name__])
@console_ns.response(200, "Success", console_ns.models[SimpleMessageResponse.__name__])
@with_current_tenant_id
@model_validate(InstalledAppCreatePayload)
def post(self, req_data: InstalledAppCreatePayload, current_tenant_id: str):
recommended_app = db.session.scalar(
select(RecommendedApp).where(RecommendedApp.app_id == req_data.app_id).limit(1)
)
if recommended_app is None:
raise NotFound("Recommended app not found")
app = db.session.get(App, req_data.app_id)
if app is None:
raise NotFound("App entity not found")
if not app.is_public:
raise Forbidden("You can't install a non-public app")
installed_app = db.session.scalar(
select(InstalledApp)
.where(and_(InstalledApp.app_id == req_data.app_id, InstalledApp.tenant_id == current_tenant_id))
.limit(1)
)
if installed_app is None:
# todo: position
recommended_app.install_count += 1
new_installed_app = InstalledApp(
app_id=req_data.app_id,
tenant_id=current_tenant_id,
app_owner_tenant_id=app.tenant_id,
is_pinned=False,
last_used_at=naive_utc_now(),
)
db.session.add(new_installed_app)
db.session.commit()
return {"message": "App installed successfully"}
@console_ns.route("/installed-apps/<uuid:installed_app_id>")
class InstalledAppApi(Resource):
"""Read or update an admitted workspace installation."""
class InstalledAppApi(InstalledAppResource):
"""
get, update, and delete an installed app
use InstalledAppResource to apply default decorators and get installed_app
"""
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[InstalledAppResponse.__name__])
@console_account_admission()
@get_installed_app
@console_ns.response(200, "Success", console_ns.models[InstalledAppResponse.__name__])
@with_current_user
@with_current_tenant_id
def get(
self,
request_context: RequestContext,
installed_app: InstalledAppRef,
) -> dict[str, object]:
try:
detail = application_services().installed_apps.management.get_detail(
installed_app=installed_app, account_id=request_context.account_id
)
except InstalledAppUnavailableError as error:
raise InstalledAppUnavailableHTTPError() from error
except InstalledAppNotFoundError as error:
raise InstalledAppNotFoundHTTPError() from error
current_tenant_id: str,
current_user: Account,
installed_app: InstalledApp,
):
app_model = InstalledAppService.get_published_app(installed_app.app_id, session=db.session)
if app_model is None:
raise NotFound("Installed app not found")
if current_user.current_tenant is None:
raise ValueError("current_user.current_tenant must not be None")
current_user.role = TenantService.get_user_role(current_user, current_user.current_tenant, session=db.session())
return dump_response(
InstalledAppResponse,
_installed_app_response_data(
detail.installation,
editable=detail.editable,
installed_app,
app_model,
current_tenant_id=current_tenant_id,
current_user=current_user,
),
)
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[SimpleResultMessageResponse.__name__])
@console_ns.response(204, "App uninstalled successfully")
@with_current_tenant_id
def delete(self, current_tenant_id: str, installed_app: InstalledApp):
if installed_app.app_owner_tenant_id == current_tenant_id:
raise BadRequest("You can't uninstall an app owned by the current tenant")
db.session.delete(installed_app)
db.session.commit()
return "", 204
@console_ns.response(200, "Success", console_ns.models[SimpleResultMessageResponse.__name__])
@console_ns.expect(console_ns.models[InstalledAppUpdatePayload.__name__])
@console_account_admission()
@get_installed_app
@model_validate(InstalledAppUpdatePayload)
def patch(
self, req_data: InstalledAppUpdatePayload, request_context: RequestContext, installed_app: InstalledAppRef
) -> dict[str, str]:
try:
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
def patch(self, req_data: InstalledAppUpdatePayload, installed_app: InstalledApp):
commit_args = False
if req_data.is_pinned is not None:
installed_app.is_pinned = req_data.is_pinned
commit_args = True
if commit_args:
db.session.commit()
return {"result": "success", "message": "App info updated successfully"}

View File

@@ -1,57 +0,0 @@
"""Installed-app admission for handlers using a pure installation reference."""
import logging
from collections.abc import Callable
from functools import wraps
from typing import Concatenate
from controllers.console.explore.error import (
AppAccessDeniedError,
InstalledAppNotFoundHTTPError,
WebAppAccessUnavailableHTTPError,
)
from extensions.ext_application_services import application_services
from machinery.context import RequestContext
from services.installed_app_access_service import (
InstalledAppAccessDeniedError,
InstalledAppNotFoundError,
InstalledAppRef,
)
from services.webapp_access_query_service import WebAppAccessUnavailableError
logger = logging.getLogger(__name__)
def get_installed_app[T, **P, R](
view: Callable[Concatenate[T, RequestContext, InstalledAppRef, P], R],
) -> Callable[Concatenate[T, RequestContext, P], R]:
"""Resolve the route's installation after Console account admission."""
@wraps(view)
def decorated(self: T, request_context: RequestContext, /, *args: P.args, **kwargs: P.kwargs) -> R:
installed_app_id = kwargs.pop("installed_app_id", None)
if installed_app_id is None:
raise RuntimeError("The installed-app admission route must provide installed_app_id")
try:
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,
)
except InstalledAppNotFoundError as error:
raise InstalledAppNotFoundHTTPError() from error
except InstalledAppAccessDeniedError as error:
raise AppAccessDeniedError() from error
except WebAppAccessUnavailableError as error:
logger.exception(
"Installed app admission access check failed: installed_app_id=%s workspace_id=%s "
"account_id=%s request_id=%s",
installed_app_id,
request_context.active_workspace_id,
request_context.account_id,
request_context.request_id,
)
raise WebAppAccessUnavailableHTTPError() from error
return view(self, request_context, installed_app, *args, **kwargs)
return decorated

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