Compare commits

...

137 Commits

Author SHA1 Message Date
18b6568c2a fix: refine integrations sidebar controls 2026-05-20 16:21:25 -07:00
a3a9ded29b chore: localize main nav and integrations copy 2026-05-20 15:50:52 -07:00
de78a26920 fix: scope dataset detail navigation routes 2026-05-20 15:50:20 -07:00
c54d029e7c fix: restore dataset list markup 2026-05-20 15:49:49 -07:00
ad4b9dc2c3 refactor: reuse toggle group in update settings 2026-05-20 12:42:53 -07:00
cdec0c69a6 chore: learn dify try action same to template 2026-05-20 18:04:43 +08:00
53acc3726c merge 2026-05-20 17:43:06 +08:00
848c15a265 chore: update to only SaaS can view template (#36440) 2026-05-20 08:18:26 +00:00
yyh
be8627233d ci: show web test shard failures (#36436) 2026-05-20 08:03:15 +00:00
1fe8b7fb1d fix(auth): use validity-returned token in ChangePasswordForm reset submit (#36415) 2026-05-20 07:59:09 +00:00
yyh
5a585c8618 refactor(web): use dropdown data attributes (#36431)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-05-20 07:32:11 +00:00
cc9b90a5ae chore(api): cap non-dev dependency major versions (#36429) 2026-05-20 07:25:50 +00:00
b64d4b53ca chore: move API readiness reporting to terminal output (#36433) 2026-05-20 07:23:35 +00:00
b1d393f4d9 chore: hide select model provider in model provider page 2026-05-20 15:22:14 +08:00
5cdf4e405b fix(web): debounce email check when change email (#36421)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: JzoNgKVO <27049666+JzoNgKVO@users.noreply.github.com>
2026-05-20 07:09:15 +00:00
62e9bdd70d chore: app permission show in app card 2026-05-20 15:00:18 +08:00
7cb14cb4cc chore(codeowners): assign trigger scheduler ownership (#36430) 2026-05-20 06:48:34 +00:00
d36c76c20e merge 2026-05-20 14:34:04 +08:00
de38bba99b chore: example for [Refactor/Chore] add missing-override-decorator #36406 (#36425)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-05-20 06:16:52 +00:00
f04d809426 fix(api): fix invalid token error while changing email (#36412) 2026-05-20 05:51:15 +00:00
7ed3c7c500 chore: Check more files (#36407)
Co-authored-by: 99 <wh2099@pm.me>
2026-05-20 04:20:18 +00:00
yyh
77f1aeb1ac fix(web): resolve model provider console warnings (#36422) 2026-05-20 04:02:01 +00:00
7bc5c89e3c fix: prevent recursion error when SharePoint folder is empty (#36372) 2026-05-20 03:56:49 +00:00
718ab8433e chore: bump versions for litellm and langsmith (#36385) 2026-05-20 03:50:05 +00:00
8f197c5a0a build: fix api docker build (#36423) 2026-05-20 03:48:18 +00:00
0295862d0d chore(deps): bump the storage group across 1 directory with 4 updates (#36393)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-20 03:41:43 +00:00
2b2a5824c1 refactor: migrate to tailwind v4 style (#36417) 2026-05-20 03:39:44 +00:00
yyh
468cc19e68 fix(web): prevent local cloud analytics script errors (#36420) 2026-05-20 03:23:21 +00:00
77333e57a7 refactor: convert isinstance chains to match/case pattern (#36364) 2026-05-20 03:07:19 +00:00
f52491e2c1 chore: update deps (#36413) 2026-05-20 01:48:30 +00:00
f525e1a5eb fix(web): align onboarding and integrations i18n copy 2026-05-19 16:03:01 -07:00
05408af8a1 fix: fix add uv_cache_dir env (#36398) 2026-05-19 17:56:54 +00:00
yyh
d3ae074456 chore(web): remove generic tailwind skill (#36402) 2026-05-19 13:16:10 +00:00
0b48a7e991 fix: workflow node selection state not sync caused problem (#36390) 2026-05-19 12:29:16 +00:00
809f513ccb chore(codeowners): update plugin ownership (#36394) 2026-05-19 19:25:49 +08:00
d9e90d0fa0 feat: add new agent (#36284)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-05-19 10:43:23 +00:00
d1417bbe4b fix(api): add Phoenix wrapper spans and error tracing (#36388)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-05-19 10:09:23 +00:00
2565637e36 test: stabilize trigger subscription name uniqueness setup (#36353) 2026-05-19 10:09:02 +00:00
cae9923e5a fix: prevent agent tool info popover from jumping on close (#36389)
Co-authored-by: yyh <yuanyouhuilyz@gmail.com>
2026-05-19 09:54:52 +00:00
yyh
a328bbbced feat(dev-proxy): reload env file changes (#36384) 2026-05-19 08:24:47 +00:00
5276eb689b chore: hide model provider setting in default model setting (#36383) 2026-05-19 08:19:58 +00:00
yyh
4b2badb6f2 refactor(web): migrate multi-checkbox lists to CheckboxGroup (#36381)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-05-19 07:55:33 +00:00
34a89416f7 test(api): manage backend pytest services natively (#36235) 2026-05-19 07:52:15 +00:00
e2f779b20d chore: load 8 contiue items 2026-05-19 14:54:23 +08:00
a13ab76002 fix(agenton): use AsyncGenerator return annotation for asynccontextmanager (#36361)
Co-authored-by: Arya Rizky <algojogacor@users.noreply.github.com>
2026-05-19 06:19:35 +00:00
e198d6305c merge 2026-05-19 14:14:51 +08:00
b04b4449db chore(api): annotate simple contract responses (#36331)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: Asuka Minato <i@asukaminato.eu.org>
2026-05-19 06:13:20 +00:00
yyh
674cdc3521 feat(dev-proxy): isolate local auth cookies by target (#36371) 2026-05-19 05:59:55 +00:00
5e67514265 chore: support edcation action 2026-05-19 13:56:49 +08:00
b63896de87 feat: learn dify use api 2026-05-19 13:44:14 +08:00
yyh
2031d31ee8 refactor(web): migrate annotation selection to checkbox group (#36370) 2026-05-19 05:40:24 +00:00
yyh
04d62867af feat(dify-ui): add shared form primitives (#36334)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-05-19 05:38:57 +00:00
e463389f2c feat: improve integration install flows 2026-05-18 20:55:05 -07:00
cda348ca10 feat: split plugin settings by category 2026-05-18 20:54:32 -07:00
ca48050666 feat: refine integrations page shell controls 2026-05-18 20:54:11 -07:00
9c0f592f34 feat: open integrations settings in account modal 2026-05-18 20:53:47 -07:00
b70241ad36 fix: app list not refresh 2026-05-18 12:11:08 -07:00
4abe622b2e feat: continue with use the app list data 2026-05-18 12:10:58 -07:00
16c32c82e3 feat: knowledge new sidebar 2026-05-18 12:10:48 -07:00
46424513d1 chore: missing files 2026-05-18 12:10:37 -07:00
2c4baa20d8 feat: app new nav 2026-05-18 12:10:25 -07:00
b0ae553f2e fix(web): correct custom icon class names 2026-05-18 12:07:16 -07:00
0266a12ee5 fix(web): align rebased UI type contracts 2026-05-18 11:19:12 -07:00
9d7765d5fd docs: update main nav follow-up notes 2026-05-18 11:16:16 -07:00
d4ef983f42 refactor(web): organize integrations page helpers 2026-05-18 11:16:16 -07:00
018f36711d fix(web): route document settings to integrations 2026-05-18 11:16:16 -07:00
dacd333e4a chore(i18n): rename plugin-facing copy to integrations 2026-05-18 11:16:16 -07:00
b079a26314 fix(web): gate integrations install actions 2026-05-18 11:16:15 -07:00
7e953ebe0b feat(web): complete update setting popover 2026-05-18 11:16:15 -07:00
b4d28fca54 fix(web): polish integration page titles 2026-05-18 11:16:15 -07:00
728c6b8201 chore: rename to marketplace path 2026-05-18 11:16:15 -07:00
f56e23b5fd chore: remove discover entrance 2026-05-18 11:16:15 -07:00
5600cefa53 feat: add interation discover route 2026-05-18 11:16:15 -07:00
561eb9cbd2 fix: trigger, agent-strategry, extension problem 2026-05-18 11:16:15 -07:00
83766ca694 chore: new pages add to dataset route guard 2026-05-18 11:16:15 -07:00
678be94d22 fix: custom tool copywriting 2026-05-18 11:16:15 -07:00
9e852429be chore: split logic from accont setting and integrating setting 2026-05-18 11:16:15 -07:00
d93c5028f1 chore: rename to integration setting 2026-05-18 11:14:15 -07:00
54f189305e chore: use new hook to handle setting 2026-05-18 11:13:33 -07:00
a610a24507 chore: filter apps and knowledges no data 2026-05-18 11:12:17 -07:00
05e8a94bb5 fix: not configure default model tip not align 2026-05-18 11:12:17 -07:00
b2e2e7b60b chore: homepage coninue with to improve 2026-05-18 11:12:17 -07:00
e7d2e66ff5 chore: popup create hide some 2026-05-18 11:12:17 -07:00
c51069685c chore: some tiny style 2026-05-18 11:12:17 -07:00
28c208f36a feat: knowledge items 2026-05-18 11:12:17 -07:00
53a1386b87 feat: knowledge title 2026-05-18 11:12:17 -07:00
0e366c7300 chore: show no empty logic 2026-05-18 11:12:17 -07:00
939bdde373 feat: knowledge empty list 2026-05-18 11:12:17 -07:00
13dfa3aba4 feat(integrations): add unavailable page fallback 2026-05-18 11:12:16 -07:00
2705a7c1db feat(integrations): align tools and plugin category UI 2026-05-18 11:12:16 -07:00
258a751b8c feat(integrations): improve data source plugin management 2026-05-18 11:12:16 -07:00
5a35d3d9cd feat(plugin): add update settings popover 2026-05-18 11:12:16 -07:00
c3fbafae83 chore(i18n): localize integrations updates 2026-05-18 11:12:16 -07:00
f727c8f838 docs: update frontend agent guidance 2026-05-18 11:12:16 -07:00
90af4c39b4 chore: some small ui 2026-05-18 11:12:16 -07:00
f7c3a4e4cb feat: empty page 2026-05-18 11:12:16 -07:00
be7d043edd chore: remove mock app data 2026-05-18 11:12:16 -07:00
cef8fe3a4b chore: remove shortcut 2026-05-18 11:12:16 -07:00
afe0e6c393 chore: missing files 2026-05-18 11:12:16 -07:00
37309b931e feat: new head 2026-05-18 11:12:15 -07:00
6a83c6705c temp: app hearder 2026-05-18 11:10:59 -07:00
3e75d5e443 chore: create app card 2026-05-18 11:10:11 -07:00
7be8a5b883 chore: app card ui 2026-05-18 11:10:11 -07:00
80dcb344f4 docs: record integrations install permission follow-up 2026-05-18 11:10:11 -07:00
b029c9b1cd feat: add integrations plugin category views 2026-05-18 11:10:11 -07:00
6cb97e9201 fix: align tools and mcp provider behavior 2026-05-18 11:10:11 -07:00
4ef2e952bd feat: add integrations page shell refinements 2026-05-18 11:10:10 -07:00
cc5545339c docs: update frontend review guidance
Document shared component reuse and component-writing checks for future frontend reviews, and refresh the MainNav follow-up notes.
2026-05-18 11:10:10 -07:00
0a8c46a3a7 refactor: polish integrations and main nav UI
Reuse shared base controls in MainNav and Integrations, add active integration icons, and keep compact integration content framing covered by targeted tests.
2026-05-18 11:10:10 -07:00
65770903d1 feat: refine integrations layout and controls
- add integrations headers, install action, permission quick settings, and update setting entry points

- centralize default vs compact content insets for integrations child pages

- cover provider, plugin, marketplace, MCP, and model provider behaviors with focused tests
2026-05-18 11:10:10 -07:00
5a6ba2ffb5 fix: localize integrations i18n copy 2026-05-18 11:09:15 -07:00
aa53afe07d fix: update custom tool integration route 2026-05-18 11:09:14 -07:00
4740a89f4a feat: add canonical integrations routes 2026-05-18 11:09:14 -07:00
328db3d67a fix: align main nav interactions
Update active main nav icon positioning from the refreshed Figma assets, remove the transparent active border that caused nav item jitter, and route mobile common layout through the new MainNav instead of the legacy Header.

Also align workspace plan actions with the new UI contract by showing Upgrade for sandbox workspaces and View Plan for paid workspaces, both opening the pricing modal.
2026-05-18 11:09:14 -07:00
88062fb247 feat: explore page to home page 2026-05-18 11:09:14 -07:00
045da59220 chore: app card icon and palce of learn dify 2026-05-18 11:09:14 -07:00
948b0f6bc7 chore: templates item ui and learn dify 2026-05-18 11:09:14 -07:00
14a59f6e44 chore: tag ui 2026-05-18 11:09:14 -07:00
f9f361113e feat: add description and tag filter 2026-05-18 11:09:14 -07:00
eea6f59307 chore: remove more learning templates and templates copywrite 2026-05-18 11:09:14 -07:00
718f69dc43 feat: hide learn dify anim effect 2026-05-18 11:09:14 -07:00
82a2ba9264 feat: learn dify 2026-05-18 11:09:14 -07:00
6c8e032fbb chore: fix small css 2026-05-18 11:09:14 -07:00
28c2c3bfd3 chore: split icon to new file and enchance data struct 2026-05-18 11:09:14 -07:00
9d463e1024 feat: continue work 2026-05-18 11:09:14 -07:00
7f87616625 chore: no show slide logic 2026-05-18 11:09:14 -07:00
43a04ed0c2 feat: finish slide 2026-05-18 11:09:13 -07:00
5083edd0ce fix: align main nav gating and account popup behavior 2026-05-18 11:09:13 -07:00
8306fa41b9 fix(web): align main nav defaults
Default integrations to the model provider section and route the main nav entry there.

Hide cloud-only workspace credits and upgrade actions outside cloud edition.

Add the repo-local karpathy-guidelines skill.
2026-05-18 11:09:13 -07:00
8f33305e90 docs: update iconify review guidance
- generalize generated icon diff review guidance for intrinsic width and height changes
2026-05-18 11:09:13 -07:00
7077a43c1c feat: add integrations tools page with prebuilt icons
- add the integrations page sidebar with collapsible icon-only navigation and Figma-aligned marketplace card
- move custom integration SVGs into the iconify collection and document the Tailwind i-custom workflow
- preserve source SVG collection dimensions when flattening generated icon data so existing main nav icons keep their 20x20 viewBox
- add an icon dimension guard for layout-sensitive generated icons
- update model provider routing, i18n, and focused frontend tests
2026-05-18 11:09:13 -07:00
884a43ae0a fix(web): preserve settings fallbacks during main nav update
- hide migrated settings tabs from the account settings sidebar

- add disabled integrations destination mapping for future migration

- keep legacy settings modal fallback until integrations sections are ready

- restore main nav active styling and add titles for truncated labels
2026-05-18 11:09:13 -07:00
914f89f478 refactor(web): align main nav review feedback
- move main nav active edge styling into Tailwind classes

- split account dropdown menu content into focused components

- align frontend review skill rules with i18n and styling guidance

- add missing common i18n keys across supported locales
2026-05-18 11:09:13 -07:00
163153db18 refactor(web): split main nav components
- Move MainNav sections into focused components under main-nav/components

- Reuse Explore AppNavItem for MainNav web app rows via a mainNav variant

- Keep WorkspaceCard expanded panel behavior and styling aligned with the pre-refactor UI
2026-05-18 11:09:13 -07:00
49d890d514 feat(web): refine main nav onboarding UI
- Add a reusable dimm Badge variant for workspace plan labels

- Update MainNav workspace, web apps, account, and help menu styling to match Figma

- Add MainNav-specific account dropdown with appearance, language, timezone, and logout entries

- Keep account trigger compact without plan badge while preserving the badge in the popup header

- Prevent the common layout shell from creating a page-level scrollbar
2026-05-18 11:09:13 -07:00
0292bc2728 feat: refine desktop main nav visuals 2026-05-18 11:09:13 -07:00
5c21120977 feat: add desktop main navigation 2026-05-18 11:09:13 -07:00
1846 changed files with 40349 additions and 15116 deletions

View File

@ -1,6 +1,6 @@
---
name: frontend-code-review
description: "Trigger when the user requests a review of frontend files (e.g., `.tsx`, `.ts`, `.js`). Support both pending-change reviews and focused file reviews while applying the checklist rules."
description: "Trigger when the user requests a review of frontend files (e.g., `.tsx`, `.ts`, `.js`). Support pending-change and focused file reviews while applying checklist rules, shared component reuse checks, and React component structure guidance from how-to-write-component."
---
# Frontend Code Review
@ -16,10 +16,12 @@ Stick to the checklist below for every applicable file and mode.
## Checklist
See [references/code-quality.md](references/code-quality.md), [references/performance.md](references/performance.md), [references/business-logic.md](references/business-logic.md) for the living checklist split by category—treat it as the canonical set of rules to follow.
When reviewing React/TypeScript components, also apply the repo-local `how-to-write-component` skill as the component architecture checklist. In particular, check ownership boundaries, props and API types, query/mutation usage, navigation choices, effect usage, unnecessary wrappers, and unnecessary memoization.
Flag each rule violation with urgency metadata so future reviewers can prioritize fixes.
## Review Process
1. Open the relevant component/module. Gather lines that relate to class names, React Flow hooks, prop memoization, and styling.
1. Open the relevant component/module. Gather lines that relate to shared base/dify-ui component reuse, class names, styling/CSS imports, file size and component boundaries, i18n keys, behavior-sensitive UI interactions, React Flow hooks, and prop memoization.
2. For each rule in the review point, note where the code deviates and capture a representative snippet.
3. Compose the review section per the template below. Group violations first by **Urgent** flag, then by category order (Code Quality, Performance, Business Logic).
@ -70,4 +72,3 @@ If you use Template A (i.e., there are issues to fix) and at least one issue req
## Code review
No issues found.
```

View File

@ -13,3 +13,29 @@ Node components are also used when creating a RAG Pipe from a template, but in t
### Suggested Fix
Use `import { useNodes } from 'reactflow'` instead of `import useNodes from '@/app/components/workflow/store/workflow/use-nodes'`.
## Locale keys must be complete
IsUrgent: True
Category: Business Logic
### Description
When adding or changing user-facing i18n keys, ensure every supported locale file has the same key set as `web/i18n/en-US/`. Do not add only English keys or only a partial subset of locales; `pnpm i18n:check --file <name>` should pass for the touched translation file.
### Suggested Fix
Add matching keys to every existing supported locale file for the touched translation namespace, keeping key paths aligned with the English entry.
## Preserve behavior-sensitive interactions
IsUrgent: True
Category: Business Logic
### Description
When changing existing navigation, sidebar, dropdown, webapp list, or app-switching UI, compare behavior against the existing implementation before approving the change. Watch for regressions in expand/collapse arrows, hover persistence, pin/delete controls, routing, keyboard/focus handling, and open-state ownership.
### Suggested Fix
Reuse or extend the existing component when it already owns the interaction logic. If a refactor is needed, preserve the old interaction contract and add or update focused tests for the changed behavior.

View File

@ -7,12 +7,12 @@ Category: Code Quality
### Description
Ensure conditional CSS is handled via the shared `classNames` instead of custom ternaries, string concatenation, or template strings. Centralizing class logic keeps components consistent and easier to maintain.
Ensure conditional CSS and multi-line class composition are handled via the shared `cn` helper instead of custom ternaries, string concatenation, array `.join(' ')`, or template strings. Centralizing class logic keeps components consistent and easier to maintain.
### Suggested Fix
```ts
import { cn } from '@/utils/classnames'
import { cn } from '@langgenius/dify-ui/cn'
const classNames = cn(isActive ? 'text-primary-600' : 'text-gray-500')
```
@ -25,7 +25,34 @@ Category: Code Quality
Favor Tailwind CSS utility classes instead of adding new `.module.css` files unless a Tailwind combination cannot achieve the required styling. Keeping styles in Tailwind improves consistency and reduces maintenance overhead.
Update this file when adding, editing, or removing Code Quality rules so the catalog remains accurate.
## CSS files must be scoped
IsUrgent: True
Category: Code Quality
### Description
When CSS is truly necessary, use component-scoped `*.module.css`. Do not add component-level CSS through plain `.css` files, and do not import component CSS from `globals.css`; both patterns risk style leakage across the app.
## Split oversized components cautiously
Category: Code Quality
### Description
When a frontend file grows large or mixes multiple responsibilities, suggest splitting it into focused components, hooks, or utilities. Prefer shallow local structure that matches existing repo patterns, such as a sibling `components/` folder, and avoid deep folder hierarchies unless the surrounding code already uses them.
## Reuse base and dify-ui components before hand-rolling UI
Category: Code Quality
### Description
Before approving new or modified frontend UI, check whether the code manually recreates behavior or styling already owned by `@langgenius/dify-ui/*` or `web/app/components/base/*`. Common examples include `Button`, `Input`, `ToggleGroup`, `Popover`, `DropdownMenu`, `AlertDialog`, `Switch`, `Avatar`, `ScrollArea`, `toast`, and existing feature components. Prefer composing existing primitives instead of duplicating borders, focus states, disabled states, segmented controls, inputs, overlays, or buttons.
### Suggested Fix
Replace hand-written UI chrome with the nearest shared primitive, keeping feature-specific layout, state ownership, labels, and workflow behavior local.
## Classname ordering for easy overrides
@ -36,9 +63,11 @@ When writing components, always place the incoming `className` prop after the co
Example:
```tsx
import { cn } from '@/utils/classnames'
import { cn } from '@langgenius/dify-ui/cn'
const Button = ({ className }) => {
return <div className={cn('bg-primary-600', className)}></div>
}
```
Update this file when adding, editing, or removing Code Quality rules so the catalog remains accurate.

View File

@ -43,3 +43,14 @@ const config = useMemo(() => ({
config={config}
/>
```
## Custom SVG icon generation
IsUrgent: False
Category: Performance
### Description
New custom SVG icons should be added to `packages/iconify-collections/assets/...`, generated with `pnpm --filter @dify/iconify-collections generate`, checked with `pnpm --filter @dify/iconify-collections check:dimensions`, and consumed through Tailwind `i-custom-*` classes. Do not add new generated React icon components or JSON files under `web/app/components/base/icons/src/...` for new custom SVG icons.
When reviewing generated `packages/iconify-collections/custom-*/icons.json` diffs, verify unrelated existing icons did not lose or change intrinsic `width` / `height`.

View File

@ -12,7 +12,7 @@ Use this as the decision guide for React/TypeScript component structure. Existin
- Search before adding UI, hooks, helpers, or styling patterns. Reuse existing base components, feature components, hooks, utilities, and design styles when they fit.
- Group code by feature workflow, route, or ownership area: components, hooks, local types, query helpers, atoms, constants, and small utilities should live near the code that changes with them.
- Promote code to shared only when multiple verticals need the same stable primitive. Otherwise keep it local and compose shared primitives inside the owning feature.
- Use Tailwind CSS v4.1+ rules via the `tailwind-css-rules` skill. Prefer v4 utilities, `gap`, `text-size/line-height`, `min-h-dvh`, and avoid deprecated utilities and `@apply`.
- Follow Dify's CSS-first Tailwind v4 contract from `packages/dify-ui/README.md` and `packages/dify-ui/AGENTS.md`. Prefer design-system tokens, utilities, and radius mappings over generic Tailwind guidance.
## Ownership

View File

@ -0,0 +1,33 @@
---
name: karpathy-guidelines
description: Lightweight coding guardrails for making focused, simple, and verifiable changes in this repo. Use for all coding work.
---
# Karpathy Guidelines
Use this skill whenever you touch code in this repository.
## Principles
- Keep the change small and directly tied to the user request.
- Prefer the simplest implementation that fits the existing codebase.
- Read the nearby code first, then match its patterns.
- Avoid unrelated refactors, broad rewrites, or style churn.
- Preserve existing behavior unless the user explicitly asked to change it.
- Treat regressions as a signal to narrow the change, not to add workaround layers.
## Workflow
1. Inspect the current implementation and tests around the change.
2. Make the smallest coherent edit.
3. Add or update focused tests when the behavior changes or the risk is non-trivial.
4. Run the narrowest relevant verification first.
5. Report exactly what was verified and anything left unverified.
## Review Checklist
- Does this change solve the stated problem without expanding scope?
- Did it preserve existing route/component/data-flow semantics?
- Are new abstractions justified by real complexity?
- Are tests focused on the behavior that could regress?
- Are unrelated files and generated artifacts left alone?

View File

@ -1,367 +0,0 @@
---
name: tailwind-css-rules
description: Tailwind CSS v4.1+ rules and best practices. Use when writing, reviewing, refactoring, or upgrading Tailwind CSS classes and styles, especially v4 utility migrations, layout spacing, typography, responsive variants, dark mode, gradients, CSS variables, and component styling.
---
# Tailwind CSS Rules and Best Practices
## Core Principles
- **Always use Tailwind CSS v4.1+** - Ensure the codebase is using the latest version
- **Do not use deprecated or removed utilities** - ALWAYS use the replacement
- **Never use `@apply`** - Use CSS variables, the `--spacing()` function, or framework components instead
- **Check for redundant classes** - Remove any classes that aren't necessary
- **Group elements logically** to simplify responsive tweaks later
## Upgrading to Tailwind CSS v4
### Before Upgrading
- **Always read the upgrade documentation first** - Read https://tailwindcss.com/docs/upgrade-guide and https://tailwindcss.com/blog/tailwindcss-v4 before starting an upgrade.
- Ensure the git repository is in a clean state before starting
### Upgrade Process
1. Run the upgrade command: `npx @tailwindcss/upgrade@latest` for both major and minor updates
2. The tool will convert JavaScript config files to the new CSS format
3. Review all changes extensively to clean up any false positives
4. Test thoroughly across your application
## Breaking Changes Reference
### Removed Utilities (NEVER use these in v4)
| ❌ Deprecated | ✅ Replacement |
| ----------------------- | ------------------------------------------------- |
| `bg-opacity-*` | Use opacity modifiers like `bg-black/50` |
| `text-opacity-*` | Use opacity modifiers like `text-black/50` |
| `border-opacity-*` | Use opacity modifiers like `border-black/50` |
| `divide-opacity-*` | Use opacity modifiers like `divide-black/50` |
| `ring-opacity-*` | Use opacity modifiers like `ring-black/50` |
| `placeholder-opacity-*` | Use opacity modifiers like `placeholder-black/50` |
| `flex-shrink-*` | `shrink-*` |
| `flex-grow-*` | `grow-*` |
| `overflow-ellipsis` | `text-ellipsis` |
| `decoration-slice` | `box-decoration-slice` |
| `decoration-clone` | `box-decoration-clone` |
### Renamed Utilities
Use the v4 name when migrating code that still carries Tailwind v3 semantics. Do not blanket-replace existing v4 classes: classes such as `rounded-sm`, `shadow-sm`, `ring-1`, and `ring-2` are valid in this codebase when they intentionally represent the current design scale.
| ❌ v3 pattern | ✅ v4 pattern |
| ------------------- | -------------------------------------------------- |
| `bg-gradient-*` | `bg-linear-*` |
| old shadow scale | verify against the current Tailwind/design scale |
| old blur scale | verify against the current Tailwind/design scale |
| old radius scale | use the Dify radius token mapping when applicable |
| `outline-none` | `outline-hidden` |
| bare `ring` utility | use an explicit ring width such as `ring-1`/`ring-2`/`ring-3` |
For Figma radius tokens, follow `packages/dify-ui/AGENTS.md`. For example, `--radius/xs` maps to `rounded-sm`; do not rewrite it to `rounded-xs`.
## Layout and Spacing Rules
### Flexbox and Grid Spacing
#### Always use gap utilities for internal spacing
Gap provides consistent spacing without edge cases (no extra space on last items). It's cleaner and more maintainable than margins on children.
```html
<!-- ❌ Don't do this -->
<div class="flex">
<div class="mr-4">Item 1</div>
<div class="mr-4">Item 2</div>
<div>Item 3</div>
<!-- No margin on last -->
</div>
<!-- ✅ Do this instead -->
<div class="flex gap-4">
<div>Item 1</div>
<div>Item 2</div>
<div>Item 3</div>
</div>
```
#### Gap vs Space utilities
- **Never use `space-x-*` or `space-y-*` in flex/grid layouts** - always use gap
- Space utilities add margins to children and have issues with wrapped items
- Gap works correctly with flex-wrap and all flex directions
```html
<!-- ❌ Avoid space utilities in flex containers -->
<div class="flex flex-wrap space-x-4">
<!-- Space utilities break with wrapped items -->
</div>
<!-- ✅ Use gap for consistent spacing -->
<div class="flex flex-wrap gap-4">
<!-- Gap works perfectly with wrapping -->
</div>
```
### General Spacing Guidelines
- **Prefer top and left margins** over bottom and right margins (unless conditionally rendered)
- **Use padding on parent containers** instead of bottom margins on the last child
- **Always use `min-h-dvh` instead of `min-h-screen`** - `min-h-screen` is buggy on mobile Safari
- **Prefer `size-*` utilities** over separate `w-*` and `h-*` when setting equal dimensions
- For max-widths, prefer the container scale (e.g., `max-w-2xs` over `max-w-72`)
## Typography Rules
### Line Heights
- **Never use `leading-*` classes** - Always use line height modifiers with text size
- **Always use fixed line heights from the spacing scale** - Don't use named values
```html
<!-- ❌ Don't do this -->
<p class="text-base leading-7">Text with separate line height</p>
<p class="text-lg leading-relaxed">Text with named line height</p>
<!-- ✅ Do this instead -->
<p class="text-base/7">Text with line height modifier</p>
<p class="text-lg/8">Text with specific line height</p>
```
### Font Size Reference
Be precise with font sizes - know the actual pixel values:
- `text-xs` = 12px
- `text-sm` = 14px
- `text-base` = 16px
- `text-lg` = 18px
- `text-xl` = 20px
## Color and Opacity
### Opacity Modifiers
**Never use `bg-opacity-*`, `text-opacity-*`, etc.** - use the opacity modifier syntax:
```html
<!-- ❌ Don't do this -->
<div class="bg-red-500 bg-opacity-60">Old opacity syntax</div>
<!-- ✅ Do this instead -->
<div class="bg-red-500/60">Modern opacity syntax</div>
```
## Responsive Design
### Breakpoint Optimization
- **Check for redundant classes across breakpoints**
- **Only add breakpoint variants when values change**
```html
<!-- ❌ Redundant breakpoint classes -->
<div class="px-4 md:px-4 lg:px-4">
<!-- md:px-4 and lg:px-4 are redundant -->
</div>
<!-- ✅ Efficient breakpoint usage -->
<div class="px-4 lg:px-8">
<!-- Only specify when value changes -->
</div>
```
## Dark Mode
### Dark Mode Best Practices
- Use the plain `dark:` variant pattern
- Put light mode styles first, then dark mode styles
- Ensure `dark:` variant comes before other variants
```html
<!-- ✅ Correct dark mode pattern -->
<div class="bg-white text-black dark:bg-black dark:text-white">
<button class="hover:bg-gray-100 dark:hover:bg-gray-800">Click me</button>
</div>
```
## Gradient Utilities
- **ALWAYS Use `bg-linear-*` instead of `bg-gradient-*` utilities** - The gradient utilities were renamed in v4
- Use the new `bg-radial` or `bg-radial-[<position>]` to create radial gradients
- Use the new `bg-conic` or `bg-conic-*` to create conic gradients
```html
<!-- ✅ Use the new gradient utilities -->
<div class="h-14 bg-linear-to-br from-violet-500 to-fuchsia-500"></div>
<div
class="size-18 bg-radial-[at_50%_75%] from-sky-200 via-blue-400 to-indigo-900 to-90%"
></div>
<div
class="size-24 bg-conic-180 from-indigo-600 via-indigo-50 to-indigo-600"
></div>
<!-- ❌ Do not use bg-gradient-* utilities -->
<div class="h-14 bg-gradient-to-br from-violet-500 to-fuchsia-500"></div>
```
## Working with CSS Variables
### Accessing Theme Values
Tailwind CSS v4 exposes all theme values as CSS variables:
```css
/* Access colors, and other theme values */
.custom-element {
background: var(--color-red-500);
border-radius: var(--radius-lg);
}
```
### The `--spacing()` Function
Use the dedicated `--spacing()` function for spacing calculations:
```css
.custom-class {
margin-top: calc(100vh - --spacing(16));
}
```
### Extending theme values
Use CSS to extend theme values:
```css
@import "tailwindcss";
@theme {
--color-mint-500: oklch(0.72 0.11 178);
}
```
```html
<div class="bg-mint-500">
<!-- ... -->
</div>
```
## New v4 Features
### Container Queries
Use the `@container` class and size variants:
```html
<article class="@container">
<div class="flex flex-col @md:flex-row @lg:gap-8">
<img class="w-full @md:w-48" />
<div class="mt-4 @md:mt-0">
<!-- Content adapts to container size -->
</div>
</div>
</article>
```
### Container Query Units
Use container-based units like `cqw` for responsive sizing:
```html
<div class="@container">
<h1 class="text-[50cqw]">Responsive to container width</h1>
</div>
```
### Text Shadows (v4.1)
Use text-shadow-\* utilities from text-shadow-2xs to text-shadow-lg:
```html
<!-- ✅ Text shadow examples -->
<h1 class="text-shadow-lg">Large shadow</h1>
<p class="text-shadow-sm/50">Small shadow with opacity</p>
```
### Masking (v4.1)
Use the new composable mask utilities for image and gradient masks:
```html
<!-- ✅ Linear gradient masks on specific sides -->
<div class="mask-t-from-50%">Top fade</div>
<div class="mask-b-from-20% mask-b-to-80%">Bottom gradient</div>
<div class="mask-linear-from-white mask-linear-to-black/60">
Fade from white to black
</div>
<!-- ✅ Radial gradient masks -->
<div class="mask-radial-[100%_100%] mask-radial-from-75% mask-radial-at-left">
Radial mask
</div>
```
## Component Patterns
### Avoiding Utility Inheritance
Don't add utilities to parents that you override in children:
```html
<!-- ❌ Avoid this pattern -->
<div class="text-center">
<h1>Centered Heading</h1>
<div class="text-left">Left-aligned content</div>
</div>
<!-- ✅ Better approach -->
<div>
<h1 class="text-center">Centered Heading</h1>
<div>Left-aligned content</div>
</div>
```
### Component Extraction
- Extract repeated patterns into framework components, not CSS classes
- Keep utility classes in templates/JSX
- Use data attributes for complex state-based styling
## CSS Best Practices
### Nesting Guidelines
- Use nesting when styling both parent and children
- Avoid empty parent selectors
```css
/* ✅ Good nesting - parent has styles */
.card {
padding: --spacing(4);
> .card-title {
font-weight: bold;
}
}
/* ❌ Avoid empty parents */
ul {
> li {
/* Parent has no styles */
}
}
```
## Common Pitfalls to Avoid
1. **Using old opacity utilities** - Always use `/opacity` syntax like `bg-red-500/60`
2. **Redundant breakpoint classes** - Only specify changes
3. **Space utilities in flex/grid** - Always use gap
4. **Leading utilities** - Use line-height modifiers like `text-sm/6`
5. **Arbitrary values** - Use the design scale
6. **@apply directive** - Use components or CSS variables
7. **min-h-screen on mobile** - Use min-h-dvh
8. **Separate width/height** - Use size utilities when equal
9. **Arbitrary values** - Always use Tailwind's predefined scale whenever possible (e.g., use `ml-4` over `ml-[16px]`)

View File

@ -1,5 +1,6 @@
[run]
omit =
api/conftest.py
api/tests/*
api/migrations/*
api/core/rag/datasource/vdb/*

60
.github/CODEOWNERS vendored
View File

@ -4,7 +4,7 @@
# Owners can be @username, @org/team-name, or email addresses.
# For more information, see: https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners
* @crazywoola @laipz8200 @Yeuoly
* @crazywoola @laipz8200
# ESLint suppression file is maintained by autofix.ci pruning.
/eslint-suppressions.json
@ -85,39 +85,39 @@
/api/tasks/deal_dataset_vector_index_task.py @JohnJyong
# Backend - Plugins
/api/core/plugin/ @Mairuis @Yeuoly @Stream29
/api/services/plugin/ @Mairuis @Yeuoly @Stream29
/api/controllers/console/workspace/plugin.py @Mairuis @Yeuoly @Stream29
/api/controllers/inner_api/plugin/ @Mairuis @Yeuoly @Stream29
/api/tasks/process_tenant_plugin_autoupgrade_check_task.py @Mairuis @Yeuoly @Stream29
/api/core/plugin/ @WH-2099
/api/services/plugin/ @WH-2099
/api/controllers/console/workspace/plugin.py @WH-2099
/api/controllers/inner_api/plugin/ @WH-2099
/api/tasks/process_tenant_plugin_autoupgrade_check_task.py @WH-2099
# Backend - Trigger/Schedule/Webhook
/api/controllers/trigger/ @Mairuis @Yeuoly
/api/controllers/console/app/workflow_trigger.py @Mairuis @Yeuoly
/api/controllers/console/workspace/trigger_providers.py @Mairuis @Yeuoly
/api/core/trigger/ @Mairuis @Yeuoly
/api/core/app/layers/trigger_post_layer.py @Mairuis @Yeuoly
/api/services/trigger/ @Mairuis @Yeuoly
/api/models/trigger.py @Mairuis @Yeuoly
/api/fields/workflow_trigger_fields.py @Mairuis @Yeuoly
/api/repositories/workflow_trigger_log_repository.py @Mairuis @Yeuoly
/api/repositories/sqlalchemy_workflow_trigger_log_repository.py @Mairuis @Yeuoly
/api/libs/schedule_utils.py @Mairuis @Yeuoly
/api/services/workflow/scheduler.py @Mairuis @Yeuoly
/api/schedule/trigger_provider_refresh_task.py @Mairuis @Yeuoly
/api/schedule/workflow_schedule_task.py @Mairuis @Yeuoly
/api/tasks/trigger_processing_tasks.py @Mairuis @Yeuoly
/api/tasks/trigger_subscription_refresh_tasks.py @Mairuis @Yeuoly
/api/tasks/workflow_schedule_tasks.py @Mairuis @Yeuoly
/api/tasks/workflow_cfs_scheduler/ @Mairuis @Yeuoly
/api/events/event_handlers/sync_plugin_trigger_when_app_created.py @Mairuis @Yeuoly
/api/events/event_handlers/update_app_triggers_when_app_published_workflow_updated.py @Mairuis @Yeuoly
/api/events/event_handlers/sync_workflow_schedule_when_app_published.py @Mairuis @Yeuoly
/api/events/event_handlers/sync_webhook_when_app_created.py @Mairuis @Yeuoly
/api/controllers/trigger/ @CourTeous33
/api/controllers/console/app/workflow_trigger.py @CourTeous33
/api/controllers/console/workspace/trigger_providers.py @CourTeous33
/api/core/trigger/ @CourTeous33
/api/core/app/layers/trigger_post_layer.py @CourTeous33
/api/services/trigger/ @CourTeous33
/api/models/trigger.py @CourTeous33
/api/fields/workflow_trigger_fields.py @CourTeous33
/api/repositories/workflow_trigger_log_repository.py @CourTeous33
/api/repositories/sqlalchemy_workflow_trigger_log_repository.py @CourTeous33
/api/libs/schedule_utils.py @CourTeous33
/api/services/workflow/scheduler.py @CourTeous33
/api/schedule/trigger_provider_refresh_task.py @CourTeous33
/api/schedule/workflow_schedule_task.py @CourTeous33
/api/tasks/trigger_processing_tasks.py @CourTeous33
/api/tasks/trigger_subscription_refresh_tasks.py @CourTeous33
/api/tasks/workflow_schedule_tasks.py @CourTeous33
/api/tasks/workflow_cfs_scheduler/ @CourTeous33
/api/events/event_handlers/sync_plugin_trigger_when_app_created.py @CourTeous33
/api/events/event_handlers/update_app_triggers_when_app_published_workflow_updated.py @CourTeous33
/api/events/event_handlers/sync_workflow_schedule_when_app_published.py @CourTeous33
/api/events/event_handlers/sync_webhook_when_app_created.py @CourTeous33
# Backend - Async Workflow
/api/services/async_workflow_service.py @Mairuis @Yeuoly
/api/tasks/async_workflow_tasks.py @Mairuis @Yeuoly
/api/services/async_workflow_service.py @Mairuis
/api/tasks/async_workflow_tasks.py @Mairuis
# Backend - Billing
/api/services/billing_service.py @hj24 @zyssyz123

View File

@ -5,11 +5,11 @@ runs:
using: composite
steps:
- name: Setup pnpm
uses: pnpm/action-setup@8912a9102ac27614460f54aedde9e1e7f9aec20d # v6.0.5
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8
with:
run_install: false
- name: Setup Vite+
uses: voidzero-dev/setup-vp@4f5aa3e38c781f1b01e78fb9255527cee8a6efa6 # v1.8.0
uses: voidzero-dev/setup-vp@ca1c46663915d6c1042ae23bd39ab85718bfb0fa # v1.10.0
with:
node-version-file: .nvmrc
cache: true

View File

@ -48,10 +48,23 @@ jobs:
run: uv sync --project api --dev
- name: Run dify config tests
run: uv run --project api dev/pytest/pytest_config_tests.py
run: uv run --project api pytest api/tests/unit_tests/configs/test_env_consistency.py
- name: Run Unit Tests
run: uv run --project api bash dev/pytest/pytest_unit_tests.sh
run: |
uv run --project api pytest \
-p no:benchmark \
--timeout "${PYTEST_TIMEOUT:-20}" \
-n auto \
api/tests/unit_tests \
api/providers/vdb/*/tests/unit_tests \
api/providers/trace/*/tests/unit_tests \
--ignore=api/tests/unit_tests/controllers
# Controller tests register Flask routes at import time, so keep them out of xdist.
uv run --project api pytest \
--timeout "${PYTEST_TIMEOUT:-20}" \
--cov-append \
api/tests/unit_tests/controllers
- name: Upload unit coverage data
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
@ -96,32 +109,11 @@ jobs:
- name: Install dependencies
run: uv sync --project api --dev
- name: Set up dotenvs
run: |
cp docker/.env.example docker/.env
cp docker/envs/middleware.env.example docker/middleware.env
- name: Expose Service Ports
run: sh .github/workflows/expose_service_ports.sh
- name: Set up Sandbox
uses: hoverkraft-tech/compose-action@d2bee4f07e8ca410d6b196d00f90c12e7d48c33a # v2.6.0
with:
compose-file: |
docker/docker-compose.middleware.yaml
services: |
db_postgres
redis
sandbox
ssrf_proxy
- name: setup test config
run: |
cp api/tests/integration_tests/.env.example api/tests/integration_tests/.env
- name: Run Integration Tests
run: |
uv run --project api pytest \
-p no:benchmark \
--start-middleware \
-n auto \
--timeout "${PYTEST_TIMEOUT:-180}" \
api/tests/integration_tests/workflow \
@ -203,7 +195,7 @@ jobs:
- name: Report coverage
if: ${{ env.CODECOV_TOKEN != '' }}
uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1
with:
files: ./coverage.xml
disable_search: true

View File

@ -35,15 +35,15 @@ jobs:
- service_name: "build-api-amd64"
image_name_env: "DIFY_API_IMAGE_NAME"
artifact_context: "api"
build_context: "{{defaultContext}}:api"
file: "Dockerfile"
build_context: "{{defaultContext}}"
file: "api/Dockerfile"
platform: linux/amd64
runs_on: depot-ubuntu-24.04-4
- service_name: "build-api-arm64"
image_name_env: "DIFY_API_IMAGE_NAME"
artifact_context: "api"
build_context: "{{defaultContext}}:api"
file: "Dockerfile"
build_context: "{{defaultContext}}"
file: "api/Dockerfile"
platform: linux/arm64
runs_on: depot-ubuntu-24.04-4
- service_name: "build-web-amd64"
@ -117,8 +117,8 @@ jobs:
matrix:
include:
- service_name: "validate-api-amd64"
build_context: "{{defaultContext}}:api"
file: "Dockerfile"
build_context: "{{defaultContext}}"
file: "api/Dockerfile"
- service_name: "validate-web-amd64"
build_context: "{{defaultContext}}"
file: "web/Dockerfile"

View File

@ -6,6 +6,12 @@ on:
- "main"
paths:
- api/Dockerfile
- api/Dockerfile.dockerignore
- api/pyproject.toml
- api/uv.lock
- dify-agent/pyproject.toml
- dify-agent/README.md
- dify-agent/src/**
- web/Dockerfile
concurrency:
@ -25,13 +31,13 @@ jobs:
- service_name: "api-amd64"
platform: linux/amd64
runs_on: depot-ubuntu-24.04-4
context: "{{defaultContext}}:api"
file: "Dockerfile"
context: "{{defaultContext}}"
file: "api/Dockerfile"
- service_name: "api-arm64"
platform: linux/arm64
runs_on: depot-ubuntu-24.04-4
context: "{{defaultContext}}:api"
file: "Dockerfile"
context: "{{defaultContext}}"
file: "api/Dockerfile"
- service_name: "web-amd64"
platform: linux/amd64
runs_on: depot-ubuntu-24.04-4
@ -64,8 +70,8 @@ jobs:
matrix:
include:
- service_name: "api-amd64"
context: "{{defaultContext}}:api"
file: "Dockerfile"
context: "{{defaultContext}}"
file: "api/Dockerfile"
- service_name: "web-amd64"
context: "{{defaultContext}}"
file: "web/Dockerfile"

View File

@ -1,17 +0,0 @@
#!/bin/bash
yq eval '.services.weaviate.ports += ["8080:8080"]' -i docker/docker-compose.yaml
yq eval '.services.weaviate.ports += ["50051:50051"]' -i docker/docker-compose.yaml
yq eval '.services.qdrant.ports += ["6333:6333"]' -i docker/docker-compose.yaml
yq eval '.services.chroma.ports += ["8000:8000"]' -i docker/docker-compose.yaml
yq eval '.services["milvus-standalone"].ports += ["19530:19530"]' -i docker/docker-compose.yaml
yq eval '.services.pgvector.ports += ["5433:5432"]' -i docker/docker-compose.yaml
yq eval '.services["pgvecto-rs"].ports += ["5431:5432"]' -i docker/docker-compose.yaml
yq eval '.services["elasticsearch"].ports += ["9200:9200"]' -i docker/docker-compose.yaml
yq eval '.services.couchbase-server.ports += ["8091-8096:8091-8096"]' -i docker/docker-compose.yaml
yq eval '.services.couchbase-server.ports += ["11210:11210"]' -i docker/docker-compose.yaml
yq eval '.services.tidb.ports += ["4000:4000"]' -i docker/tidb/docker-compose.yaml
yq eval '.services.oceanbase.ports += ["2881:2881"]' -i docker/docker-compose.yaml
yq eval '.services.opengauss.ports += ["6600:6600"]' -i docker/docker-compose.yaml
echo "Ports exposed for sandbox, weaviate (HTTP 8080, gRPC 50051), tidb, qdrant, chroma, milvus, pgvector, pgvecto-rs, elasticsearch, couchbase, opengauss"

View File

@ -55,7 +55,6 @@ jobs:
api:
- 'api/**'
- '.github/workflows/api-tests.yml'
- '.github/workflows/expose_service_ports.sh'
- 'docker/.env.example'
- 'docker/envs/middleware.env.example'
- 'docker/docker-compose.middleware.yaml'
@ -90,11 +89,13 @@ jobs:
vdb:
- 'api/core/rag/datasource/**'
- 'api/tests/integration_tests/vdb/**'
- 'api/conftest.py'
- 'api/tests/pytest_dify.py'
- 'api/providers/vdb/*/tests/**'
- '.github/workflows/vdb-tests.yml'
- '.github/workflows/expose_service_ports.sh'
- 'docker/.env.example'
- 'docker/envs/middleware.env.example'
- 'docker/docker-compose.pytest.ports.yaml'
- 'docker/docker-compose.yaml'
- 'docker/docker-compose-template.yaml'
- 'docker/generate_docker_compose'
@ -114,7 +115,6 @@ jobs:
- 'api/migrations/**'
- 'api/.env.example'
- '.github/workflows/db-migration-test.yml'
- '.github/workflows/expose_service_ports.sh'
- 'docker/.env.example'
- 'docker/envs/middleware.env.example'
- 'docker/docker-compose.middleware.yaml'

View File

@ -158,7 +158,7 @@ jobs:
- name: Run Claude Code for Translation Sync
if: steps.context.outputs.CHANGED_FILES != ''
uses: anthropics/claude-code-action@476e359e6203e73dad705c8b322e333fabbd7416 # v1.0.119
uses: anthropics/claude-code-action@1dc994ee7a008f0ecc866d9ac23ef036b7229f84 # v1.0.127
with:
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
github_token: ${{ secrets.GITHUB_TOKEN }}

View File

@ -48,14 +48,6 @@ jobs:
- name: Install dependencies
run: uv sync --project api --dev
- name: Set up dotenvs
run: |
cp docker/.env.example docker/.env
cp docker/envs/middleware.env.example docker/middleware.env
- name: Expose Service Ports
run: sh .github/workflows/expose_service_ports.sh
# - name: Set up Vector Store (TiDB)
# uses: hoverkraft-tech/compose-action@v2.0.2
# with:
@ -64,32 +56,13 @@ jobs:
# tidb
# tiflash
- name: Set up Full Vector Store Matrix
uses: hoverkraft-tech/compose-action@d2bee4f07e8ca410d6b196d00f90c12e7d48c33a # v2.6.0
with:
compose-file: |
docker/docker-compose.yaml
services: |
weaviate
qdrant
couchbase-server
etcd
minio
milvus-standalone
pgvecto-rs
pgvector
chroma
elasticsearch
oceanbase
- name: setup test config
run: |
echo $(pwd)
ls -lah .
cp api/tests/integration_tests/.env.example api/tests/integration_tests/.env
# - name: Check VDB Ready (TiDB)
# run: uv run --project api python api/providers/vdb/tidb-vector/tests/integration_tests/check_tiflash_ready.py
- name: Test Vector Stores
run: uv run --project api bash dev/pytest/pytest_vdb.sh
run: |
uv run --project api pytest \
--start-vdb \
--vdb-services "weaviate,qdrant,couchbase-server,etcd,minio,milvus-standalone,pgvecto-rs,pgvector,chroma,elasticsearch,oceanbase" \
--timeout "${PYTEST_TIMEOUT:-180}" \
api/providers/vdb/*/tests/integration_tests

View File

@ -45,14 +45,6 @@ jobs:
- name: Install dependencies
run: uv sync --project api --dev
- name: Set up dotenvs
run: |
cp docker/.env.example docker/.env
cp docker/envs/middleware.env.example docker/middleware.env
- name: Expose Service Ports
run: sh .github/workflows/expose_service_ports.sh
# - name: Set up Vector Store (TiDB)
# uses: hoverkraft-tech/compose-action@v2.0.2
# with:
@ -61,31 +53,14 @@ jobs:
# tidb
# tiflash
- name: Set up Vector Stores for Smoke Coverage
uses: hoverkraft-tech/compose-action@d2bee4f07e8ca410d6b196d00f90c12e7d48c33a # v2.6.0
with:
compose-file: |
docker/docker-compose.yaml
services: |
db_postgres
redis
weaviate
qdrant
pgvector
chroma
- name: setup test config
run: |
echo $(pwd)
ls -lah .
cp api/tests/integration_tests/.env.example api/tests/integration_tests/.env
# - name: Check VDB Ready (TiDB)
# run: uv run --project api python api/providers/vdb/tidb-vector/tests/integration_tests/check_tiflash_ready.py
- name: Test Vector Stores
run: |
uv run --project api pytest --timeout "${PYTEST_TIMEOUT:-180}" \
uv run --project api pytest \
--start-vdb \
--timeout "${PYTEST_TIMEOUT:-180}" \
api/providers/vdb/vdb-chroma/tests/integration_tests \
api/providers/vdb/vdb-pgvector/tests/integration_tests \
api/providers/vdb/vdb-qdrant/tests/integration_tests \

View File

@ -39,7 +39,7 @@ jobs:
uses: ./.github/actions/setup-web
- name: Run tests
run: vp test run --reporter=blob --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} --coverage
run: vp test run --reporter=blob --reporter=minimal --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} --coverage
- name: Upload blob report
if: ${{ !cancelled() }}
@ -83,7 +83,7 @@ jobs:
- name: Report coverage
if: ${{ env.CODECOV_TOKEN != '' }}
uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1
with:
directory: web/coverage
flags: web
@ -117,7 +117,7 @@ jobs:
- name: Report coverage
if: ${{ env.CODECOV_TOKEN != '' }}
uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1
with:
directory: packages/dify-ui/coverage
flags: dify-ui

View File

@ -0,0 +1,4 @@
# Mocks to Remove Before Release
- `emptyAppList=true`: frontend URL preview flag for forcing the `/apps` page into the first-empty state. Remove the parser and rendering override before release.
- `emptyDataList=true`: frontend URL preview flag for forcing the `/datasets` page into the first-empty state. Remove the parser and rendering override before release.

View File

@ -85,13 +85,13 @@ lint:
type-check:
@echo "📝 Running type checks (pyrefly + mypy)..."
@./dev/pyrefly-check-local $(PATH_TO_CHECK)
@uv --directory api run mypy --exclude-gitignore --exclude 'tests/' --exclude 'migrations/' --exclude 'dev/generate_swagger_specs.py' --exclude 'dev/generate_fastopenapi_specs.py' --check-untyped-defs --disable-error-code=import-untyped .
@uv --directory api run mypy --exclude-gitignore --exclude '(^|/)conftest\.py$$' --exclude 'tests/' --exclude 'migrations/' --exclude 'dev/generate_swagger_specs.py' --exclude 'dev/generate_fastopenapi_specs.py' --check-untyped-defs --disable-error-code=import-untyped .
@echo "✅ Type checks complete"
type-check-core:
@echo "📝 Running core type checks (pyrefly + mypy)..."
@./dev/pyrefly-check-local $(PATH_TO_CHECK)
@uv --directory api run mypy --exclude-gitignore --exclude 'tests/' --exclude 'migrations/' --exclude 'dev/generate_swagger_specs.py' --exclude 'dev/generate_fastopenapi_specs.py' --check-untyped-defs --disable-error-code=import-untyped .
@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:
@ -100,7 +100,46 @@ test:
echo "Target: $(TARGET_TESTS)"; \
uv run --project api --dev pytest $(TARGET_TESTS); \
else \
PYTEST_XDIST_ARGS="-n auto" uv run --project api --dev dev/pytest/pytest_unit_tests.sh; \
echo "Running backend unit tests"; \
uv run --project api --dev pytest -p no:benchmark --timeout "$${PYTEST_TIMEOUT:-20}" -n auto \
api/tests/unit_tests \
api/providers/vdb/*/tests/unit_tests \
api/providers/trace/*/tests/unit_tests \
--ignore=api/tests/unit_tests/controllers; \
uv run --project api --dev pytest --timeout "$${PYTEST_TIMEOUT:-20}" --cov-append \
api/tests/unit_tests/controllers; \
fi
@echo "✅ Unit tests complete"
test-all:
@echo "🧪 Running full backend test suite..."
@if [ -n "$(TARGET_TESTS)" ]; then \
echo "Target: $(TARGET_TESTS)"; \
uv run --project api --dev pytest $(TARGET_TESTS); \
else \
echo "Running backend unit tests"; \
uv run --project api --dev pytest -p no:benchmark --timeout "$${PYTEST_TIMEOUT:-20}" -n auto \
api/tests/unit_tests \
api/providers/vdb/*/tests/unit_tests \
api/providers/trace/*/tests/unit_tests \
--ignore=api/tests/unit_tests/controllers; \
uv run --project api --dev pytest --timeout "$${PYTEST_TIMEOUT:-20}" --cov-append \
api/tests/unit_tests/controllers; \
echo "Running backend integration tests"; \
uv run --project api --dev pytest -p no:benchmark --start-middleware -n auto \
--timeout "$${PYTEST_TIMEOUT:-180}" \
--cov-append \
api/tests/integration_tests/workflow \
api/tests/integration_tests/tools \
api/tests/test_containers_integration_tests; \
echo "Running VDB smoke tests"; \
uv run --project api --dev pytest --start-vdb \
--timeout "$${PYTEST_TIMEOUT:-180}" \
--cov-append \
api/providers/vdb/vdb-chroma/tests/integration_tests \
api/providers/vdb/vdb-pgvector/tests/integration_tests \
api/providers/vdb/vdb-qdrant/tests/integration_tests \
api/providers/vdb/vdb-weaviate/tests/integration_tests; \
fi
@echo "✅ Tests complete"
@ -155,6 +194,7 @@ help:
@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 ""
@echo "Docker Build Targets:"
@echo " make build-web - Build web Docker image"
@ -164,4 +204,4 @@ help:
@echo " make build-push-all - Build and push all Docker images"
# Phony targets
.PHONY: build-web build-api push-web push-api build-all push-all build-push-all dev-setup prepare-docker prepare-web prepare-api dev-clean help format check lint type-check test
.PHONY: build-web build-api push-web push-api build-all push-all build-push-all dev-setup prepare-docker prepare-web prepare-api dev-clean help format check lint type-check test test-all

View File

@ -180,6 +180,8 @@ Quick checks while iterating:
- Format: `make format`
- Lint (includes auto-fix): `make lint`
- Type check: `make type-check`
- Unit tests: `make test`
- Full backend tests, including Docker-backed suites: `make test-all`
- Targeted tests: `make test TARGET_TESTS=./api/tests/<target_tests>`
Before opening a PR / submitting:

View File

@ -22,9 +22,11 @@ RUN apt-get update \
libmpfr-dev libmpc-dev
# Install Python dependencies (workspace members under providers/vdb/)
COPY pyproject.toml uv.lock ./
COPY providers ./providers
# Trust the checked-in lock during image builds; dev-only path sources live outside the api/ context.
COPY api/pyproject.toml api/uv.lock ./
COPY api/providers ./providers
COPY dify-agent/pyproject.toml dify-agent/README.md /app/dify-agent/
COPY dify-agent/src /app/dify-agent/src
# Trust the checked-in lock during image builds; local path sources are copied from the repository context.
RUN uv sync --frozen --no-dev
# production stage
@ -108,10 +110,10 @@ RUN python -c "import tiktoken; tiktoken.encoding_for_model('gpt2')" \
&& chown -R dify:dify ${TIKTOKEN_CACHE_DIR}
# Copy source code
COPY --chown=dify:dify . /app/api/
COPY --chown=dify:dify api /app/api/
# Prepare entrypoint script
COPY --chown=dify:dify --chmod=755 docker/entrypoint.sh /entrypoint.sh
COPY --chown=dify:dify --chmod=755 api/docker/entrypoint.sh /entrypoint.sh
ARG COMMIT_SHA

View File

@ -0,0 +1,25 @@
*
!api/
!api/**
!dify-agent/
!dify-agent/pyproject.toml
!dify-agent/README.md
!dify-agent/src/
!dify-agent/src/**
api/.venv
api/.venv/**
api/.env
api/*.env.*
api/.idea
api/.mypy_cache
api/.ruff_cache
api/storage/generate_files/*
api/storage/privkeys/*
api/storage/tools/*
api/storage/upload_files/*
api/logs
api/*.log*
**/__pycache__
**/*.pyc

1
api/clients/__init__.py Normal file
View File

@ -0,0 +1 @@
"""External service client packages."""

View File

@ -0,0 +1,74 @@
"""API-side integration boundary for the Dify Agent backend.
Public wire DTOs come from ``dify_agent.protocol``. This package only contains
API adapters: request building from Dify product concepts, a thin client wrapper,
event adaptation for future workflow integration, and deterministic fakes.
"""
from clients.agent_backend.client import AgentBackendRunClient, DifyAgentBackendRunClient
from clients.agent_backend.errors import (
AgentBackendError,
AgentBackendHTTPError,
AgentBackendRequestBuildError,
AgentBackendRunFailedError,
AgentBackendStreamError,
AgentBackendTransportError,
AgentBackendValidationError,
)
from clients.agent_backend.event_adapter import (
AgentBackendInternalEvent,
AgentBackendInternalEventType,
AgentBackendRunCancelledInternalEvent,
AgentBackendRunEventAdapter,
AgentBackendRunFailedInternalEvent,
AgentBackendRunPausedInternalEvent,
AgentBackendRunStartedInternalEvent,
AgentBackendRunSucceededInternalEvent,
AgentBackendStreamInternalEvent,
)
from clients.agent_backend.factory import create_agent_backend_run_client
from clients.agent_backend.fake_client import FakeAgentBackendRunClient, FakeAgentBackendScenario
from clients.agent_backend.request_builder import (
AGENT_SOUL_PROMPT_LAYER_ID,
DIFY_PLUGIN_CONTEXT_LAYER_ID,
WORKFLOW_NODE_JOB_PROMPT_LAYER_ID,
WORKFLOW_USER_PROMPT_LAYER_ID,
AgentBackendModelConfig,
AgentBackendOutputConfig,
AgentBackendRunRequestBuilder,
AgentBackendWorkflowNodeRunInput,
redact_for_agent_backend_log,
)
__all__ = [
"AGENT_SOUL_PROMPT_LAYER_ID",
"DIFY_PLUGIN_CONTEXT_LAYER_ID",
"WORKFLOW_NODE_JOB_PROMPT_LAYER_ID",
"WORKFLOW_USER_PROMPT_LAYER_ID",
"AgentBackendError",
"AgentBackendHTTPError",
"AgentBackendInternalEvent",
"AgentBackendInternalEventType",
"AgentBackendModelConfig",
"AgentBackendOutputConfig",
"AgentBackendRequestBuildError",
"AgentBackendRunCancelledInternalEvent",
"AgentBackendRunClient",
"AgentBackendRunEventAdapter",
"AgentBackendRunFailedError",
"AgentBackendRunFailedInternalEvent",
"AgentBackendRunPausedInternalEvent",
"AgentBackendRunRequestBuilder",
"AgentBackendRunStartedInternalEvent",
"AgentBackendRunSucceededInternalEvent",
"AgentBackendStreamError",
"AgentBackendStreamInternalEvent",
"AgentBackendTransportError",
"AgentBackendValidationError",
"AgentBackendWorkflowNodeRunInput",
"DifyAgentBackendRunClient",
"FakeAgentBackendRunClient",
"FakeAgentBackendScenario",
"create_agent_backend_run_client",
"redact_for_agent_backend_log",
]

View File

@ -0,0 +1,130 @@
"""Synchronous API-side wrapper around the public ``dify-agent`` client.
``dify-agent`` owns the cross-service DTOs and HTTP/SSE implementation. The API
backend keeps this thin wrapper so workflow code depends on a local protocol,
gets API-native errors, and can use a deterministic fake in tests without
creating another wire contract.
"""
from __future__ import annotations
from collections.abc import Iterator
from typing import Protocol
from dify_agent.client import (
DifyAgentClientError,
DifyAgentHTTPError,
DifyAgentStreamError,
DifyAgentTimeoutError,
DifyAgentValidationError,
)
from dify_agent.protocol import (
CancelRunRequest,
CancelRunResponse,
CreateRunRequest,
CreateRunResponse,
RunEvent,
RunStatusResponse,
)
from clients.agent_backend.errors import (
AgentBackendError,
AgentBackendHTTPError,
AgentBackendStreamError,
AgentBackendTransportError,
AgentBackendValidationError,
)
class AgentBackendRunClient(Protocol):
"""Local boundary used by API workflow integrations to run Agent backend jobs."""
def create_run(self, request: CreateRunRequest) -> CreateRunResponse:
"""Create one Agent backend run and return its accepted status."""
def cancel_run(self, run_id: str, request: CancelRunRequest | None = None) -> CancelRunResponse:
"""Request explicit cancellation for one Agent backend run."""
def stream_events(self, run_id: str, *, after: str | None = None) -> Iterator[RunEvent]:
"""Yield public ``dify-agent`` run events in stream order."""
def wait_run(self, run_id: str, *, timeout_seconds: float | None = None) -> RunStatusResponse:
"""Wait for a run to reach a terminal status and return that status."""
class _DifyAgentSyncClient(Protocol):
"""Subset of ``dify_agent.client.Client`` used by the API wrapper."""
def create_run_sync(self, request: CreateRunRequest) -> CreateRunResponse:
"""Create one run synchronously."""
def cancel_run_sync(self, run_id: str, request: CancelRunRequest | None = None) -> CancelRunResponse:
"""Cancel one run synchronously."""
def stream_events_sync(self, run_id: str, *, after: str | None = None) -> Iterator[RunEvent]:
"""Stream run events synchronously."""
def wait_run_sync(self, run_id: str, *, timeout_seconds: float | None = None) -> RunStatusResponse:
"""Wait for terminal run status synchronously."""
class DifyAgentBackendRunClient:
"""Adapter from API sync call sites to ``dify_agent.client.Client`` sync methods."""
client: _DifyAgentSyncClient
def __init__(self, client: _DifyAgentSyncClient) -> None:
self.client = client
def create_run(self, request: CreateRunRequest) -> CreateRunResponse:
"""Create one run through ``POST /runs`` and normalize client exceptions."""
try:
return self.client.create_run_sync(request)
except Exception as exc:
raise _normalize_dify_agent_error(exc) from exc
def cancel_run(self, run_id: str, request: CancelRunRequest | None = None) -> CancelRunResponse:
"""Cancel one run through ``POST /runs/{run_id}/cancel`` and normalize exceptions."""
try:
return self.client.cancel_run_sync(run_id, request=request)
except Exception as exc:
raise _normalize_dify_agent_error(exc) from exc
def stream_events(self, run_id: str, *, after: str | None = None) -> Iterator[RunEvent]:
"""Stream run events from ``/events/sse`` with the wrapped client's reconnect policy."""
try:
yield from self.client.stream_events_sync(run_id, after=after)
except Exception as exc:
raise _normalize_dify_agent_error(exc) from exc
def wait_run(self, run_id: str, *, timeout_seconds: float | None = None) -> RunStatusResponse:
"""Poll run status until terminal state and normalize client exceptions."""
try:
return self.client.wait_run_sync(run_id, timeout_seconds=timeout_seconds)
except Exception as exc:
raise _normalize_dify_agent_error(exc) from exc
def _normalize_dify_agent_error(exc: Exception) -> AgentBackendError:
"""Map public ``dify-agent`` client errors to API-side integration errors."""
match exc:
case DifyAgentValidationError() as error:
return AgentBackendValidationError(
"Agent backend request or response validation failed", detail=error.detail
)
case DifyAgentHTTPError() as error:
return AgentBackendHTTPError(
f"Agent backend HTTP {error.status_code}",
status_code=error.status_code,
detail=error.detail,
)
case DifyAgentTimeoutError() as error:
return AgentBackendTransportError(str(error))
case DifyAgentStreamError() as error:
return AgentBackendStreamError(str(error))
case DifyAgentClientError() as error:
return AgentBackendTransportError(str(error))
case AgentBackendError() as error:
return error
case _:
return AgentBackendTransportError(str(exc) or type(exc).__name__)

View File

@ -0,0 +1,61 @@
"""API-side errors for the Dify Agent backend integration.
The wire protocol and low-level HTTP behaviour are owned by ``dify-agent``.
This module only normalizes those client errors into the API backend's boundary
so workflow/node code does not depend directly on transport-specific exception
classes.
"""
from __future__ import annotations
from typing import Any
class AgentBackendError(Exception):
"""Base error for API-side Agent backend integration failures."""
class AgentBackendRequestBuildError(AgentBackendError):
"""Raised when Dify product/workflow state cannot be mapped to a run request."""
class AgentBackendTransportError(AgentBackendError):
"""Raised for timeout or request-level failures talking to Agent backend."""
class AgentBackendHTTPError(AgentBackendTransportError):
"""Raised for Agent backend HTTP errors after status/detail normalization."""
status_code: int
detail: object
def __init__(self, message: str, *, status_code: int, detail: object) -> None:
self.status_code = status_code
self.detail = detail
super().__init__(message)
class AgentBackendValidationError(AgentBackendError):
"""Raised for local request validation or Agent backend 422 responses."""
detail: object
def __init__(self, message: str, *, detail: object) -> None:
self.detail = detail
super().__init__(message)
class AgentBackendStreamError(AgentBackendError):
"""Raised when an Agent backend event stream is malformed or exhausted."""
class AgentBackendRunFailedError(AgentBackendError):
"""Raised by callers that choose to translate a terminal failed run into an exception."""
run_id: str
detail: Any
def __init__(self, run_id: str, detail: Any) -> None:
self.run_id = run_id
self.detail = detail
super().__init__(f"Agent backend run failed: {run_id}")

View File

@ -0,0 +1,167 @@
"""Adapt public ``dify-agent`` run events into API-internal event semantics.
The adapter does not define a new cross-service event contract. It consumes
``dify_agent.protocol.RunEvent`` and produces small API-internal models that the
future workflow Agent Node can map to Graphon/AppQueue events in phase 3.
"""
from __future__ import annotations
from enum import StrEnum
from typing import Annotated, Literal, cast
from agenton.compositor import CompositorSessionSnapshot
from dify_agent.protocol import (
PydanticAIStreamRunEvent,
RunCancelledEvent,
RunEvent,
RunFailedEvent,
RunPausedEvent,
RunStartedEvent,
RunSucceededEvent,
)
from pydantic import BaseModel, ConfigDict, Field, JsonValue, TypeAdapter
_EVENT_DATA_ADAPTER = TypeAdapter(object)
class AgentBackendInternalEventType(StrEnum):
"""API-only event labels used before Graphon/AppQueue integration."""
RUN_STARTED = "run_started"
STREAM_EVENT = "stream_event"
RUN_PAUSED = "run_paused"
RUN_SUCCEEDED = "run_succeeded"
RUN_FAILED = "run_failed"
RUN_CANCELLED = "run_cancelled"
class AgentBackendInternalEventBase(BaseModel):
"""Common fields preserved from public Dify Agent run events."""
run_id: str
source_event_id: str | None = None
model_config = ConfigDict(extra="forbid", arbitrary_types_allowed=True)
class AgentBackendRunStartedInternalEvent(AgentBackendInternalEventBase):
"""API-internal marker for a started Agent backend run."""
type: Literal[AgentBackendInternalEventType.RUN_STARTED] = AgentBackendInternalEventType.RUN_STARTED
class AgentBackendStreamInternalEvent(AgentBackendInternalEventBase):
"""API-internal wrapper for one pydantic-ai stream event payload."""
type: Literal[AgentBackendInternalEventType.STREAM_EVENT] = AgentBackendInternalEventType.STREAM_EVENT
event_kind: str | None = None
data: JsonValue
class AgentBackendRunSucceededInternalEvent(AgentBackendInternalEventBase):
"""API-internal terminal success event carrying final output and session state."""
type: Literal[AgentBackendInternalEventType.RUN_SUCCEEDED] = AgentBackendInternalEventType.RUN_SUCCEEDED
output: JsonValue
session_snapshot: CompositorSessionSnapshot
class AgentBackendRunPausedInternalEvent(AgentBackendInternalEventBase):
"""API-internal resumable pause event for human handoff and Babysit flows."""
type: Literal[AgentBackendInternalEventType.RUN_PAUSED] = AgentBackendInternalEventType.RUN_PAUSED
reason: str
message: str | None = None
session_snapshot: CompositorSessionSnapshot | None = None
class AgentBackendRunFailedInternalEvent(AgentBackendInternalEventBase):
"""API-internal terminal failure event carrying the backend-safe error text."""
type: Literal[AgentBackendInternalEventType.RUN_FAILED] = AgentBackendInternalEventType.RUN_FAILED
error: str
reason: str | None = None
class AgentBackendRunCancelledInternalEvent(AgentBackendInternalEventBase):
"""API-internal terminal cancellation event."""
type: Literal[AgentBackendInternalEventType.RUN_CANCELLED] = AgentBackendInternalEventType.RUN_CANCELLED
reason: str | None = None
message: str | None = None
type AgentBackendInternalEvent = Annotated[
AgentBackendRunStartedInternalEvent
| AgentBackendStreamInternalEvent
| AgentBackendRunPausedInternalEvent
| AgentBackendRunSucceededInternalEvent
| AgentBackendRunFailedInternalEvent
| AgentBackendRunCancelledInternalEvent,
Field(discriminator="type"),
]
class AgentBackendRunEventAdapter:
"""Maps public ``dify-agent`` event variants to API-internal event variants."""
def adapt(self, event: RunEvent) -> list[AgentBackendInternalEvent]:
"""Return zero or more API-internal events derived from one public run event."""
match event:
case RunStartedEvent():
return [
AgentBackendRunStartedInternalEvent(
run_id=event.run_id,
source_event_id=event.id,
)
]
case PydanticAIStreamRunEvent():
data = cast(JsonValue, _EVENT_DATA_ADAPTER.dump_python(event.data, mode="json"))
event_kind = data.get("event_kind") if isinstance(data, dict) else None
return [
AgentBackendStreamInternalEvent(
run_id=event.run_id,
source_event_id=event.id,
event_kind=event_kind if isinstance(event_kind, str) else None,
data=data,
)
]
case RunSucceededEvent():
return [
AgentBackendRunSucceededInternalEvent(
run_id=event.run_id,
source_event_id=event.id,
output=event.data.output,
session_snapshot=event.data.session_snapshot,
)
]
case RunPausedEvent():
return [
AgentBackendRunPausedInternalEvent(
run_id=event.run_id,
source_event_id=event.id,
reason=event.data.reason,
message=event.data.message,
session_snapshot=event.data.session_snapshot,
)
]
case RunFailedEvent():
return [
AgentBackendRunFailedInternalEvent(
run_id=event.run_id,
source_event_id=event.id,
error=event.data.error,
reason=event.data.reason,
)
]
case RunCancelledEvent():
return [
AgentBackendRunCancelledInternalEvent(
run_id=event.run_id,
source_event_id=event.id,
reason=event.data.reason,
message=event.data.message,
)
]
raise TypeError(f"unsupported agent backend run event: {type(event).__name__}")

View File

@ -0,0 +1,22 @@
"""Factories for API-side Agent backend clients."""
from __future__ import annotations
from dify_agent.client import Client
from clients.agent_backend.client import AgentBackendRunClient, DifyAgentBackendRunClient
from clients.agent_backend.fake_client import FakeAgentBackendRunClient, FakeAgentBackendScenario
def create_agent_backend_run_client(
*,
base_url: str | None = None,
use_fake: bool = False,
fake_scenario: str | FakeAgentBackendScenario = FakeAgentBackendScenario.SUCCESS,
) -> AgentBackendRunClient:
"""Create the API-side run client without hiding the ``dify-agent`` protocol."""
if use_fake:
return FakeAgentBackendRunClient(scenario=FakeAgentBackendScenario(fake_scenario))
if base_url is None:
raise ValueError("base_url is required when creating a real Agent backend client")
return DifyAgentBackendRunClient(Client(base_url=base_url))

View File

@ -0,0 +1,117 @@
"""Deterministic fake Agent backend client using public ``dify-agent`` events.
Tests should exercise the same ``RunEvent`` DTOs as the real HTTP client. This
fake therefore replaces the previous custom mock protocol instead of emulating a
separate ``agent-backend.v1`` event stream.
"""
from __future__ import annotations
from collections.abc import Iterator
from datetime import UTC, datetime
from enum import StrEnum
from agenton.compositor import CompositorSessionSnapshot
from dify_agent.protocol import (
CancelRunRequest,
CancelRunResponse,
CreateRunRequest,
CreateRunResponse,
RunEvent,
RunFailedEvent,
RunFailedEventData,
RunStartedEvent,
RunStatusResponse,
RunSucceededEvent,
RunSucceededEventData,
)
_FIXED_TIME = datetime(2026, 1, 1, tzinfo=UTC)
class FakeAgentBackendScenario(StrEnum):
"""Deterministic fake scenarios for API-side integration tests."""
SUCCESS = "success"
FAILED = "failed"
class FakeAgentBackendRunClient:
"""In-memory implementation of ``AgentBackendRunClient`` for unit tests."""
scenario: FakeAgentBackendScenario
run_id: str
request: CreateRunRequest | None
def __init__(
self,
*,
scenario: FakeAgentBackendScenario = FakeAgentBackendScenario.SUCCESS,
run_id: str = "fake-run-1",
) -> None:
self.scenario = scenario
self.run_id = run_id
self.request = None
def create_run(self, request: CreateRunRequest) -> CreateRunResponse:
"""Record the request and return a deterministic accepted response."""
self.request = request
return CreateRunResponse(run_id=self.run_id, status="running")
def cancel_run(self, run_id: str, request: CancelRunRequest | None = None) -> CancelRunResponse:
"""Return a deterministic cancellation response."""
del request
return CancelRunResponse(run_id=run_id, status="cancelled")
def stream_events(self, run_id: str, *, after: str | None = None) -> Iterator[RunEvent]:
"""Yield the deterministic public ``RunEvent`` sequence for ``run_id``."""
for event in self._events(run_id):
if after is not None and event.id is not None and event.id <= after:
continue
yield event
def wait_run(self, run_id: str, *, timeout_seconds: float | None = None) -> RunStatusResponse:
"""Return a deterministic terminal status; timeout is accepted for protocol parity."""
del timeout_seconds
match self.scenario:
case FakeAgentBackendScenario.SUCCESS:
return RunStatusResponse(
run_id=run_id,
status="succeeded",
created_at=_FIXED_TIME,
updated_at=_FIXED_TIME,
)
case FakeAgentBackendScenario.FAILED:
return RunStatusResponse(
run_id=run_id,
status="failed",
created_at=_FIXED_TIME,
updated_at=_FIXED_TIME,
error="fake failure",
)
def _events(self, run_id: str) -> tuple[RunEvent, ...]:
match self.scenario:
case FakeAgentBackendScenario.SUCCESS:
return (
RunStartedEvent(id="1-0", run_id=run_id, created_at=_FIXED_TIME),
RunSucceededEvent(
id="2-0",
run_id=run_id,
created_at=_FIXED_TIME,
data=RunSucceededEventData(
output={"text": "hello agent"},
session_snapshot=CompositorSessionSnapshot(layers=[]),
),
),
)
case FakeAgentBackendScenario.FAILED:
return (
RunStartedEvent(id="1-0", run_id=run_id, created_at=_FIXED_TIME),
RunFailedEvent(
id="2-0",
run_id=run_id,
created_at=_FIXED_TIME,
data=RunFailedEventData(error="fake failure", reason="unit_test"),
),
)

View File

@ -0,0 +1,192 @@
"""Build ``dify-agent`` run requests from API-side product concepts.
This module is intentionally an adapter, not a wire DTO package. The emitted
object is always ``dify_agent.protocol.CreateRunRequest`` so the Agent backend
protocol has a single owner. API-only context such as Agent Soul vs workflow job
prompt is preserved in layer names and metadata until the dedicated product
schemas land in later phases.
"""
from __future__ import annotations
from typing import ClassVar
from agenton.compositor import CompositorSessionSnapshot
from agenton.layers import ExitIntent
from agenton_collections.layers.plain import PLAIN_PROMPT_LAYER_TYPE_ID, PromptLayerConfig
from dify_agent.layers.dify_plugin import (
DIFY_PLUGIN_LAYER_TYPE_ID,
DIFY_PLUGIN_LLM_LAYER_TYPE_ID,
DifyPluginCredentialValue,
DifyPluginLayerConfig,
DifyPluginLLMLayerConfig,
)
from dify_agent.layers.output import DIFY_OUTPUT_LAYER_TYPE_ID, DifyOutputLayerConfig
from dify_agent.protocol import (
DIFY_AGENT_MODEL_LAYER_ID,
DIFY_AGENT_OUTPUT_LAYER_ID,
CreateRunRequest,
ExecutionContext,
LayerExitSignals,
RunComposition,
RunLayerSpec,
RunPurpose,
)
from pydantic import BaseModel, ConfigDict, Field, JsonValue, field_validator
AGENT_SOUL_PROMPT_LAYER_ID = "agent_soul_prompt"
WORKFLOW_NODE_JOB_PROMPT_LAYER_ID = "workflow_node_job_prompt"
WORKFLOW_USER_PROMPT_LAYER_ID = "workflow_user_prompt"
DIFY_PLUGIN_CONTEXT_LAYER_ID = "plugin"
class AgentBackendModelConfig(BaseModel):
"""API-side model/plugin selection before it is converted to Dify Agent layers."""
tenant_id: str
plugin_id: str
model_provider: str
model: str
user_id: str | None = None
credentials: dict[str, DifyPluginCredentialValue] = Field(default_factory=dict)
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
class AgentBackendOutputConfig(BaseModel):
"""API-side structured output declaration for the conventional output layer."""
json_schema: dict[str, JsonValue]
name: str = "final_result"
description: str | None = None
strict: bool | None = None
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
class AgentBackendWorkflowNodeRunInput(BaseModel):
"""Inputs needed to build the first workflow-node-oriented Agent backend run request."""
model: AgentBackendModelConfig
execution_context: ExecutionContext
workflow_node_job_prompt: str
user_prompt: str
agent_soul_prompt: str | None = None
purpose: RunPurpose = "workflow_node"
idempotency_key: str | None = None
output: AgentBackendOutputConfig | None = None
session_snapshot: CompositorSessionSnapshot | None = None
suspend_on_exit: bool = False
metadata: dict[str, JsonValue] = Field(default_factory=dict)
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid", arbitrary_types_allowed=True)
@field_validator("workflow_node_job_prompt", "user_prompt")
@classmethod
def _reject_blank_prompt(cls, value: str) -> str:
if not value.strip():
raise ValueError("prompt must not be blank")
return value
class AgentBackendRunRequestBuilder:
"""Converts API product state into the public ``dify-agent`` run protocol."""
def build_for_workflow_node(self, run_input: AgentBackendWorkflowNodeRunInput) -> CreateRunRequest:
"""Build a workflow Agent Node run request without defining another wire schema."""
layers: list[RunLayerSpec] = []
if run_input.agent_soul_prompt:
layers.append(
RunLayerSpec(
name=AGENT_SOUL_PROMPT_LAYER_ID,
type=PLAIN_PROMPT_LAYER_TYPE_ID,
metadata={**run_input.metadata, "origin": "agent_soul"},
config=PromptLayerConfig(prefix=run_input.agent_soul_prompt),
)
)
layers.extend(
[
RunLayerSpec(
name=WORKFLOW_NODE_JOB_PROMPT_LAYER_ID,
type=PLAIN_PROMPT_LAYER_TYPE_ID,
metadata={**run_input.metadata, "origin": "workflow_node_job"},
config=PromptLayerConfig(prefix=run_input.workflow_node_job_prompt),
),
RunLayerSpec(
name=WORKFLOW_USER_PROMPT_LAYER_ID,
type=PLAIN_PROMPT_LAYER_TYPE_ID,
metadata={**run_input.metadata, "origin": "workflow_user_prompt"},
config=PromptLayerConfig(user=run_input.user_prompt),
),
RunLayerSpec(
name=DIFY_PLUGIN_CONTEXT_LAYER_ID,
type=DIFY_PLUGIN_LAYER_TYPE_ID,
metadata=run_input.metadata,
config=DifyPluginLayerConfig(
tenant_id=run_input.model.tenant_id,
plugin_id=run_input.model.plugin_id,
user_id=run_input.model.user_id,
),
),
RunLayerSpec(
name=DIFY_AGENT_MODEL_LAYER_ID,
type=DIFY_PLUGIN_LLM_LAYER_TYPE_ID,
deps={"plugin": DIFY_PLUGIN_CONTEXT_LAYER_ID},
metadata=run_input.metadata,
config=DifyPluginLLMLayerConfig(
model_provider=run_input.model.model_provider,
model=run_input.model.model,
credentials=run_input.model.credentials,
),
),
]
)
if run_input.output is not None:
layers.append(
RunLayerSpec(
name=DIFY_AGENT_OUTPUT_LAYER_ID,
type=DIFY_OUTPUT_LAYER_TYPE_ID,
metadata=run_input.metadata,
config=DifyOutputLayerConfig(
json_schema=run_input.output.json_schema,
name=run_input.output.name,
description=run_input.output.description,
strict=run_input.output.strict,
),
)
)
return CreateRunRequest(
composition=RunComposition(layers=layers),
execution_context=run_input.execution_context,
purpose=run_input.purpose,
idempotency_key=run_input.idempotency_key,
metadata=run_input.metadata,
session_snapshot=run_input.session_snapshot,
on_exit=LayerExitSignals(
default=ExitIntent.SUSPEND if run_input.suspend_on_exit else ExitIntent.DELETE,
),
)
_SENSITIVE_KEY_PARTS = ("secret", "credential", "token", "password", "api_key")
def redact_for_agent_backend_log(value: object) -> object:
"""Return a JSON-like copy with credential-bearing keys redacted for logs/tests."""
if isinstance(value, BaseModel):
return redact_for_agent_backend_log(value.model_dump(mode="json", warnings=False))
if isinstance(value, dict):
redacted: dict[object, object] = {}
for key, item in value.items():
key_text = str(key).lower()
if any(part in key_text for part in _SENSITIVE_KEY_PARTS):
redacted[key] = "[REDACTED]"
else:
redacted[key] = redact_for_agent_backend_log(item)
return redacted
if isinstance(value, list):
return [redact_for_agent_backend_log(item) for item in value]
return value

View File

@ -1,6 +1,6 @@
import logging
from pathlib import Path
from typing import Any
from typing import Any, override
from pydantic.fields import FieldInfo
from pydantic_settings import BaseSettings, PydanticBaseSettingsSource, SettingsConfigDict, TomlConfigSettingsSource
@ -25,6 +25,7 @@ class RemoteSettingsSourceFactory(PydanticBaseSettingsSource):
def __init__(self, settings_cls: type[BaseSettings]):
super().__init__(settings_cls)
@override
def get_field_value(self, field: FieldInfo, field_name: str) -> tuple[Any, str, bool]:
raise NotImplementedError
@ -90,6 +91,7 @@ class DifyConfig(
# Thanks for your concentration and consideration.
@classmethod
@override
def settings_customise_sources(
cls,
settings_cls: type[BaseSettings],

91
api/conftest.py Normal file
View File

@ -0,0 +1,91 @@
"""Global pytest hooks for Dify backend tests.
This root conftest is loaded before package-specific conftests, which lets tests opt
into Docker-backed middleware before application modules read environment config.
It intentionally lives at the API root because pytest applies conftest.py files to
tests below their directory, and this setup is shared by api/tests and api/providers.
"""
from __future__ import annotations
from pathlib import Path
import pytest
from tests.pytest_dify import (
DEFAULT_MIDDLEWARE_SERVICES,
DEFAULT_VDB_SERVICES,
DockerComposeStack,
build_middleware_stack,
build_vdb_stack,
ensure_backend_test_environment,
ensure_compose_env_files,
parse_services,
)
_REPO_ROOT = Path(__file__).resolve().parent.parent
_DIFY_COMPOSE_STACKS_KEY = pytest.StashKey[list[DockerComposeStack]]()
# This must run at import time because package-specific conftests can import the
# Flask app before pytest_configure hooks from this file are called.
ensure_backend_test_environment(_REPO_ROOT)
def pytest_addoption(parser: pytest.Parser) -> None:
group = parser.getgroup("dify")
group.addoption(
"--start-middleware",
action="store_true",
default=False,
help="Start the Docker middleware services needed by API integration tests.",
)
group.addoption(
"--middleware-services",
default=",".join(DEFAULT_MIDDLEWARE_SERVICES),
help="Comma-separated services from docker/docker-compose.middleware.yaml to start.",
)
group.addoption(
"--start-vdb",
action="store_true",
default=False,
help="Start vector-store Docker services for VDB integration tests.",
)
group.addoption(
"--vdb-services",
default=",".join(DEFAULT_VDB_SERVICES),
help="Comma-separated services from docker/docker-compose.yaml to start for VDB tests.",
)
def pytest_configure(config: pytest.Config) -> None:
config.stash[_DIFY_COMPOSE_STACKS_KEY] = []
def pytest_sessionstart(session: pytest.Session) -> None:
config = session.config
if hasattr(config, "workerinput"):
return
stacks: list[DockerComposeStack] = []
if config.getoption("start_middleware"):
ensure_compose_env_files(_REPO_ROOT)
stack = build_middleware_stack(_REPO_ROOT, parse_services(config.getoption("middleware_services")))
stack.up()
stacks.append(stack)
if config.getoption("start_vdb"):
ensure_compose_env_files(_REPO_ROOT)
stack = build_vdb_stack(_REPO_ROOT, parse_services(config.getoption("vdb_services")))
stack.up()
stacks.append(stack)
config.stash[_DIFY_COMPOSE_STACKS_KEY] = stacks
def pytest_unconfigure(config: pytest.Config) -> None:
if hasattr(config, "workerinput"):
return
stacks = config.stash.get(_DIFY_COMPOSE_STACKS_KEY, [])
for stack in reversed(stacks):
stack.down()

View File

@ -2,8 +2,9 @@ from __future__ import annotations
from typing import Any
from pydantic import BaseModel, ConfigDict, computed_field
from pydantic import BaseModel, ConfigDict, Field, computed_field
from fields.base import ResponseModel
from graphon.file import helpers as file_helpers
from models.model import IconType
@ -19,6 +20,113 @@ class SystemParameters(BaseModel):
workflow_file_upload_limit: int
class SimpleResultResponse(ResponseModel):
result: str
class SimpleResultMessageResponse(ResponseModel):
result: str
message: str
class SimpleMessageResponse(ResponseModel):
message: str
class SimpleDataResponse(ResponseModel):
data: str
class SimpleResultDataResponse(ResponseModel):
result: str
data: str
class SimpleResultStringListResponse(ResponseModel):
result: str
data: list[str]
class SimpleResultOptionalDataResponse(ResponseModel):
result: str
data: str | None = None
class AccessTokenData(ResponseModel):
access_token: str
class AccessTokenResultResponse(ResponseModel):
result: str
data: AccessTokenData
class VerificationTokenResponse(ResponseModel):
is_valid: bool
email: str
token: str
class LoginStatusResponse(ResponseModel):
logged_in: bool
app_logged_in: bool
class AccessModeResponse(ResponseModel):
access_mode: str = Field(serialization_alias="accessMode", validation_alias="accessMode")
class BooleanResultResponse(ResponseModel):
result: bool
class SuccessResponse(ResponseModel):
success: bool
class UsageCheckResponse(ResponseModel):
is_using: bool
class UsageCountResponse(ResponseModel):
is_using: bool
count: int
class IndexInfoResponse(ResponseModel):
welcome: str
api_version: str
server_version: str
class AvatarUrlResponse(ResponseModel):
avatar_url: str
class TextContentResponse(ResponseModel):
content: str
class AllowedExtensionsResponse(ResponseModel):
allowed_extensions: list[str]
class UrlResponse(ResponseModel):
url: str
class RedirectUrlResponse(ResponseModel):
redirect_url: str
class ApiBaseUrlResponse(ResponseModel):
api_base_url: str
class NewAppResponse(ResponseModel):
new_app_id: str
class Parameters(BaseModel):
opening_statement: str | None = None
suggested_questions: list[str]

View File

@ -44,6 +44,8 @@ from . import (
spec,
version,
)
from .agent import composer as agent_composer
from .agent import roster as agent_roster
# Import app controllers
from .app import (
@ -143,7 +145,9 @@ __all__ = [
"activate",
"advanced_prompt_template",
"agent",
"agent_composer",
"agent_providers",
"agent_roster",
"annotation",
"api",
"apikey",

View File

@ -0,0 +1,3 @@
from . import composer, roster
__all__ = ["composer", "roster"]

View File

@ -0,0 +1,153 @@
from flask_restx import Resource
from controllers.common.schema import register_schema_models
from controllers.console import console_ns
from controllers.console.app.wraps import get_app_model
from controllers.console.wraps import account_initialization_required, edit_permission_required, setup_required
from libs.login import current_account_with_tenant, login_required
from models.model import AppMode
from services.agent.composer_service import AgentComposerService
from services.agent.composer_validator import ComposerConfigValidator
from services.entities.agent_entities import ComposerSavePayload
register_schema_models(console_ns, ComposerSavePayload)
@console_ns.route("/apps/<uuid:app_id>/workflows/draft/nodes/<string:node_id>/agent-composer")
class WorkflowAgentComposerApi(Resource):
@setup_required
@login_required
@account_initialization_required
@get_app_model(mode=[AppMode.WORKFLOW, AppMode.ADVANCED_CHAT])
def get(self, app_model, node_id: str):
_, tenant_id = current_account_with_tenant()
return AgentComposerService.load_workflow_composer(
tenant_id=tenant_id,
app_id=app_model.id,
node_id=node_id,
)
@console_ns.expect(console_ns.models[ComposerSavePayload.__name__])
@setup_required
@login_required
@account_initialization_required
@edit_permission_required
@get_app_model(mode=[AppMode.WORKFLOW, AppMode.ADVANCED_CHAT])
def put(self, app_model, node_id: str):
account, tenant_id = current_account_with_tenant()
payload = ComposerSavePayload.model_validate(console_ns.payload or {})
return AgentComposerService.save_workflow_composer(
tenant_id=tenant_id,
app_id=app_model.id,
node_id=node_id,
account_id=account.id,
payload=payload,
)
@console_ns.route("/apps/<uuid:app_id>/workflows/draft/nodes/<string:node_id>/agent-composer/validate")
class WorkflowAgentComposerValidateApi(Resource):
@console_ns.expect(console_ns.models[ComposerSavePayload.__name__])
@setup_required
@login_required
@account_initialization_required
@get_app_model(mode=[AppMode.WORKFLOW, AppMode.ADVANCED_CHAT])
def post(self, app_model, node_id: str):
payload = ComposerSavePayload.model_validate(console_ns.payload or {})
ComposerConfigValidator.validate_save_payload(payload)
return {"result": "success", "errors": []}
@console_ns.route("/apps/<uuid:app_id>/workflows/draft/nodes/<string:node_id>/agent-composer/candidates")
class WorkflowAgentComposerCandidatesApi(Resource):
@setup_required
@login_required
@account_initialization_required
@get_app_model(mode=[AppMode.WORKFLOW, AppMode.ADVANCED_CHAT])
def get(self, app_model, node_id: str):
return AgentComposerService.get_workflow_candidates(app_id=app_model.id)
@console_ns.route("/apps/<uuid:app_id>/workflows/draft/nodes/<string:node_id>/agent-composer/impact")
class WorkflowAgentComposerImpactApi(Resource):
@setup_required
@login_required
@account_initialization_required
@get_app_model(mode=[AppMode.WORKFLOW, AppMode.ADVANCED_CHAT])
def post(self, app_model, node_id: str):
_, tenant_id = current_account_with_tenant()
payload = ComposerSavePayload.model_validate(console_ns.payload or {})
current_snapshot_id = payload.binding.current_snapshot_id if payload.binding else None
if not current_snapshot_id:
return {"current_snapshot_id": None, "workflow_node_count": 0, "bindings": []}
return AgentComposerService.calculate_impact(tenant_id=tenant_id, current_snapshot_id=current_snapshot_id)
@console_ns.route("/apps/<uuid:app_id>/workflows/draft/nodes/<string:node_id>/agent-composer/save-to-roster")
class WorkflowAgentComposerSaveToRosterApi(Resource):
@console_ns.expect(console_ns.models[ComposerSavePayload.__name__])
@setup_required
@login_required
@account_initialization_required
@edit_permission_required
@get_app_model(mode=[AppMode.WORKFLOW, AppMode.ADVANCED_CHAT])
def post(self, app_model, node_id: str):
account, tenant_id = current_account_with_tenant()
payload = ComposerSavePayload.model_validate(console_ns.payload or {})
return AgentComposerService.save_workflow_composer(
tenant_id=tenant_id,
app_id=app_model.id,
node_id=node_id,
account_id=account.id,
payload=payload,
)
@console_ns.route("/apps/<uuid:app_id>/agent-composer")
class AgentAppComposerApi(Resource):
@setup_required
@login_required
@account_initialization_required
@get_app_model()
def get(self, app_model):
_, tenant_id = current_account_with_tenant()
return AgentComposerService.load_agent_app_composer(tenant_id=tenant_id, app_id=app_model.id)
@console_ns.expect(console_ns.models[ComposerSavePayload.__name__])
@setup_required
@login_required
@account_initialization_required
@edit_permission_required
@get_app_model()
def put(self, app_model):
account, tenant_id = current_account_with_tenant()
payload = ComposerSavePayload.model_validate(console_ns.payload or {})
return AgentComposerService.save_agent_app_composer(
tenant_id=tenant_id,
app_id=app_model.id,
account_id=account.id,
payload=payload,
)
@console_ns.route("/apps/<uuid:app_id>/agent-composer/validate")
class AgentAppComposerValidateApi(Resource):
@console_ns.expect(console_ns.models[ComposerSavePayload.__name__])
@setup_required
@login_required
@account_initialization_required
@get_app_model()
def post(self, app_model):
payload = ComposerSavePayload.model_validate(console_ns.payload or {})
ComposerConfigValidator.validate_save_payload(payload)
return {"result": "success", "errors": []}
@console_ns.route("/apps/<uuid:app_id>/agent-composer/candidates")
class AgentAppComposerCandidatesApi(Resource):
@setup_required
@login_required
@account_initialization_required
@get_app_model()
def get(self, app_model):
return AgentComposerService.get_agent_app_candidates(app_id=app_model.id)

View File

@ -0,0 +1,130 @@
from flask import request
from flask_restx import Resource
from pydantic import BaseModel, Field
from controllers.common.schema import register_schema_models
from controllers.console import console_ns
from controllers.console.wraps import account_initialization_required, edit_permission_required, setup_required
from extensions.ext_database import db
from libs.login import current_account_with_tenant, login_required
from services.agent.roster_service import AgentRosterService
from services.entities.agent_entities import RosterAgentCreatePayload, RosterAgentUpdatePayload, RosterListQuery
class AgentInviteOptionsQuery(RosterListQuery):
app_id: str | None = Field(default=None, description="Workflow app id for in-current-workflow markers")
class AgentIdPath(BaseModel):
agent_id: str
register_schema_models(
console_ns,
AgentInviteOptionsQuery,
AgentIdPath,
RosterAgentCreatePayload,
RosterAgentUpdatePayload,
RosterListQuery,
)
def _agent_roster_service() -> AgentRosterService:
return AgentRosterService(db.session)
@console_ns.route("/agents")
class AgentRosterListApi(Resource):
@setup_required
@login_required
@account_initialization_required
def get(self):
_, tenant_id = current_account_with_tenant()
query = RosterListQuery.model_validate(request.args.to_dict(flat=True))
return _agent_roster_service().list_roster_agents(
tenant_id=tenant_id, page=query.page, limit=query.limit, keyword=query.keyword
)
@console_ns.expect(console_ns.models[RosterAgentCreatePayload.__name__])
@setup_required
@login_required
@account_initialization_required
@edit_permission_required
def post(self):
account, tenant_id = current_account_with_tenant()
payload = RosterAgentCreatePayload.model_validate(console_ns.payload or {})
service = _agent_roster_service()
agent = service.create_roster_agent(tenant_id=tenant_id, account_id=account.id, payload=payload)
return service.get_roster_agent_detail(tenant_id=tenant_id, agent_id=agent.id), 201
@console_ns.route("/agents/invite-options")
class AgentInviteOptionsApi(Resource):
@setup_required
@login_required
@account_initialization_required
def get(self):
_, tenant_id = current_account_with_tenant()
query = AgentInviteOptionsQuery.model_validate(request.args.to_dict(flat=True))
return _agent_roster_service().list_invite_options(
tenant_id=tenant_id,
page=query.page,
limit=query.limit,
keyword=query.keyword,
app_id=query.app_id,
)
@console_ns.route("/agents/<uuid:agent_id>")
class AgentRosterDetailApi(Resource):
@setup_required
@login_required
@account_initialization_required
def get(self, agent_id):
_, tenant_id = current_account_with_tenant()
return _agent_roster_service().get_roster_agent_detail(tenant_id=tenant_id, agent_id=str(agent_id))
@console_ns.expect(console_ns.models[RosterAgentUpdatePayload.__name__])
@setup_required
@login_required
@account_initialization_required
@edit_permission_required
def patch(self, agent_id):
account, tenant_id = current_account_with_tenant()
payload = RosterAgentUpdatePayload.model_validate(console_ns.payload or {})
return _agent_roster_service().update_roster_agent(
tenant_id=tenant_id, agent_id=str(agent_id), account_id=account.id, payload=payload
)
@setup_required
@login_required
@account_initialization_required
@edit_permission_required
def delete(self, agent_id):
account, tenant_id = current_account_with_tenant()
_agent_roster_service().archive_roster_agent(tenant_id=tenant_id, agent_id=str(agent_id), account_id=account.id)
return "", 204
@console_ns.route("/agents/<uuid:agent_id>/versions")
class AgentRosterVersionsApi(Resource):
@setup_required
@login_required
@account_initialization_required
def get(self, agent_id):
_, tenant_id = current_account_with_tenant()
return {"data": _agent_roster_service().list_agent_versions(tenant_id=tenant_id, agent_id=str(agent_id))}
@console_ns.route("/agents/<uuid:agent_id>/versions/<uuid:version_id>")
class AgentRosterVersionDetailApi(Resource):
@setup_required
@login_required
@account_initialization_required
def get(self, agent_id, version_id):
_, tenant_id = current_account_with_tenant()
return _agent_roster_service().get_agent_version_detail(
tenant_id=tenant_id,
agent_id=str(agent_id),
version_id=str(version_id),
)

View File

@ -12,8 +12,9 @@ from sqlalchemy.orm import Session
from werkzeug.datastructures import MultiDict
from werkzeug.exceptions import BadRequest
from controllers.common.fields import RedirectUrlResponse, SimpleResultResponse
from controllers.common.helpers import FileInfo
from controllers.common.schema import register_enum_models, register_schema_models
from controllers.common.schema import register_enum_models, register_response_schema_models, register_schema_models
from controllers.console import console_ns
from controllers.console.app.wraps import get_app_model
from controllers.console.workspace.models import LoadBalancingPayload
@ -413,6 +414,7 @@ class AppExportResponse(ResponseModel):
register_enum_models(console_ns, RetrievalMethod, WorkflowExecutionStatus, DatasetPermissionEnum)
register_response_schema_models(console_ns, RedirectUrlResponse, SimpleResultResponse)
register_schema_models(
console_ns,
@ -724,6 +726,7 @@ class AppExportApi(Resource):
@console_ns.route("/apps/<uuid:app_id>/publish-to-creators-platform")
class AppPublishToCreatorsPlatformApi(Resource):
@console_ns.response(200, "Success", console_ns.models[RedirectUrlResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@ -861,7 +864,11 @@ class AppTraceApi(Resource):
@console_ns.doc(description="Update app tracing configuration")
@console_ns.doc(params={"app_id": "Application ID"})
@console_ns.expect(console_ns.models[AppTracePayload.__name__])
@console_ns.response(200, "Trace configuration updated successfully")
@console_ns.response(
200,
"Trace configuration updated successfully",
console_ns.models[SimpleResultResponse.__name__],
)
@console_ns.response(403, "Insufficient permissions")
@setup_required
@login_required

View File

@ -7,7 +7,8 @@ from pydantic import BaseModel, Field, field_validator
from werkzeug.exceptions import InternalServerError, NotFound
import services
from controllers.common.schema import register_schema_models
from controllers.common.fields import SimpleResultResponse
from controllers.common.schema import register_response_schema_models, register_schema_models
from controllers.console import console_ns
from controllers.console.app.error import (
AppUnavailableError,
@ -66,6 +67,7 @@ class ChatMessagePayload(BaseMessagePayload):
register_schema_models(console_ns, CompletionMessagePayload, ChatMessagePayload)
register_response_schema_models(console_ns, SimpleResultResponse)
# define completion message api for user
@ -124,7 +126,7 @@ class CompletionMessageStopApi(Resource):
@console_ns.doc("stop_completion_message")
@console_ns.doc(description="Stop a running completion message generation")
@console_ns.doc(params={"app_id": "Application ID", "task_id": "Task ID to stop"})
@console_ns.response(200, "Task stopped successfully")
@console_ns.response(200, "Task stopped successfully", console_ns.models[SimpleResultResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@ -205,7 +207,7 @@ class ChatMessageStopApi(Resource):
@console_ns.doc("stop_chat_message")
@console_ns.doc(description="Stop a running chat message generation")
@console_ns.doc(params={"app_id": "Application ID", "task_id": "Task ID to stop"})
@console_ns.response(200, "Task stopped successfully")
@console_ns.response(200, "Task stopped successfully", console_ns.models[SimpleResultResponse.__name__])
@setup_required
@login_required
@account_initialization_required

View File

@ -9,7 +9,8 @@ from sqlalchemy import exists, func, select
from werkzeug.exceptions import InternalServerError, NotFound
from controllers.common.controller_schemas import MessageFeedbackPayload as _MessageFeedbackPayloadBase
from controllers.common.schema import register_schema_models
from controllers.common.fields import SimpleResultResponse
from controllers.common.schema import register_response_schema_models, register_schema_models
from controllers.console import console_ns
from controllers.console.app.error import (
CompletionRequestError,
@ -162,6 +163,7 @@ register_schema_models(
MessageDetailResponse,
MessageInfiniteScrollPaginationResponse,
)
register_response_schema_models(console_ns, SimpleResultResponse)
@console_ns.route("/apps/<uuid:app_id>/chat-messages")
@ -247,7 +249,7 @@ class MessageFeedbackApi(Resource):
@console_ns.doc(description="Create or update message feedback (like/dislike)")
@console_ns.doc(params={"app_id": "Application ID"})
@console_ns.expect(console_ns.models[MessageFeedbackPayload.__name__])
@console_ns.response(200, "Feedback updated successfully")
@console_ns.response(200, "Feedback updated successfully", console_ns.models[SimpleResultResponse.__name__])
@console_ns.response(404, "Message not found")
@console_ns.response(403, "Insufficient permissions")
@get_app_model

View File

@ -12,6 +12,7 @@ from werkzeug.exceptions import BadRequest, Forbidden, InternalServerError, NotF
import services
from controllers.common.controller_schemas import DefaultBlockConfigQuery, WorkflowListQuery, WorkflowUpdatePayload
from controllers.common.fields import NewAppResponse, SimpleResultResponse
from controllers.common.schema import (
register_response_schema_model,
register_response_schema_models,
@ -290,6 +291,8 @@ register_response_schema_models(
WorkflowOnlineUser,
WorkflowOnlineUsersByApp,
WorkflowOnlineUsersResponse,
NewAppResponse,
SimpleResultResponse,
)
@ -869,7 +872,7 @@ class WorkflowTaskStopApi(Resource):
@console_ns.doc("stop_workflow_task")
@console_ns.doc(description="Stop running workflow task")
@console_ns.doc(params={"app_id": "Application ID", "task_id": "Task ID"})
@console_ns.response(200, "Task stopped successfully")
@console_ns.response(200, "Task stopped successfully", console_ns.models[SimpleResultResponse.__name__])
@console_ns.response(404, "Task not found")
@console_ns.response(403, "Permission denied")
@setup_required
@ -1069,7 +1072,11 @@ class ConvertToWorkflowApi(Resource):
@console_ns.doc("convert_to_workflow")
@console_ns.doc(description="Convert application to workflow mode")
@console_ns.doc(params={"app_id": "Application ID"})
@console_ns.response(200, "Application converted to workflow successfully")
@console_ns.response(
200,
"Application converted to workflow successfully",
console_ns.models[NewAppResponse.__name__],
)
@console_ns.response(400, "Application cannot be converted")
@console_ns.response(403, "Permission denied")
@setup_required
@ -1106,7 +1113,11 @@ class WorkflowFeaturesApi(Resource):
@console_ns.doc("update_workflow_features")
@console_ns.doc(description="Update draft workflow features")
@console_ns.doc(params={"app_id": "Application ID"})
@console_ns.response(200, "Workflow features updated successfully")
@console_ns.response(
200,
"Workflow features updated successfully",
console_ns.models[SimpleResultResponse.__name__],
)
@setup_required
@login_required
@account_initialization_required

View File

@ -1,5 +1,3 @@
from typing import Any
from flask import request
from flask_restx import Resource
from pydantic import BaseModel, Field, field_validator
@ -40,16 +38,29 @@ class ActivatePayload(BaseModel):
return timezone(value)
class ActivationCheckResponse(BaseModel):
is_valid: bool = Field(description="Whether token is valid")
data: dict[str, Any] | None = Field(default=None, description="Activation data if valid")
class ActivationResponse(BaseModel):
result: str = Field(description="Operation result")
register_schema_models(console_ns, ActivateCheckQuery, ActivatePayload, ActivationCheckResponse, ActivationResponse)
class ActivationCheckData(BaseModel):
workspace_name: str | None
workspace_id: str | None
email: str | None
class ActivationCheckResponse(BaseModel):
is_valid: bool = Field(description="Whether token is valid")
data: ActivationCheckData | None = Field(default=None, description="Activation data if valid")
register_schema_models(
console_ns,
ActivateCheckQuery,
ActivatePayload,
ActivationCheckData,
ActivationCheckResponse,
ActivationResponse,
)
@console_ns.route("/activate/check")

View File

@ -1,7 +1,8 @@
from flask_restx import Resource
from pydantic import BaseModel, Field
from controllers.common.schema import register_schema_models
from controllers.common.schema import register_response_schema_models, register_schema_models
from fields.base import ResponseModel
from libs.login import current_account_with_tenant, login_required
from services.auth.api_key_auth_service import ApiKeyAuthService
@ -16,11 +17,26 @@ class ApiKeyAuthBindingPayload(BaseModel):
credentials: dict = Field(...)
class ApiKeyAuthDataSourceItem(ResponseModel):
id: str
category: str
provider: str
disabled: bool
created_at: int
updated_at: int
class ApiKeyAuthDataSourceListResponse(ResponseModel):
sources: list[ApiKeyAuthDataSourceItem]
register_schema_models(console_ns, ApiKeyAuthBindingPayload)
register_response_schema_models(console_ns, ApiKeyAuthDataSourceItem, ApiKeyAuthDataSourceListResponse)
@console_ns.route("/api-key-auth/data-source")
class ApiKeyAuthDataSource(Resource):
@console_ns.response(200, "Success", console_ns.models[ApiKeyAuthDataSourceListResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@ -70,6 +86,7 @@ class ApiKeyAuthDataSourceBindingDelete(Resource):
@login_required
@account_initialization_required
@is_admin_or_owner_required
@console_ns.response(204, "Binding deleted successfully")
def delete(self, binding_id):
# The role of the current user in the table must be admin or owner
_, current_tenant_id = current_account_with_tenant()

View File

@ -4,7 +4,8 @@ from pydantic import BaseModel, Field, field_validator
from configs import dify_config
from constants.languages import get_valid_language, languages
from controllers.common.schema import register_schema_models
from controllers.common.fields import SimpleResultDataResponse, VerificationTokenResponse
from controllers.common.schema import register_response_schema_models, register_schema_models
from controllers.console import console_ns
from controllers.console.auth.error import (
EmailAlreadyInUseError,
@ -58,6 +59,7 @@ class EmailRegisterResetPayload(BaseModel):
register_schema_models(console_ns, EmailRegisterSendPayload, EmailRegisterValidityPayload, EmailRegisterResetPayload)
register_response_schema_models(console_ns, SimpleResultDataResponse, VerificationTokenResponse)
@console_ns.route("/email-register/send-email")
@ -65,6 +67,7 @@ class EmailRegisterSendEmailApi(Resource):
@setup_required
@email_password_login_enabled
@email_register_enabled
@console_ns.response(200, "Success", console_ns.models[SimpleResultDataResponse.__name__])
def post(self):
args = EmailRegisterSendPayload.model_validate(console_ns.payload)
normalized_email = args.email.lower()
@ -89,6 +92,7 @@ class EmailRegisterCheckApi(Resource):
@setup_required
@email_password_login_enabled
@email_register_enabled
@console_ns.response(200, "Success", console_ns.models[VerificationTokenResponse.__name__])
def post(self):
args = EmailRegisterValidityPayload.model_validate(console_ns.payload)

View File

@ -9,7 +9,8 @@ from werkzeug.exceptions import Unauthorized
import services
from configs import dify_config
from constants.languages import get_valid_language
from controllers.common.schema import register_schema_models
from controllers.common.fields import SimpleResultDataResponse, SimpleResultOptionalDataResponse, SimpleResultResponse
from controllers.common.schema import register_response_schema_models, register_schema_models
from controllers.console import console_ns
from controllers.console.auth.error import (
AuthenticationFailedError,
@ -81,6 +82,12 @@ class EmailCodeLoginPayload(BaseModel):
register_schema_models(console_ns, LoginPayload, EmailPayload, EmailCodeLoginPayload)
register_response_schema_models(
console_ns,
SimpleResultDataResponse,
SimpleResultOptionalDataResponse,
SimpleResultResponse,
)
@console_ns.route("/login")
@ -90,6 +97,7 @@ class LoginApi(Resource):
@setup_required
@email_password_login_enabled
@console_ns.expect(console_ns.models[LoginPayload.__name__])
@console_ns.response(200, "Success", console_ns.models[SimpleResultOptionalDataResponse.__name__])
@decrypt_password_field
def post(self):
"""Authenticate user and login."""
@ -163,6 +171,7 @@ class LoginApi(Resource):
@console_ns.route("/logout")
class LogoutApi(Resource):
@setup_required
@console_ns.response(200, "Success", console_ns.models[SimpleResultResponse.__name__])
def post(self):
current_user, _ = current_account_with_tenant()
account = current_user
@ -186,6 +195,7 @@ class ResetPasswordSendEmailApi(Resource):
@setup_required
@email_password_login_enabled
@console_ns.expect(console_ns.models[EmailPayload.__name__])
@console_ns.response(200, "Success", console_ns.models[SimpleResultDataResponse.__name__])
def post(self):
args = EmailPayload.model_validate(console_ns.payload)
normalized_email = args.email.lower()
@ -213,6 +223,7 @@ class ResetPasswordSendEmailApi(Resource):
class EmailCodeLoginSendEmailApi(Resource):
@setup_required
@console_ns.expect(console_ns.models[EmailPayload.__name__])
@console_ns.response(200, "Success", console_ns.models[SimpleResultDataResponse.__name__])
def post(self):
args = EmailPayload.model_validate(console_ns.payload)
normalized_email = args.email.lower()
@ -245,6 +256,7 @@ class EmailCodeLoginSendEmailApi(Resource):
class EmailCodeLoginApi(Resource):
@setup_required
@console_ns.expect(console_ns.models[EmailCodeLoginPayload.__name__])
@console_ns.response(200, "Success", console_ns.models[SimpleResultResponse.__name__])
@decrypt_code_field
def post(self):
args = EmailCodeLoginPayload.model_validate(console_ns.payload)
@ -321,6 +333,7 @@ class EmailCodeLoginApi(Resource):
@console_ns.route("/refresh-token")
class RefreshTokenApi(Resource):
@console_ns.response(200, "Success", console_ns.models[SimpleResultResponse.__name__])
def post(self):
# Get refresh token from cookie instead of request body
refresh_token = extract_refresh_token(request)

View File

@ -9,7 +9,8 @@ from sqlalchemy import select
from sqlalchemy.orm import sessionmaker
from werkzeug.exceptions import NotFound
from controllers.common.schema import get_or_create_model, register_schema_model
from controllers.common.fields import SimpleResultResponse, TextContentResponse
from controllers.common.schema import get_or_create_model, register_response_schema_models, register_schema_model
from core.datasource.entities.datasource_entities import DatasourceProviderType, OnlineDocumentPagesMessage
from core.datasource.online_document.online_document_plugin import OnlineDocumentDatasourcePlugin
from core.indexing_runner import IndexingRunner
@ -54,6 +55,7 @@ class DataSourceNotionPreviewQuery(BaseModel):
register_schema_model(console_ns, NotionEstimatePayload)
register_response_schema_models(console_ns, SimpleResultResponse, TextContentResponse)
integrate_icon_model = get_or_create_model("DataSourceIntegrateIcon", integrate_icon_fields)
@ -157,6 +159,7 @@ class DataSourceApi(Resource):
@setup_required
@login_required
@account_initialization_required
@console_ns.response(200, "Success", console_ns.models[SimpleResultResponse.__name__])
def patch(self, binding_id, action: Literal["enable", "disable"]):
_, current_tenant_id = current_account_with_tenant()
binding_id = str(binding_id)
@ -289,6 +292,7 @@ class DataSourceNotionApi(Resource):
@setup_required
@login_required
@account_initialization_required
@console_ns.response(200, "Success", console_ns.models[TextContentResponse.__name__])
def get(self, page_id, page_type):
_, current_tenant_id = current_account_with_tenant()
@ -362,6 +366,7 @@ class DataSourceNotionDatasetSyncApi(Resource):
@setup_required
@login_required
@account_initialization_required
@console_ns.response(200, "Success", console_ns.models[SimpleResultResponse.__name__])
def get(self, dataset_id):
dataset_id_str = str(dataset_id)
dataset = DatasetService.get_dataset(dataset_id_str)
@ -379,6 +384,7 @@ class DataSourceNotionDocumentSyncApi(Resource):
@setup_required
@login_required
@account_initialization_required
@console_ns.response(200, "Success", console_ns.models[SimpleResultResponse.__name__])
def get(self, dataset_id, document_id):
dataset_id_str = str(dataset_id)
document_id_str = str(document_id)

View File

@ -8,7 +8,8 @@ from werkzeug.exceptions import Forbidden, NotFound
import services
from configs import dify_config
from controllers.common.schema import get_or_create_model, register_schema_models
from controllers.common.fields import ApiBaseUrlResponse, SimpleResultResponse, UsageCheckResponse
from controllers.common.schema import get_or_create_model, register_response_schema_models, register_schema_models
from controllers.console import console_ns
from controllers.console.apikey import ApiKeyItem, ApiKeyList
from controllers.console.app.error import ProviderNotInitializeError
@ -58,6 +59,8 @@ from models.provider_ids import ModelProviderID
from services.api_token_service import ApiTokenCache
from services.dataset_service import DatasetPermissionService, DatasetService, DocumentService
register_response_schema_models(console_ns, ApiBaseUrlResponse, SimpleResultResponse, UsageCheckResponse)
# Register models for flask_restx to avoid dict type issues in Swagger
dataset_base_model = get_or_create_model("DatasetBase", dataset_fields)
@ -521,6 +524,7 @@ class DatasetApi(Resource):
@login_required
@account_initialization_required
@cloud_edition_billing_rate_limit_check("knowledge")
@console_ns.response(204, "Dataset deleted successfully")
def delete(self, dataset_id):
dataset_id_str = str(dataset_id)
current_user, _ = current_account_with_tenant()
@ -543,7 +547,11 @@ class DatasetUseCheckApi(Resource):
@console_ns.doc("check_dataset_use")
@console_ns.doc(description="Check if dataset is in use")
@console_ns.doc(params={"dataset_id": "Dataset ID"})
@console_ns.response(200, "Dataset use status retrieved successfully")
@console_ns.response(
200,
"Dataset use status retrieved successfully",
console_ns.models[UsageCheckResponse.__name__],
)
@setup_required
@login_required
@account_initialization_required
@ -873,6 +881,7 @@ class DatasetEnableApiApi(Resource):
@setup_required
@login_required
@account_initialization_required
@console_ns.response(200, "Success", console_ns.models[SimpleResultResponse.__name__])
def post(self, dataset_id, status):
dataset_id_str = str(dataset_id)
@ -885,7 +894,7 @@ class DatasetEnableApiApi(Resource):
class DatasetApiBaseUrlApi(Resource):
@console_ns.doc("get_dataset_api_base_info")
@console_ns.doc(description="Get dataset API base information")
@console_ns.response(200, "API base info retrieved successfully")
@console_ns.response(200, "API base info retrieved successfully", console_ns.models[ApiBaseUrlResponse.__name__])
@setup_required
@login_required
@account_initialization_required

View File

@ -15,7 +15,8 @@ from werkzeug.exceptions import Forbidden, NotFound
import services
from controllers.common.controller_schemas import DocumentBatchDownloadZipPayload
from controllers.common.schema import register_schema_models
from controllers.common.fields import SimpleResultMessageResponse, SimpleResultResponse, UrlResponse
from controllers.common.schema import register_response_schema_models, register_schema_models
from controllers.console import console_ns
from core.errors.error import (
LLMBadRequestError,
@ -204,6 +205,7 @@ register_schema_models(
DocumentWithSegmentsResponse,
DatasetAndDocumentResponse,
)
register_response_schema_models(console_ns, SimpleResultMessageResponse, SimpleResultResponse, UrlResponse)
class DocumentResource(Resource):
@ -487,6 +489,7 @@ class DatasetDocumentListApi(Resource):
@login_required
@account_initialization_required
@cloud_edition_billing_rate_limit_check("knowledge")
@console_ns.response(204, "Documents deleted successfully")
def delete(self, dataset_id):
dataset_id = str(dataset_id)
dataset = DatasetService.get_dataset(dataset_id)
@ -946,6 +949,7 @@ class DocumentApi(DocumentResource):
@login_required
@account_initialization_required
@cloud_edition_billing_rate_limit_check("knowledge")
@console_ns.response(204, "Document deleted successfully")
def delete(self, dataset_id, document_id):
dataset_id = str(dataset_id)
document_id = str(document_id)
@ -971,6 +975,7 @@ class DocumentDownloadApi(DocumentResource):
@console_ns.doc("get_dataset_document_download_url")
@console_ns.doc(description="Get a signed download URL for a dataset document's original uploaded file")
@console_ns.response(200, "Download URL generated successfully", console_ns.models[UrlResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@ -1028,7 +1033,11 @@ class DocumentProcessingApi(DocumentResource):
@console_ns.doc(
params={"dataset_id": "Dataset ID", "document_id": "Document ID", "action": "Action to perform (pause/resume)"}
)
@console_ns.response(200, "Processing status updated successfully")
@console_ns.response(
200,
"Processing status updated successfully",
console_ns.models[SimpleResultResponse.__name__],
)
@console_ns.response(404, "Document not found")
@console_ns.response(400, "Invalid action")
@setup_required
@ -1073,7 +1082,11 @@ class DocumentMetadataApi(DocumentResource):
@console_ns.doc(description="Update document metadata")
@console_ns.doc(params={"dataset_id": "Dataset ID", "document_id": "Document ID"})
@console_ns.expect(console_ns.models[DocumentMetadataUpdatePayload.__name__])
@console_ns.response(200, "Document metadata updated successfully")
@console_ns.response(
200,
"Document metadata updated successfully",
console_ns.models[SimpleResultMessageResponse.__name__],
)
@console_ns.response(404, "Document not found")
@console_ns.response(403, "Permission denied")
@setup_required
@ -1127,6 +1140,7 @@ class DocumentStatusApi(DocumentResource):
@account_initialization_required
@cloud_edition_billing_resource_check("vector_space")
@cloud_edition_billing_rate_limit_check("knowledge")
@console_ns.response(200, "Success", console_ns.models[SimpleResultResponse.__name__])
def patch(self, dataset_id, action: Literal["enable", "disable", "archive", "un_archive"]):
current_user, _ = current_account_with_tenant()
dataset_id = str(dataset_id)
@ -1164,6 +1178,7 @@ class DocumentPauseApi(DocumentResource):
@login_required
@account_initialization_required
@cloud_edition_billing_rate_limit_check("knowledge")
@console_ns.response(204, "Document paused successfully")
def patch(self, dataset_id, document_id):
"""pause document."""
dataset_id = str(dataset_id)
@ -1198,6 +1213,7 @@ class DocumentRecoverApi(DocumentResource):
@login_required
@account_initialization_required
@cloud_edition_billing_rate_limit_check("knowledge")
@console_ns.response(204, "Document resumed successfully")
def patch(self, dataset_id, document_id):
"""recover document."""
dataset_id = str(dataset_id)
@ -1230,6 +1246,7 @@ class DocumentRetryApi(DocumentResource):
@account_initialization_required
@cloud_edition_billing_rate_limit_check("knowledge")
@console_ns.expect(console_ns.models[DocumentRetryPayload.__name__])
@console_ns.response(204, "Documents retry started successfully")
def post(self, dataset_id):
"""retry document."""
payload = DocumentRetryPayload.model_validate(console_ns.payload or {})
@ -1296,6 +1313,7 @@ class WebsiteDocumentSyncApi(DocumentResource):
@setup_required
@login_required
@account_initialization_required
@console_ns.response(200, "Success", console_ns.models[SimpleResultResponse.__name__])
def get(self, dataset_id, document_id):
"""sync website document."""
_, current_tenant_id = current_account_with_tenant()
@ -1362,7 +1380,11 @@ class DocumentGenerateSummaryApi(Resource):
@console_ns.doc(description="Generate summary index for documents")
@console_ns.doc(params={"dataset_id": "Dataset ID"})
@console_ns.expect(console_ns.models[GenerateSummaryPayload.__name__])
@console_ns.response(200, "Summary generation started successfully")
@console_ns.response(
200,
"Summary generation started successfully",
console_ns.models[SimpleResultResponse.__name__],
)
@console_ns.response(400, "Invalid request or dataset configuration")
@console_ns.response(403, "Permission denied")
@console_ns.response(404, "Dataset not found")

View File

@ -10,7 +10,8 @@ from werkzeug.exceptions import Forbidden, NotFound
import services
from configs import dify_config
from controllers.common.controller_schemas import ChildChunkCreatePayload, ChildChunkUpdatePayload
from controllers.common.schema import register_schema_models
from controllers.common.fields import SimpleResultResponse
from controllers.common.schema import register_response_schema_models, register_schema_models
from controllers.console import console_ns
from controllers.console.app.error import ProviderNotInitializeError
from controllers.console.datasets.error import (
@ -30,6 +31,7 @@ from core.model_manager import ModelManager
from core.rag.index_processor.constant.index_type import IndexTechniqueType
from extensions.ext_database import db
from extensions.ext_redis import redis_client
from fields.base import ResponseModel
from fields.segment_fields import child_chunk_fields, segment_fields
from graphon.model_runtime.entities.model_entities import ModelType
from libs.helper import escape_like_pattern
@ -83,6 +85,11 @@ class BatchImportPayload(BaseModel):
upload_file_id: str
class SegmentBatchImportStatusResponse(ResponseModel):
job_id: str
job_status: str
class ChildChunkBatchUpdatePayload(BaseModel):
chunks: list[ChildChunkUpdateArgs]
@ -98,6 +105,7 @@ register_schema_models(
ChildChunkBatchUpdatePayload,
ChildChunkUpdateArgs,
)
register_response_schema_models(console_ns, SegmentBatchImportStatusResponse, SimpleResultResponse)
@console_ns.route("/datasets/<uuid:dataset_id>/documents/<uuid:document_id>/segments")
@ -217,6 +225,7 @@ class DatasetDocumentSegmentListApi(Resource):
@login_required
@account_initialization_required
@cloud_edition_billing_rate_limit_check("knowledge")
@console_ns.response(204, "Segments deleted successfully")
def delete(self, dataset_id, document_id):
current_user, _ = current_account_with_tenant()
@ -252,6 +261,7 @@ class DatasetDocumentSegmentApi(Resource):
@account_initialization_required
@cloud_edition_billing_resource_check("vector_space")
@cloud_edition_billing_rate_limit_check("knowledge")
@console_ns.response(200, "Success", console_ns.models[SimpleResultResponse.__name__])
def patch(self, dataset_id, document_id, action):
current_user, current_tenant_id = current_account_with_tenant()
@ -424,6 +434,7 @@ class DatasetDocumentSegmentUpdateApi(Resource):
@login_required
@account_initialization_required
@cloud_edition_billing_rate_limit_check("knowledge")
@console_ns.response(204, "Segment deleted successfully")
def delete(self, dataset_id, document_id, segment_id):
current_user, current_tenant_id = current_account_with_tenant()
@ -464,6 +475,7 @@ class DatasetDocumentSegmentUpdateApi(Resource):
"/datasets/batch_import_status/<uuid:job_id>",
)
class DatasetDocumentSegmentBatchImportApi(Resource):
@console_ns.response(200, "Batch import started", console_ns.models[SegmentBatchImportStatusResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@ -514,6 +526,7 @@ class DatasetDocumentSegmentBatchImportApi(Resource):
return {"error": str(e)}, 500
return {"job_id": job_id, "job_status": "waiting"}, 200
@console_ns.response(200, "Batch import status", console_ns.models[SegmentBatchImportStatusResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@ -691,6 +704,7 @@ class ChildChunkUpdateApi(Resource):
@login_required
@account_initialization_required
@cloud_edition_billing_rate_limit_check("knowledge")
@console_ns.response(204, "Child chunk deleted successfully")
def delete(self, dataset_id, document_id, segment_id, child_chunk_id):
current_user, current_tenant_id = current_account_with_tenant()

View File

@ -4,7 +4,8 @@ from pydantic import BaseModel, Field
from werkzeug.exceptions import Forbidden, InternalServerError, NotFound
import services
from controllers.common.schema import get_or_create_model, register_schema_models
from controllers.common.fields import UsageCountResponse
from controllers.common.schema import get_or_create_model, register_response_schema_models, register_schema_models
from controllers.console import console_ns
from controllers.console.datasets.error import DatasetNameDuplicateError
from controllers.console.wraps import account_initialization_required, edit_permission_required, setup_required
@ -27,6 +28,8 @@ from services.external_knowledge_service import ExternalDatasetService
from services.hit_testing_service import HitTestingService
from services.knowledge_service import BedrockRetrievalSetting, ExternalDatasetTestService
register_response_schema_models(console_ns, UsageCountResponse)
def _build_dataset_detail_model():
keyword_setting_model = get_or_create_model("DatasetKeywordSetting", keyword_setting_fields)
@ -206,6 +209,7 @@ class ExternalApiTemplateApi(Resource):
@setup_required
@login_required
@account_initialization_required
@console_ns.response(204, "External knowledge API deleted successfully")
def delete(self, external_knowledge_api_id):
current_user, current_tenant_id = current_account_with_tenant()
external_knowledge_api_id = str(external_knowledge_api_id)
@ -222,7 +226,7 @@ class ExternalApiUseCheckApi(Resource):
@console_ns.doc("check_external_api_usage")
@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.response(200, "Usage check completed successfully", console_ns.models[UsageCountResponse.__name__])
@setup_required
@login_required
@account_initialization_required

View File

@ -4,7 +4,8 @@ from flask_restx import Resource, marshal_with
from werkzeug.exceptions import NotFound
from controllers.common.controller_schemas import MetadataUpdatePayload
from controllers.common.schema import register_schema_models
from controllers.common.fields import SimpleResultResponse
from controllers.common.schema import register_response_schema_models, register_schema_models
from controllers.console import console_ns
from controllers.console.wraps import account_initialization_required, enterprise_license_required, setup_required
from fields.dataset_fields import dataset_metadata_fields
@ -21,6 +22,7 @@ from services.metadata_service import MetadataService
register_schema_models(
console_ns, MetadataArgs, MetadataOperationData, MetadataUpdatePayload, DocumentMetadataOperation, MetadataDetail
)
register_response_schema_models(console_ns, SimpleResultResponse)
@console_ns.route("/datasets/<uuid:dataset_id>/metadata")
@ -83,6 +85,7 @@ class DatasetMetadataApi(Resource):
@login_required
@account_initialization_required
@enterprise_license_required
@console_ns.response(204, "Metadata deleted successfully")
def delete(self, dataset_id, metadata_id):
current_user, _ = current_account_with_tenant()
dataset_id_str = str(dataset_id)
@ -113,6 +116,7 @@ class DatasetMetadataBuiltInFieldActionApi(Resource):
@login_required
@account_initialization_required
@enterprise_license_required
@console_ns.response(200, "Success", console_ns.models[SimpleResultResponse.__name__])
def post(self, dataset_id, action: Literal["enable", "disable"]):
current_user, _ = current_account_with_tenant()
dataset_id_str = str(dataset_id)
@ -136,6 +140,7 @@ class DocumentMetadataEditApi(Resource):
@account_initialization_required
@enterprise_license_required
@console_ns.expect(console_ns.models[MetadataOperationData.__name__])
@console_ns.response(200, "Success", console_ns.models[SimpleResultResponse.__name__])
def post(self, dataset_id):
current_user, _ = current_account_with_tenant()
dataset_id_str = str(dataset_id)

View File

@ -6,7 +6,8 @@ from pydantic import BaseModel, Field
from werkzeug.exceptions import Forbidden, NotFound
from configs import dify_config
from controllers.common.schema import register_schema_models
from controllers.common.fields import SimpleResultResponse
from controllers.common.schema import register_response_schema_models, register_schema_models
from controllers.console import console_ns
from controllers.console.wraps import account_initialization_required, edit_permission_required, setup_required
from core.plugin.impl.oauth import OAuthHandler
@ -56,6 +57,7 @@ register_schema_models(
DatasourceDefaultPayload,
DatasourceUpdateNamePayload,
)
register_response_schema_models(console_ns, SimpleResultResponse)
@console_ns.route("/oauth/plugin/<path:provider_id>/datasource/get-authorization-url")
@ -209,6 +211,7 @@ class DatasourceAuth(Resource):
@console_ns.route("/auth/plugin/datasource/<path:provider_id>/delete")
class DatasourceAuthDeleteApi(Resource):
@console_ns.expect(console_ns.models[DatasourceCredentialDeletePayload.__name__])
@console_ns.response(200, "Success", console_ns.models[SimpleResultResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@ -306,6 +309,7 @@ class DatasourceAuthOauthCustomClient(Resource):
@setup_required
@login_required
@account_initialization_required
@console_ns.response(200, "Success", console_ns.models[SimpleResultResponse.__name__])
def delete(self, provider_id: str):
_, current_tenant_id = current_account_with_tenant()
@ -321,6 +325,7 @@ class DatasourceAuthOauthCustomClient(Resource):
@console_ns.route("/auth/plugin/datasource/<path:provider_id>/default")
class DatasourceAuthDefaultApi(Resource):
@console_ns.expect(console_ns.models[DatasourceDefaultPayload.__name__])
@console_ns.response(200, "Success", console_ns.models[SimpleResultResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@ -342,6 +347,7 @@ class DatasourceAuthDefaultApi(Resource):
@console_ns.route("/auth/plugin/datasource/<path:provider_id>/update-name")
class DatasourceUpdateProviderNameApi(Resource):
@console_ns.expect(console_ns.models[DatasourceUpdateNamePayload.__name__])
@console_ns.response(200, "Success", console_ns.models[SimpleResultResponse.__name__])
@setup_required
@login_required
@account_initialization_required

View File

@ -6,7 +6,8 @@ from pydantic import BaseModel, Field
from sqlalchemy import select
from sqlalchemy.orm import sessionmaker
from controllers.common.schema import register_schema_models
from controllers.common.fields import SimpleDataResponse
from controllers.common.schema import register_response_schema_models, register_schema_models
from controllers.console import console_ns
from controllers.console.wraps import (
account_initialization_required,
@ -59,6 +60,7 @@ class Payload(BaseModel):
register_schema_models(console_ns, Payload)
register_response_schema_models(console_ns, SimpleDataResponse)
@console_ns.route("/rag/pipeline/customized/templates/<string:template_id>")
@ -85,6 +87,7 @@ class CustomizedPipelineTemplateApi(Resource):
@login_required
@account_initialization_required
@enterprise_license_required
@console_ns.response(200, "Success", console_ns.models[SimpleDataResponse.__name__])
def post(self, template_id: str):
with sessionmaker(db.engine, expire_on_commit=False).begin() as session:
template = session.scalar(

View File

@ -10,6 +10,7 @@ from werkzeug.exceptions import BadRequest, Forbidden, InternalServerError, NotF
import services
from controllers.common.controller_schemas import DefaultBlockConfigQuery, WorkflowListQuery, WorkflowUpdatePayload
from controllers.common.fields import SimpleResultResponse
from controllers.common.schema import register_response_schema_models, register_schema_models
from controllers.console import console_ns
from controllers.console.app.error import (
@ -34,6 +35,7 @@ from core.app.apps.pipeline.pipeline_generator import PipelineGenerator
from core.app.entities.app_invoke_entities import InvokeFrom
from extensions.ext_database import db
from factories import variable_factory
from fields.base import ResponseModel
from fields.workflow_run_fields import (
WorkflowRunDetailResponse,
WorkflowRunNodeExecutionListResponse,
@ -115,6 +117,17 @@ class RagPipelineRecommendedPluginQuery(BaseModel):
type: str = "all"
class RagPipelineWorkflowSyncResponse(ResponseModel):
result: str
hash: str
updated_at: int
class RagPipelineWorkflowPublishResponse(ResponseModel):
result: str
created_at: int
register_schema_models(
console_ns,
DraftWorkflowSyncPayload,
@ -133,6 +146,9 @@ register_schema_models(
)
register_response_schema_models(
console_ns,
RagPipelineWorkflowPublishResponse,
RagPipelineWorkflowSyncResponse,
SimpleResultResponse,
WorkflowRunDetailResponse,
WorkflowRunNodeExecutionListResponse,
WorkflowRunNodeExecutionResponse,
@ -172,6 +188,7 @@ class DraftRagPipelineApi(Resource):
@account_initialization_required
@get_rag_pipeline
@edit_permission_required
@console_ns.response(200, "Success", console_ns.models[RagPipelineWorkflowSyncResponse.__name__])
def post(self, pipeline: Pipeline):
"""
Sync draft workflow
@ -462,6 +479,7 @@ class RagPipelineDraftNodeRunApi(Resource):
@console_ns.route("/rag/pipelines/<uuid:pipeline_id>/workflow-runs/tasks/<string:task_id>/stop")
class RagPipelineTaskStopApi(Resource):
@console_ns.response(200, "Task stopped successfully", console_ns.models[SimpleResultResponse.__name__])
@setup_required
@login_required
@edit_permission_required
@ -508,6 +526,7 @@ class PublishedRagPipelineApi(Resource):
return dump_response(WorkflowResponse, workflow)
@console_ns.response(200, "Success", console_ns.models[RagPipelineWorkflowPublishResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@ -630,6 +649,7 @@ class PublishedAllRagPipelineApi(Resource):
@console_ns.route("/rag/pipelines/<uuid:pipeline_id>/workflows/<string:workflow_id>/restore")
class RagPipelineDraftWorkflowRestoreApi(Resource):
@console_ns.response(200, "Success", console_ns.models[RagPipelineWorkflowSyncResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@ -699,6 +719,7 @@ class RagPipelineByIdApi(Resource):
return dump_response(WorkflowResponse, workflow)
@console_ns.response(204, "Workflow deleted successfully")
@setup_required
@login_required
@account_initialization_required

View File

@ -6,7 +6,8 @@ from pydantic import BaseModel, Field, field_validator
from werkzeug.exceptions import InternalServerError, NotFound
import services
from controllers.common.schema import register_schema_models
from controllers.common.fields import SimpleResultResponse
from controllers.common.schema import register_response_schema_models, register_schema_models
from controllers.console.app.error import (
AppUnavailableError,
CompletionRequestError,
@ -72,6 +73,7 @@ class ChatMessagePayload(BaseModel):
register_schema_models(console_ns, CompletionMessageExplorePayload, ChatMessagePayload)
register_response_schema_models(console_ns, SimpleResultResponse)
# define completion api for user
@ -130,6 +132,7 @@ class CompletionApi(InstalledAppResource):
endpoint="installed_app_stop_completion",
)
class CompletionStopApi(InstalledAppResource):
@console_ns.response(200, "Success", console_ns.models[SimpleResultResponse.__name__])
def post(self, installed_app, task_id):
app_model = installed_app.app
if app_model.mode != AppMode.COMPLETION:
@ -205,6 +208,7 @@ class ChatApi(InstalledAppResource):
endpoint="installed_app_stop_chat_completion",
)
class ChatStopApi(InstalledAppResource):
@console_ns.response(200, "Success", console_ns.models[SimpleResultResponse.__name__])
def post(self, installed_app, task_id):
app_model = installed_app.app
app_mode = AppMode.value_of(app_model.mode)

View File

@ -6,7 +6,7 @@ from sqlalchemy.orm import sessionmaker
from werkzeug.exceptions import NotFound
from controllers.common.controller_schemas import ConversationRenamePayload
from controllers.common.schema import register_schema_models
from controllers.common.schema import register_response_schema_models, register_schema_models
from controllers.console.explore.error import NotChatAppError
from controllers.console.explore.wraps import InstalledAppResource
from core.app.entities.app_invoke_entities import InvokeFrom
@ -34,6 +34,7 @@ class ConversationListQuery(BaseModel):
register_schema_models(console_ns, ConversationListQuery, ConversationRenamePayload)
register_response_schema_models(console_ns, ResultResponse)
@console_ns.route(
@ -89,6 +90,7 @@ class ConversationListApi(InstalledAppResource):
endpoint="installed_app_conversation",
)
class ConversationApi(InstalledAppResource):
@console_ns.response(204, "Conversation deleted successfully")
def delete(self, installed_app, c_id):
app_model = installed_app.app
app_mode = AppMode.value_of(app_model.mode)
@ -142,6 +144,7 @@ class ConversationRenameApi(InstalledAppResource):
endpoint="installed_app_conversation_pin",
)
class ConversationPinApi(InstalledAppResource):
@console_ns.response(200, "Success", console_ns.models[ResultResponse.__name__])
def patch(self, installed_app, c_id):
app_model = installed_app.app
app_mode = AppMode.value_of(app_model.mode)
@ -165,6 +168,7 @@ class ConversationPinApi(InstalledAppResource):
endpoint="installed_app_conversation_unpin",
)
class ConversationUnPinApi(InstalledAppResource):
@console_ns.response(200, "Success", console_ns.models[ResultResponse.__name__])
def patch(self, installed_app, c_id):
app_model = installed_app.app
app_mode = AppMode.value_of(app_model.mode)

View File

@ -8,7 +8,8 @@ from pydantic import BaseModel, Field, computed_field, field_validator
from sqlalchemy import and_, select
from werkzeug.exceptions import BadRequest, Forbidden, NotFound
from controllers.common.schema import register_schema_models
from controllers.common.fields import SimpleMessageResponse, SimpleResultMessageResponse
from controllers.common.schema import register_response_schema_models, register_schema_models
from controllers.console import console_ns
from controllers.console.explore.wraps import InstalledAppResource
from controllers.console.wraps import account_initialization_required, cloud_edition_billing_resource_check
@ -122,6 +123,7 @@ register_schema_models(
InstalledAppResponse,
InstalledAppListResponse,
)
register_response_schema_models(console_ns, SimpleMessageResponse, SimpleResultMessageResponse)
@console_ns.route("/installed-apps")
@ -209,6 +211,7 @@ class InstalledAppsListApi(Resource):
@login_required
@account_initialization_required
@cloud_edition_billing_resource_check("apps")
@console_ns.response(200, "Success", console_ns.models[SimpleMessageResponse.__name__])
def post(self):
payload = InstalledAppCreatePayload.model_validate(console_ns.payload or {})
@ -258,6 +261,7 @@ class InstalledAppApi(InstalledAppResource):
use InstalledAppResource to apply default decorators and get installed_app
"""
@console_ns.response(204, "App uninstalled successfully")
def delete(self, installed_app):
_, current_tenant_id = current_account_with_tenant()
if installed_app.app_owner_tenant_id == current_tenant_id:
@ -268,6 +272,7 @@ class InstalledAppApi(InstalledAppResource):
return {"result": "success", "message": "App uninstalled successfully"}, 204
@console_ns.response(200, "Success", console_ns.models[SimpleResultMessageResponse.__name__])
def patch(self, installed_app):
payload = InstalledAppUpdatePayload.model_validate(console_ns.payload or {})

View File

@ -6,7 +6,7 @@ from pydantic import BaseModel, TypeAdapter
from werkzeug.exceptions import InternalServerError, NotFound
from controllers.common.controller_schemas import MessageFeedbackPayload, MessageListQuery
from controllers.common.schema import register_schema_models
from controllers.common.schema import register_response_schema_models, register_schema_models
from controllers.console.app.error import (
AppMoreLikeThisDisabledError,
CompletionRequestError,
@ -49,6 +49,7 @@ class MoreLikeThisQuery(BaseModel):
register_schema_models(console_ns, MessageListQuery, MessageFeedbackPayload, MoreLikeThisQuery)
register_response_schema_models(console_ns, ResultResponse, SuggestedQuestionsResponse)
@console_ns.route(
@ -93,6 +94,7 @@ class MessageListApi(InstalledAppResource):
)
class MessageFeedbackApi(InstalledAppResource):
@console_ns.expect(console_ns.models[MessageFeedbackPayload.__name__])
@console_ns.response(200, "Feedback submitted successfully", console_ns.models[ResultResponse.__name__])
def post(self, installed_app, message_id):
current_user, _ = current_account_with_tenant()
app_model = installed_app.app
@ -166,6 +168,7 @@ class MessageMoreLikeThisApi(InstalledAppResource):
endpoint="installed_app_suggested_question",
)
class MessageSuggestedQuestionApi(InstalledAppResource):
@console_ns.response(200, "Success", console_ns.models[SuggestedQuestionsResponse.__name__])
def get(self, installed_app, message_id):
current_user, _ = current_account_with_tenant()
app_model = installed_app.app

View File

@ -3,7 +3,7 @@ from pydantic import TypeAdapter
from werkzeug.exceptions import NotFound
from controllers.common.controller_schemas import SavedMessageCreatePayload, SavedMessageListQuery
from controllers.common.schema import register_schema_models
from controllers.common.schema import register_response_schema_models, register_schema_models
from controllers.console import console_ns
from controllers.console.explore.error import NotCompletionAppError
from controllers.console.explore.wraps import InstalledAppResource
@ -14,6 +14,7 @@ from services.errors.message import MessageNotExistsError
from services.saved_message_service import SavedMessageService
register_schema_models(console_ns, SavedMessageListQuery, SavedMessageCreatePayload)
register_response_schema_models(console_ns, ResultResponse)
@console_ns.route("/installed-apps/<uuid:installed_app_id>/saved-messages", endpoint="installed_app_saved_messages")
@ -42,6 +43,7 @@ class SavedMessageListApi(InstalledAppResource):
).model_dump(mode="json")
@console_ns.expect(console_ns.models[SavedMessageCreatePayload.__name__])
@console_ns.response(200, "Success", console_ns.models[ResultResponse.__name__])
def post(self, installed_app):
current_user, _ = current_account_with_tenant()
app_model = installed_app.app
@ -62,6 +64,7 @@ class SavedMessageListApi(InstalledAppResource):
"/installed-apps/<uuid:installed_app_id>/saved-messages/<uuid:message_id>", endpoint="installed_app_saved_message"
)
class SavedMessageApi(InstalledAppResource):
@console_ns.response(204, "Saved message deleted successfully")
def delete(self, installed_app, message_id):
current_user, _ = current_account_with_tenant()
app_model = installed_app.app

View File

@ -3,7 +3,8 @@ import logging
from werkzeug.exceptions import InternalServerError
from controllers.common.controller_schemas import WorkflowRunPayload
from controllers.common.schema import register_schema_model
from controllers.common.fields import SimpleResultResponse
from controllers.common.schema import register_response_schema_models, register_schema_model
from controllers.console.app.error import (
CompletionRequestError,
ProviderModelCurrentlyNotSupportError,
@ -34,6 +35,7 @@ from .. import console_ns
logger = logging.getLogger(__name__)
register_schema_model(console_ns, WorkflowRunPayload)
register_response_schema_models(console_ns, SimpleResultResponse)
@console_ns.route("/installed-apps/<uuid:installed_app_id>/workflows/run")
@ -78,6 +80,7 @@ class InstalledAppWorkflowRunApi(InstalledAppResource):
@console_ns.route("/installed-apps/<uuid:installed_app_id>/workflows/tasks/<string:task_id>/stop")
class InstalledAppWorkflowTaskStopApi(InstalledAppResource):
@console_ns.response(200, "Success", console_ns.models[SimpleResultResponse.__name__])
def post(self, installed_app: InstalledApp, task_id: str):
"""
Stop workflow task

View File

@ -70,6 +70,21 @@ def _serialize_api_based_extension(extension: APIBasedExtension) -> dict[str, An
return APIBasedExtensionResponse.model_validate(extension, from_attributes=True).model_dump(mode="json")
def _serialize_saved_api_based_extension(extension: APIBasedExtension, api_key: str) -> dict[str, Any]:
"""Serialize a saved extension with the plaintext key used for response masking only.
APIBasedExtensionService.save mutates the ORM object to hold the encrypted token before returning it. The response
contract, however, should match list/detail responses, where api_key is masked from the decrypted token.
"""
return APIBasedExtensionResponse(
id=extension.id,
name=extension.name,
api_endpoint=extension.api_endpoint,
api_key=api_key,
created_at=to_timestamp(extension.created_at),
).model_dump(mode="json")
@console_ns.route("/code-based-extension")
class CodeBasedExtensionAPI(Resource):
@console_ns.doc("get_code_based_extension")
@ -125,7 +140,7 @@ class APIBasedExtensionAPI(Resource):
api_key=payload.api_key,
)
return _serialize_api_based_extension(APIBasedExtensionService.save(extension_data))
return _serialize_saved_api_based_extension(APIBasedExtensionService.save(extension_data), payload.api_key), 201
@console_ns.route("/api-based-extension/<uuid:id>")
@ -160,14 +175,19 @@ class APIBasedExtensionDetailAPI(Resource):
extension_data_from_db = APIBasedExtensionService.get_with_tenant_id(current_tenant_id, api_based_extension_id)
payload = APIBasedExtensionPayload.model_validate(console_ns.payload or {})
api_key_for_response = extension_data_from_db.api_key
extension_data_from_db.name = payload.name
extension_data_from_db.api_endpoint = payload.api_endpoint
if payload.api_key != HIDDEN_VALUE:
extension_data_from_db.api_key = payload.api_key
api_key_for_response = payload.api_key
return _serialize_api_based_extension(APIBasedExtensionService.save(extension_data_from_db))
return _serialize_saved_api_based_extension(
APIBasedExtensionService.save(extension_data_from_db),
api_key_for_response,
)
@console_ns.doc("delete_api_based_extension")
@console_ns.doc(description="Delete API-based extension")

View File

@ -1,12 +1,15 @@
from flask_restx import Resource, fields
from flask_restx import Resource
from werkzeug.exceptions import Unauthorized
from controllers.common.schema import register_response_schema_models
from libs.login import current_account_with_tenant, current_user, login_required
from services.feature_service import FeatureService
from services.feature_service import FeatureModel, FeatureService, SystemFeatureModel
from . import console_ns
from .wraps import account_initialization_required, cloud_utm_record, setup_required
register_response_schema_models(console_ns, FeatureModel, SystemFeatureModel)
@console_ns.route("/features")
class FeatureApi(Resource):
@ -15,7 +18,7 @@ class FeatureApi(Resource):
@console_ns.response(
200,
"Success",
console_ns.model("FeatureResponse", {"features": fields.Raw(description="Feature configuration object")}),
console_ns.models[FeatureModel.__name__],
)
@setup_required
@login_required
@ -35,9 +38,7 @@ class SystemFeatureApi(Resource):
@console_ns.response(
200,
"Success",
console_ns.model(
"SystemFeatureResponse", {"features": fields.Raw(description="System feature configuration object")}
),
console_ns.models[SystemFeatureModel.__name__],
)
def get(self):
"""Get system-wide feature configuration

View File

@ -15,7 +15,8 @@ from controllers.common.errors import (
TooManyFilesError,
UnsupportedFileTypeError,
)
from controllers.common.schema import register_schema_models
from controllers.common.fields import AllowedExtensionsResponse, TextContentResponse
from controllers.common.schema import register_response_schema_models, register_schema_models
from controllers.console.wraps import (
account_initialization_required,
cloud_edition_billing_resource_check,
@ -29,6 +30,7 @@ from services.file_service import FileService
from . import console_ns
register_schema_models(console_ns, UploadConfig, FileResponse)
register_response_schema_models(console_ns, AllowedExtensionsResponse, TextContentResponse)
PREVIEW_WORDS_LIMIT = 3000
@ -103,6 +105,7 @@ class FilePreviewApi(Resource):
@setup_required
@login_required
@account_initialization_required
@console_ns.response(200, "Success", console_ns.models[TextContentResponse.__name__])
def get(self, file_id):
file_id = str(file_id)
_, tenant_id = current_account_with_tenant()
@ -115,5 +118,6 @@ class FileSupportTypeApi(Resource):
@setup_required
@login_required
@account_initialization_required
@console_ns.response(200, "Success", console_ns.models[AllowedExtensionsResponse.__name__])
def get(self):
return {"allowed_extensions": list(DOCUMENT_EXTENSIONS)}

View File

@ -5,6 +5,8 @@ from flask import request
from flask_restx import Resource
from pydantic import BaseModel, Field
from controllers.common.fields import SimpleResultResponse
from controllers.common.schema import register_response_schema_models
from controllers.console import console_ns
from controllers.console.wraps import account_initialization_required, only_edition_cloud, setup_required
from libs.login import current_account_with_tenant, login_required
@ -48,6 +50,9 @@ class DismissNotificationPayload(BaseModel):
notification_id: str = Field(...)
register_response_schema_models(console_ns, SimpleResultResponse)
@console_ns.route("/notification")
class NotificationApi(Resource):
@console_ns.doc("get_notification")
@ -110,6 +115,7 @@ class NotificationDismissApi(Resource):
@login_required
@account_initialization_required
@only_edition_cloud
@console_ns.response(200, "Success", console_ns.models[SimpleResultResponse.__name__])
def post(self):
current_user, _ = current_account_with_tenant()
payload = DismissNotificationPayload.model_validate(request.get_json())

View File

@ -11,6 +11,7 @@ from controllers.common.errors import (
RemoteFileUploadError,
UnsupportedFileTypeError,
)
from controllers.common.schema import register_response_schema_models, register_schema_models
from controllers.console import console_ns
from core.helper import ssrf_proxy
from extensions.ext_database import db
@ -24,8 +25,13 @@ class RemoteFileUploadPayload(BaseModel):
url: str = Field(..., description="URL to fetch")
register_schema_models(console_ns, RemoteFileUploadPayload)
register_response_schema_models(console_ns, FileWithSignedUrl, RemoteFileInfo)
@console_ns.route("/remote-files/<path:url>")
class GetRemoteFileInfo(Resource):
@console_ns.response(200, "Success", console_ns.models[RemoteFileInfo.__name__])
@login_required
def get(self, url: str):
decoded_url = urllib.parse.unquote(url)
@ -41,6 +47,8 @@ class GetRemoteFileInfo(Resource):
@console_ns.route("/remote-files/upload")
class RemoteFileUpload(Resource):
@console_ns.expect(console_ns.models[RemoteFileUploadPayload.__name__])
@console_ns.response(201, "File uploaded successfully", console_ns.models[FileWithSignedUrl.__name__])
@login_required
def post(self):
payload = RemoteFileUploadPayload.model_validate(console_ns.payload)

View File

@ -5,7 +5,8 @@ from flask_restx import Resource
from pydantic import BaseModel, Field, field_validator
from werkzeug.exceptions import Forbidden
from controllers.common.schema import register_schema_models
from controllers.common.fields import SimpleResultResponse
from controllers.common.schema import register_response_schema_models, register_schema_models
from controllers.console import console_ns
from controllers.console.wraps import account_initialization_required, edit_permission_required, setup_required
from fields.base import ResponseModel
@ -78,6 +79,7 @@ register_schema_models(
TagListQueryParam,
TagResponse,
)
register_response_schema_models(console_ns, SimpleResultResponse)
@console_ns.route("/tags")
@ -102,6 +104,7 @@ class TagListApi(Resource):
return serialized_tags, 200
@console_ns.expect(console_ns.models[TagBasePayload.__name__])
@console_ns.response(200, "Success", console_ns.models[TagResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@ -124,6 +127,7 @@ class TagListApi(Resource):
@console_ns.route("/tags/<uuid:tag_id>")
class TagUpdateDeleteApi(Resource):
@console_ns.expect(console_ns.models[TagUpdateRequestPayload.__name__])
@console_ns.response(200, "Success", console_ns.models[TagResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@ -149,6 +153,7 @@ class TagUpdateDeleteApi(Resource):
@login_required
@account_initialization_required
@edit_permission_required
@console_ns.response(204, "Tag deleted successfully")
def delete(self, tag_id):
tag_id = str(tag_id)
@ -203,6 +208,7 @@ class TagBindingCollectionApi(Resource):
@console_ns.doc("create_tag_binding")
@console_ns.expect(console_ns.models[TagBindingPayload.__name__])
@console_ns.response(200, "Success", console_ns.models[SimpleResultResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@ -217,6 +223,7 @@ class TagBindingRemoveApi(Resource):
@console_ns.doc("remove_tag_bindings")
@console_ns.doc(description="Remove one or more tag bindings from a target.")
@console_ns.expect(console_ns.models[TagBindingRemovePayload.__name__])
@console_ns.response(200, "Success", console_ns.models[SimpleResultResponse.__name__])
@setup_required
@login_required
@account_initialization_required

View File

@ -12,7 +12,13 @@ from werkzeug.exceptions import NotFound
from configs import dify_config
from constants.languages import supported_language
from controllers.common.schema import register_schema_models
from controllers.common.fields import (
AvatarUrlResponse,
SimpleResultDataResponse,
SimpleResultResponse,
VerificationTokenResponse,
)
from controllers.common.schema import register_response_schema_models, register_schema_models
from controllers.console import console_ns
from controllers.console.auth.error import (
EmailAlreadyInUseError,
@ -50,6 +56,12 @@ from models.enums import CreatorUserRole
from models.model import UploadFile
from services.account_service import AccountService
from services.billing_service import BillingService
from services.entities.auth_entities import (
ChangeEmailNewEmailToken,
ChangeEmailNewEmailVerifiedToken,
ChangeEmailOldEmailToken,
ChangeEmailOldEmailVerifiedToken,
)
from services.errors.account import CurrentPasswordIncorrectError as ServiceCurrentPasswordIncorrectError
@ -231,11 +243,19 @@ register_schema_models(
EducationStatusResponse,
EducationAutocompleteResponse,
)
register_response_schema_models(
console_ns,
AvatarUrlResponse,
SimpleResultDataResponse,
SimpleResultResponse,
VerificationTokenResponse,
)
@console_ns.route("/account/init")
class AccountInitApi(Resource):
@console_ns.expect(console_ns.models[AccountInitPayload.__name__])
@console_ns.response(200, "Success", console_ns.models[SimpleResultResponse.__name__])
@setup_required
@login_required
def post(self):
@ -312,6 +332,7 @@ class AccountAvatarApi(Resource):
@console_ns.expect(console_ns.models[AccountAvatarQuery.__name__])
@console_ns.doc("get_account_avatar")
@console_ns.doc(description="Get account avatar url")
@console_ns.response(200, "Success", console_ns.models[AvatarUrlResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@ -473,6 +494,7 @@ class AccountDeleteVerifyApi(Resource):
@setup_required
@login_required
@account_initialization_required
@console_ns.response(200, "Success", console_ns.models[SimpleResultDataResponse.__name__])
def get(self):
account, _ = current_account_with_tenant()
@ -485,6 +507,7 @@ class AccountDeleteVerifyApi(Resource):
@console_ns.route("/account/delete")
class AccountDeleteApi(Resource):
@console_ns.expect(console_ns.models[AccountDeletePayload.__name__])
@console_ns.response(200, "Success", console_ns.models[SimpleResultResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@ -505,6 +528,7 @@ class AccountDeleteApi(Resource):
@console_ns.route("/account/delete/feedback")
class AccountDeleteUpdateFeedbackApi(Resource):
@console_ns.expect(console_ns.models[AccountDeletionFeedbackPayload.__name__])
@console_ns.response(200, "Success", console_ns.models[SimpleResultResponse.__name__])
@setup_required
def post(self):
payload = console_ns.payload or {}
@ -584,6 +608,7 @@ class EducationAutoCompleteApi(Resource):
@console_ns.route("/account/change-email")
class ChangeEmailSendEmailApi(Resource):
@console_ns.expect(console_ns.models[ChangeEmailSendPayload.__name__])
@console_ns.response(200, "Success", console_ns.models[SimpleResultDataResponse.__name__])
@enable_change_email
@setup_required
@login_required
@ -601,8 +626,8 @@ class ChangeEmailSendEmailApi(Resource):
language = "zh-Hans"
else:
language = "en-US"
account = None
user_email = None
account = current_user
user_email = current_user.email
email_for_sending = args.email.lower()
# Default to the initial phase; any legacy/unexpected client input is
# coerced back to `old_email` so we never trust the caller to declare
@ -617,24 +642,18 @@ class ChangeEmailSendEmailApi(Resource):
if reset_data is None:
raise InvalidTokenError()
# The token used to request a new-email code must come from the
# old-email verification step. This prevents the bypass described
# in GHSA-4q3w-q5mc-45rq where the phase-1 token was reused here.
token_phase = reset_data.get(AccountService.CHANGE_EMAIL_TOKEN_PHASE_KEY)
if token_phase != AccountService.CHANGE_EMAIL_PHASE_OLD_VERIFIED:
if not isinstance(reset_data, ChangeEmailOldEmailVerifiedToken):
raise InvalidTokenError()
user_email = reset_data.get("email", "")
if not reset_data.is_bound_to_account(current_user.id):
raise InvalidTokenError()
user_email = reset_data.email
if user_email.lower() != current_user.email.lower():
raise InvalidEmailError()
user_email = current_user.email
else:
account = AccountService.get_account_by_email_with_case_fallback(args.email)
if account is None:
raise AccountNotFound()
email_for_sending = account.email
user_email = account.email
if email_for_sending != current_user.email.lower():
raise InvalidEmailError()
email_for_sending = current_user.email
token = AccountService.send_change_email_email(
account=account,
@ -649,11 +668,13 @@ class ChangeEmailSendEmailApi(Resource):
@console_ns.route("/account/change-email/validity")
class ChangeEmailCheckApi(Resource):
@console_ns.expect(console_ns.models[ChangeEmailValidityPayload.__name__])
@console_ns.response(200, "Success", console_ns.models[VerificationTokenResponse.__name__])
@enable_change_email
@setup_required
@login_required
@account_initialization_required
def post(self):
current_user, _ = current_account_with_tenant()
payload = console_ns.payload or {}
args = ChangeEmailValidityPayload.model_validate(payload)
@ -666,42 +687,26 @@ class ChangeEmailCheckApi(Resource):
token_data = AccountService.get_change_email_data(args.token)
if token_data is None:
raise InvalidTokenError()
if not token_data.is_bound_to_account(current_user.id):
raise InvalidTokenError()
token_email = token_data.get("email")
normalized_token_email = token_email.lower() if isinstance(token_email, str) else token_email
normalized_token_email = token_data.email.lower()
if user_email != normalized_token_email:
raise InvalidEmailError()
if args.code != token_data.get("code"):
if args.code != token_data.code:
AccountService.add_change_email_error_rate_limit(user_email)
raise EmailCodeError()
# Only advance tokens that were minted by the matching send-code step;
# refuse tokens that have already progressed or lack a phase marker so
# the chain `old_email -> old_email_verified -> new_email -> new_email_verified`
# is strictly enforced.
phase_transitions = {
AccountService.CHANGE_EMAIL_PHASE_OLD: AccountService.CHANGE_EMAIL_PHASE_OLD_VERIFIED,
AccountService.CHANGE_EMAIL_PHASE_NEW: AccountService.CHANGE_EMAIL_PHASE_NEW_VERIFIED,
}
token_phase = token_data.get(AccountService.CHANGE_EMAIL_TOKEN_PHASE_KEY)
if not isinstance(token_phase, str):
raise InvalidTokenError()
refreshed_phase = phase_transitions.get(token_phase)
if refreshed_phase is None:
if isinstance(token_data, ChangeEmailOldEmailToken | ChangeEmailNewEmailToken):
refreshed_token_data = token_data.promote()
else:
raise InvalidTokenError()
# Verified, revoke the first token
AccountService.revoke_change_email_token(args.token)
# Refresh token data by generating a new token that carries the
# upgraded phase so later steps can check it.
_, new_token = AccountService.generate_change_email_token(
user_email,
code=args.code,
old_email=token_data.get("old_email"),
additional_data={AccountService.CHANGE_EMAIL_TOKEN_PHASE_KEY: refreshed_phase},
)
new_token = AccountService.generate_change_email_token(refreshed_token_data, current_user)
AccountService.reset_change_email_error_rate_limit(user_email)
return {"is_valid": True, "email": normalized_token_email, "token": new_token}
@ -726,27 +731,22 @@ class ChangeEmailResetApi(Resource):
if not AccountService.check_email_unique(normalized_new_email):
raise EmailAlreadyInUseError()
current_user, _ = current_account_with_tenant()
reset_data = AccountService.get_change_email_data(args.token)
if not reset_data:
raise InvalidTokenError()
if not reset_data.is_bound_to_account(current_user.id):
raise InvalidTokenError()
# Only tokens that completed both verification phases may be used to
# change the email. This closes GHSA-4q3w-q5mc-45rq where a token from
# the initial send-code step could be replayed directly here.
token_phase = reset_data.get(AccountService.CHANGE_EMAIL_TOKEN_PHASE_KEY)
if token_phase != AccountService.CHANGE_EMAIL_PHASE_NEW_VERIFIED:
if not isinstance(reset_data, ChangeEmailNewEmailVerifiedToken):
raise InvalidTokenError()
# Bind the new email to the token that was mailed and verified, so a
# verified token cannot be reused with a different `new_email` value.
token_email = reset_data.get("email")
normalized_token_email = token_email.lower() if isinstance(token_email, str) else token_email
if normalized_token_email != normalized_new_email:
if reset_data.email.lower() != normalized_new_email:
raise InvalidTokenError()
old_email = reset_data.get("old_email", "")
current_user, _ = current_account_with_tenant()
if current_user.email.lower() != old_email.lower():
if current_user.email.lower() != reset_data.old_email.lower():
raise AccountNotFound()
# Revoke only after all checks pass so failed attempts don't burn a
@ -765,6 +765,7 @@ class ChangeEmailResetApi(Resource):
@console_ns.route("/account/change-email/check-email-unique")
class CheckEmailUnique(Resource):
@console_ns.expect(console_ns.models[CheckEmailUniquePayload.__name__])
@console_ns.response(200, "Success", console_ns.models[SimpleResultResponse.__name__])
@setup_required
def post(self):
payload = console_ns.payload or {}

View File

@ -6,7 +6,8 @@ from pydantic import BaseModel, Field, TypeAdapter
import services
from configs import dify_config
from controllers.common.schema import register_enum_models, register_schema_models
from controllers.common.fields import SimpleResultDataResponse, VerificationTokenResponse
from controllers.common.schema import register_enum_models, register_response_schema_models, register_schema_models
from controllers.console import console_ns
from controllers.console.auth.error import (
CannotTransferOwnerToSelfError,
@ -68,6 +69,7 @@ register_schema_models(
OwnerTransferCheckPayload,
OwnerTransferPayload,
)
register_response_schema_models(console_ns, SimpleResultDataResponse, VerificationTokenResponse)
def _is_role_enabled(role: TenantAccountRole | str, tenant_id: str) -> bool:
@ -262,6 +264,7 @@ class SendOwnerTransferEmailApi(Resource):
"""Send owner transfer email."""
@console_ns.expect(console_ns.models[OwnerTransferEmailPayload.__name__])
@console_ns.response(200, "Success", console_ns.models[SimpleResultDataResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@ -299,6 +302,7 @@ class SendOwnerTransferEmailApi(Resource):
@console_ns.route("/workspaces/current/members/owner-transfer-check")
class OwnerTransferCheckApi(Resource):
@console_ns.expect(console_ns.models[OwnerTransferCheckPayload.__name__])
@console_ns.response(200, "Success", console_ns.models[VerificationTokenResponse.__name__])
@setup_required
@login_required
@account_initialization_required

View File

@ -5,7 +5,8 @@ from flask import request, send_file
from flask_restx import Resource
from pydantic import BaseModel, Field, field_validator
from controllers.common.schema import register_schema_models
from controllers.common.fields import SimpleResultResponse
from controllers.common.schema import register_response_schema_models, register_schema_models
from controllers.console import console_ns
from controllers.console.wraps import account_initialization_required, is_admin_or_owner_required, setup_required
from graphon.model_runtime.entities.model_entities import ModelType
@ -85,6 +86,7 @@ register_schema_models(
ParserCredentialValidate,
ParserPreferredProviderType,
)
register_response_schema_models(console_ns, SimpleResultResponse)
@console_ns.route("/workspaces/current/model-providers")
@ -177,6 +179,7 @@ class ModelProviderCredentialApi(Resource):
return {"result": "success"}
@console_ns.expect(console_ns.models[ParserCredentialDelete.__name__])
@console_ns.response(204, "Credential deleted successfully")
@setup_required
@login_required
@is_admin_or_owner_required
@ -197,6 +200,7 @@ class ModelProviderCredentialApi(Resource):
@console_ns.route("/workspaces/current/model-providers/<path:provider>/credentials/switch")
class ModelProviderCredentialSwitchApi(Resource):
@console_ns.expect(console_ns.models[ParserCredentialSwitch.__name__])
@console_ns.response(200, "Success", console_ns.models[SimpleResultResponse.__name__])
@setup_required
@login_required
@is_admin_or_owner_required
@ -271,6 +275,7 @@ class ModelProviderIconApi(Resource):
@console_ns.route("/workspaces/current/model-providers/<path:provider>/preferred-provider-type")
class PreferredProviderTypeUpdateApi(Resource):
@console_ns.expect(console_ns.models[ParserPreferredProviderType.__name__])
@console_ns.response(200, "Success", console_ns.models[SimpleResultResponse.__name__])
@setup_required
@login_required
@is_admin_or_owner_required

View File

@ -5,7 +5,8 @@ from flask import request
from flask_restx import Resource
from pydantic import BaseModel, Field, field_validator
from controllers.common.schema import register_enum_models, register_schema_models
from controllers.common.fields import SimpleResultResponse
from controllers.common.schema import register_enum_models, register_response_schema_models, register_schema_models
from controllers.console import console_ns
from controllers.console.wraps import account_initialization_required, is_admin_or_owner_required, setup_required
from graphon.model_runtime.entities.model_entities import ModelType
@ -126,6 +127,7 @@ register_schema_models(
Inner,
ParserSwitch,
)
register_response_schema_models(console_ns, SimpleResultResponse)
register_enum_models(console_ns, ModelType)
@ -149,6 +151,7 @@ class DefaultModelApi(Resource):
return jsonable_encoder({"data": default_model_entity})
@console_ns.expect(console_ns.models[ParserPostDefault.__name__])
@console_ns.response(200, "Success", console_ns.models[SimpleResultResponse.__name__])
@setup_required
@login_required
@is_admin_or_owner_required
@ -241,6 +244,7 @@ class ModelProviderModelApi(Resource):
return {"result": "success"}, 200
@console_ns.expect(console_ns.models[ParserDeleteModels.__name__])
@console_ns.response(204, "Model deleted successfully")
@setup_required
@login_required
@is_admin_or_owner_required
@ -373,6 +377,7 @@ class ModelProviderModelCredentialApi(Resource):
return {"result": "success"}
@console_ns.expect(console_ns.models[ParserDeleteCredential.__name__])
@console_ns.response(204, "Credential deleted successfully")
@setup_required
@login_required
@is_admin_or_owner_required
@ -396,6 +401,7 @@ class ModelProviderModelCredentialApi(Resource):
@console_ns.route("/workspaces/current/model-providers/<path:provider>/models/credentials/switch")
class ModelProviderModelCredentialSwitchApi(Resource):
@console_ns.expect(console_ns.models[ParserSwitch.__name__])
@console_ns.response(200, "Success", console_ns.models[SimpleResultResponse.__name__])
@setup_required
@login_required
@is_admin_or_owner_required
@ -420,6 +426,7 @@ class ModelProviderModelCredentialSwitchApi(Resource):
)
class ModelProviderModelEnableApi(Resource):
@console_ns.expect(console_ns.models[ParserDeleteModels.__name__])
@console_ns.response(200, "Success", console_ns.models[SimpleResultResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@ -441,6 +448,7 @@ class ModelProviderModelEnableApi(Resource):
)
class ModelProviderModelDisableApi(Resource):
@console_ns.expect(console_ns.models[ParserDeleteModels.__name__])
@console_ns.response(200, "Success", console_ns.models[SimpleResultResponse.__name__])
@setup_required
@login_required
@account_initialization_required

View File

@ -9,11 +9,13 @@ from werkzeug.datastructures import FileStorage
from werkzeug.exceptions import Forbidden
from configs import dify_config
from controllers.common.schema import register_enum_models, register_schema_models
from controllers.common.fields import SuccessResponse
from controllers.common.schema import register_enum_models, register_response_schema_models, register_schema_models
from controllers.console import console_ns
from controllers.console.workspace import plugin_permission_required
from controllers.console.wraps import account_initialization_required, is_admin_or_owner_required, setup_required
from core.plugin.impl.exc import PluginDaemonClientSideError
from fields.base import ResponseModel
from graphon.model_runtime.utils.encoders import jsonable_encoder
from libs.login import current_account_with_tenant, login_required
from models.account import TenantPluginAutoUpgradeStrategy, TenantPluginPermission
@ -137,6 +139,12 @@ class ParserReadme(BaseModel):
language: str = Field(default="en-US")
class PluginDebuggingKeyResponse(ResponseModel):
key: str
host: str
port: int
register_schema_models(
console_ns,
ParserList,
@ -160,6 +168,7 @@ register_schema_models(
ParserExcludePlugin,
ParserReadme,
)
register_response_schema_models(console_ns, PluginDebuggingKeyResponse, SuccessResponse)
register_enum_models(
console_ns,
@ -186,6 +195,7 @@ def _read_upload_content(file: FileStorage, max_size: int) -> bytes:
@console_ns.route("/workspaces/current/plugin/debugging-key")
class PluginDebuggingKeyApi(Resource):
@console_ns.response(200, "Success", console_ns.models[PluginDebuggingKeyResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@ -491,6 +501,7 @@ class PluginFetchInstallTaskApi(Resource):
@console_ns.route("/workspaces/current/plugin/tasks/<task_id>/delete")
class PluginDeleteInstallTaskApi(Resource):
@console_ns.response(200, "Success", console_ns.models[SuccessResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@ -506,6 +517,7 @@ class PluginDeleteInstallTaskApi(Resource):
@console_ns.route("/workspaces/current/plugin/tasks/delete_all")
class PluginDeleteAllInstallTaskItemsApi(Resource):
@console_ns.response(200, "Success", console_ns.models[SuccessResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@ -521,6 +533,7 @@ class PluginDeleteAllInstallTaskItemsApi(Resource):
@console_ns.route("/workspaces/current/plugin/tasks/<task_id>/delete/<path:identifier>")
class PluginDeleteInstallTaskItemApi(Resource):
@console_ns.response(200, "Success", console_ns.models[SuccessResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@ -586,6 +599,7 @@ class PluginUpgradeFromGithubApi(Resource):
@console_ns.route("/workspaces/current/plugin/uninstall")
class PluginUninstallApi(Resource):
@console_ns.expect(console_ns.models[ParserUninstall.__name__])
@console_ns.response(200, "Success", console_ns.models[SuccessResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@ -604,6 +618,7 @@ class PluginUninstallApi(Resource):
@console_ns.route("/workspaces/current/plugin/permission/change")
class PluginChangePermissionApi(Resource):
@console_ns.expect(console_ns.models[ParserPermissionChange.__name__])
@console_ns.response(200, "Success", console_ns.models[SuccessResponse.__name__])
@setup_required
@login_required
@account_initialization_required

View File

@ -10,7 +10,8 @@ from sqlalchemy.orm import sessionmaker
from werkzeug.exceptions import Forbidden
from configs import dify_config
from controllers.common.schema import register_schema_models
from controllers.common.fields import SimpleResultResponse
from controllers.common.schema import register_response_schema_models, register_schema_models
from controllers.console import console_ns
from controllers.console.wraps import (
account_initialization_required,
@ -252,6 +253,7 @@ register_schema_models(
MCPProviderDeletePayload,
MCPAuthPayload,
)
register_response_schema_models(console_ns, SimpleResultResponse)
@console_ns.route("/workspaces/current/tool-providers")
@ -1055,6 +1057,7 @@ class ToolProviderMCPApi(Resource):
return {"result": "success"}
@console_ns.expect(console_ns.models[MCPProviderDeletePayload.__name__])
@console_ns.response(200, "Success", console_ns.models[SimpleResultResponse.__name__])
@setup_required
@login_required
@account_initialization_required

View File

@ -8,7 +8,8 @@ from sqlalchemy.orm import sessionmaker
from werkzeug.exceptions import BadRequest, Forbidden
from configs import dify_config
from controllers.common.schema import register_schema_models
from controllers.common.fields import SimpleResultResponse
from controllers.common.schema import register_response_schema_models, register_schema_models
from controllers.web.error import NotFoundError
from core.plugin.entities.plugin_daemon import CredentialType
from core.plugin.impl.oauth import OAuthHandler
@ -68,6 +69,7 @@ register_schema_models(
TriggerSubscriptionBuilderUpdatePayload,
TriggerOAuthClientPayload,
)
register_response_schema_models(console_ns, SimpleResultResponse)
@console_ns.route("/workspaces/current/trigger-provider/<path:provider>/icon")
@ -365,6 +367,7 @@ class TriggerSubscriptionUpdateApi(Resource):
"/workspaces/current/trigger-provider/<path:subscription_id>/subscriptions/delete",
)
class TriggerSubscriptionDeleteApi(Resource):
@console_ns.response(200, "Success", console_ns.models[SimpleResultResponse.__name__])
@setup_required
@login_required
@is_admin_or_owner_required

View File

@ -16,7 +16,7 @@ from controllers.common.errors import (
TooManyFilesError,
UnsupportedFileTypeError,
)
from controllers.common.schema import register_schema_models
from controllers.common.schema import register_response_schema_models, register_schema_models
from controllers.console import console_ns
from controllers.console.admin import admin_required
from controllers.console.error import AccountNotLinkTenantError
@ -89,6 +89,12 @@ class TenantInfoResponse(ResponseModel):
return to_timestamp(value)
class WorkspacePermissionResponse(ResponseModel):
workspace_id: str
allow_member_invite: bool
allow_owner_transfer: bool
register_schema_models(
console_ns,
WorkspaceListQuery,
@ -97,6 +103,7 @@ register_schema_models(
WorkspaceInfoPayload,
TenantInfoResponse,
)
register_response_schema_models(console_ns, WorkspacePermissionResponse)
provider_fields = {
"provider_name": fields.String,
@ -357,6 +364,7 @@ class WorkspaceInfoApi(Resource):
class WorkspacePermissionApi(Resource):
"""Get workspace permissions for the current workspace."""
@console_ns.response(200, "Success", console_ns.models[WorkspacePermissionResponse.__name__])
@setup_required
@login_required
@account_initialization_required

View File

@ -3,14 +3,27 @@ from typing import Any, cast
from flask_restx import Resource
from controllers.common.fields import Parameters
from controllers.common.schema import register_response_schema_models
from controllers.service_api import service_api_ns
from controllers.service_api.app.error import AppUnavailableError
from controllers.service_api.wraps import validate_app_token
from core.app.app_config.common.parameters_mapping import get_parameters_from_feature_dict
from fields.base import ResponseModel
from models.model import App, AppMode
from services.app_service import AppService
class AppInfoResponse(ResponseModel):
name: str
description: str | None
tags: list[str]
mode: str
author_name: str | None
register_response_schema_models(service_api_ns, AppInfoResponse)
@service_api_ns.route("/parameters")
class AppParameterApi(Resource):
"""Resource for app variables."""
@ -81,6 +94,11 @@ class AppInfoApi(Resource):
404: "Application not found",
}
)
@service_api_ns.response(
200,
"Application info retrieved successfully",
service_api_ns.models[AppInfoResponse.__name__],
)
@validate_app_token
def get(self, app_model: App):
"""Get app information.

View File

@ -8,7 +8,8 @@ from pydantic import BaseModel, Field, field_validator
from werkzeug.exceptions import BadRequest, InternalServerError, NotFound
import services
from controllers.common.schema import register_schema_models
from controllers.common.fields import SimpleResultResponse
from controllers.common.schema import register_response_schema_models, register_schema_models
from controllers.service_api import service_api_ns
from controllers.service_api.app.error import (
AppUnavailableError,
@ -75,6 +76,7 @@ class ChatRequestPayload(BaseModel):
register_schema_models(service_api_ns, CompletionRequestPayload, ChatRequestPayload)
register_response_schema_models(service_api_ns, SimpleResultResponse)
@service_api_ns.route("/completion-messages")
@ -155,6 +157,7 @@ class CompletionStopApi(Resource):
404: "Task not found",
}
)
@service_api_ns.response(200, "Task stopped successfully", service_api_ns.models[SimpleResultResponse.__name__])
@validate_app_token(fetch_user_arg=FetchUserArg(fetch_from=WhereisUserArg.JSON, required=True))
def post(self, app_model: App, end_user: EndUser, task_id: str):
"""Stop a running completion task."""
@ -254,6 +257,7 @@ class ChatStopApi(Resource):
404: "Task not found",
}
)
@service_api_ns.response(200, "Task stopped successfully", service_api_ns.models[SimpleResultResponse.__name__])
@validate_app_token(fetch_user_arg=FetchUserArg(fetch_from=WhereisUserArg.JSON, required=True))
def post(self, app_model: App, end_user: EndUser, task_id: str):
"""Stop a running chat message generation."""

View File

@ -7,7 +7,8 @@ from werkzeug.exceptions import BadRequest, InternalServerError, NotFound
import services
from controllers.common.controller_schemas import MessageFeedbackPayload, MessageListQuery
from controllers.common.schema import register_schema_models
from controllers.common.fields import SimpleResultStringListResponse
from controllers.common.schema import register_response_schema_models, register_schema_models
from controllers.service_api import service_api_ns
from controllers.service_api.app.error import NotChatAppError
from controllers.service_api.wraps import FetchUserArg, WhereisUserArg, validate_app_token
@ -32,6 +33,7 @@ class FeedbackListQuery(BaseModel):
register_schema_models(service_api_ns, MessageListQuery, MessageFeedbackPayload, FeedbackListQuery)
register_response_schema_models(service_api_ns, ResultResponse, SimpleResultStringListResponse)
@service_api_ns.route("/messages")
@ -80,6 +82,7 @@ class MessageListApi(Resource):
@service_api_ns.route("/messages/<uuid:message_id>/feedbacks")
class MessageFeedbackApi(Resource):
@service_api_ns.expect(service_api_ns.models[MessageFeedbackPayload.__name__])
@service_api_ns.response(200, "Feedback submitted successfully", service_api_ns.models[ResultResponse.__name__])
@service_api_ns.doc("create_message_feedback")
@service_api_ns.doc(description="Submit feedback for a message")
@service_api_ns.doc(params={"message_id": "Message ID"})
@ -138,6 +141,11 @@ class AppGetFeedbacksApi(Resource):
@service_api_ns.route("/messages/<uuid:message_id>/suggested")
class MessageSuggestedApi(Resource):
@service_api_ns.response(
200,
"Suggested questions retrieved successfully",
service_api_ns.models[SimpleResultStringListResponse.__name__],
)
@service_api_ns.doc("get_suggested_questions")
@service_api_ns.doc(description="Get suggested follow-up questions for a message")
@service_api_ns.doc(params={"message_id": "Message ID"})

View File

@ -3,12 +3,15 @@ from sqlalchemy import select
from werkzeug.exceptions import Forbidden
from controllers.common.fields import Site as SiteResponse
from controllers.common.schema import register_response_schema_models
from controllers.service_api import service_api_ns
from controllers.service_api.wraps import validate_app_token
from extensions.ext_database import db
from models.account import TenantStatus
from models.model import App, Site
register_response_schema_models(service_api_ns, SiteResponse)
@service_api_ns.route("/site")
class AppSiteApi(Resource):
@ -23,6 +26,11 @@ class AppSiteApi(Resource):
403: "Forbidden - site not found or tenant archived",
}
)
@service_api_ns.response(
200,
"Site configuration retrieved successfully",
service_api_ns.models[SiteResponse.__name__],
)
@validate_app_token
def get(self, app_model: App):
"""Retrieve app site info.

View File

@ -11,7 +11,8 @@ from sqlalchemy.orm import sessionmaker
from werkzeug.exceptions import BadRequest, InternalServerError, NotFound
from controllers.common.controller_schemas import WorkflowRunPayload as WorkflowRunPayloadBase
from controllers.common.schema import register_schema_models
from controllers.common.fields import SimpleResultResponse
from controllers.common.schema import register_response_schema_models, register_schema_models
from controllers.service_api import service_api_ns
from controllers.service_api.app.error import (
CompletionRequestError,
@ -67,6 +68,7 @@ class WorkflowLogQuery(BaseModel):
register_schema_models(service_api_ns, WorkflowRunPayload, WorkflowLogQuery)
register_response_schema_models(service_api_ns, SimpleResultResponse)
def _enum_value(value):
@ -376,6 +378,7 @@ class WorkflowTaskStopApi(Resource):
404: "Task not found",
}
)
@service_api_ns.response(200, "Task stopped successfully", service_api_ns.models[SimpleResultResponse.__name__])
@validate_app_token(fetch_user_arg=FetchUserArg(fetch_from=WhereisUserArg.JSON, required=True))
def post(self, app_model: App, end_user: EndUser, task_id: str):
"""Stop a running workflow task."""

View File

@ -6,7 +6,8 @@ from pydantic import BaseModel, Field, TypeAdapter, field_validator, model_valid
from werkzeug.exceptions import Forbidden, NotFound
import services
from controllers.common.schema import register_enum_models, register_schema_models
from controllers.common.fields import SimpleResultResponse
from controllers.common.schema import register_enum_models, register_response_schema_models, register_schema_models
from controllers.console.wraps import edit_permission_required
from controllers.service_api import service_api_ns
from controllers.service_api.dataset.error import DatasetInUseError, DatasetNameDuplicateError, InvalidActionError
@ -138,6 +139,7 @@ register_schema_models(
DatasetListQuery,
DataSetTag,
)
register_response_schema_models(service_api_ns, SimpleResultResponse)
@service_api_ns.route("/datasets")
@ -434,6 +436,11 @@ class DatasetApi(DatasetApiResource):
class DocumentStatusApi(DatasetApiResource):
"""Resource for batch document status operations."""
@service_api_ns.response(
200,
"Document status updated successfully",
service_api_ns.models[SimpleResultResponse.__name__],
)
@service_api_ns.doc("update_document_status")
@service_api_ns.doc(description="Batch update document status")
@service_api_ns.doc(

View File

@ -26,7 +26,8 @@ from controllers.common.errors import (
TooManyFilesError,
UnsupportedFileTypeError,
)
from controllers.common.schema import register_enum_models, register_schema_models
from controllers.common.fields import UrlResponse
from controllers.common.schema import register_enum_models, register_response_schema_models, register_schema_models
from controllers.service_api import service_api_ns
from controllers.service_api.app.error import ProviderNotInitializeError
from controllers.service_api.dataset.error import (
@ -120,6 +121,7 @@ register_schema_models(
PreProcessingRule,
Segmentation,
)
register_response_schema_models(service_api_ns, UrlResponse)
def _create_document_by_text(tenant_id: str, dataset_id: UUID) -> tuple[Mapping[str, object], int]:
@ -749,6 +751,11 @@ class DocumentDownloadApi(DatasetApiResource):
404: "Document or upload file not found",
}
)
@service_api_ns.response(
200,
"Download URL generated successfully",
service_api_ns.models[UrlResponse.__name__],
)
@cloud_edition_billing_rate_limit_check("knowledge", "dataset")
def get(self, tenant_id, dataset_id, document_id):
dataset = self.get_dataset(str(dataset_id), str(tenant_id))

View File

@ -5,7 +5,8 @@ from flask_restx import marshal
from werkzeug.exceptions import NotFound
from controllers.common.controller_schemas import MetadataUpdatePayload
from controllers.common.schema import register_schema_model, register_schema_models
from controllers.common.fields import SimpleResultResponse
from controllers.common.schema import register_response_schema_models, register_schema_model, register_schema_models
from controllers.service_api import service_api_ns
from controllers.service_api.wraps import DatasetApiResource, cloud_edition_billing_rate_limit_check
from fields.dataset_fields import dataset_metadata_fields
@ -26,6 +27,7 @@ register_schema_models(
DocumentMetadataOperation,
MetadataOperationData,
)
register_response_schema_models(service_api_ns, SimpleResultResponse)
@service_api_ns.route("/datasets/<uuid:dataset_id>/metadata")
@ -154,6 +156,11 @@ class DatasetMetadataBuiltInFieldActionServiceApi(DatasetApiResource):
404: "Dataset not found",
}
)
@service_api_ns.response(
200,
"Action completed successfully",
service_api_ns.models[SimpleResultResponse.__name__],
)
@cloud_edition_billing_rate_limit_check("knowledge", "dataset")
def post(self, tenant_id, dataset_id, action: Literal["enable", "disable"]):
"""Enable or disable built-in metadata field."""
@ -184,6 +191,11 @@ class DocumentMetadataEditServiceApi(DatasetApiResource):
404: "Dataset not found",
}
)
@service_api_ns.response(
200,
"Documents metadata updated successfully",
service_api_ns.models[SimpleResultResponse.__name__],
)
@cloud_edition_billing_rate_limit_check("knowledge", "dataset")
def post(self, tenant_id, dataset_id):
"""Update metadata for multiple documents."""

View File

@ -2,6 +2,7 @@ from uuid import UUID
from flask_restx import Resource
from controllers.common.schema import register_response_schema_models
from controllers.service_api import service_api_ns
from controllers.service_api.end_user.error import EndUserNotFoundError
from controllers.service_api.wraps import validate_app_token
@ -9,6 +10,8 @@ from fields.end_user_fields import EndUserDetail
from models.model import App
from services.end_user_service import EndUserService
register_response_schema_models(service_api_ns, EndUserDetail)
@service_api_ns.route("/end-users/<uuid:end_user_id>")
class EndUserApi(Resource):
@ -24,6 +27,7 @@ class EndUserApi(Resource):
404: "End user not found",
},
)
@service_api_ns.response(200, "End user retrieved successfully", service_api_ns.models[EndUserDetail.__name__])
@validate_app_token
def get(self, app_model: App, end_user_id: UUID):
"""Get end user detail.

View File

@ -1,11 +1,16 @@
from flask_restx import Resource
from configs import dify_config
from controllers.common.fields import IndexInfoResponse
from controllers.common.schema import register_response_schema_models
from controllers.service_api import service_api_ns
register_response_schema_models(service_api_ns, IndexInfoResponse)
@service_api_ns.route("/")
class IndexApi(Resource):
@service_api_ns.response(200, "Success", service_api_ns.models[IndexInfoResponse.__name__])
def get(self):
return {
"welcome": "Dify OpenAPI",

View File

@ -8,7 +8,7 @@ from werkzeug.exceptions import Unauthorized
from constants import HEADER_NAME_APP_CODE
from controllers.common import fields
from controllers.common.schema import register_schema_models
from controllers.common.schema import register_response_schema_models, register_schema_models
from core.app.app_config.common.parameters_mapping import get_parameters_from_feature_dict
from libs.passport import PassportService
from libs.token import extract_webapp_passport
@ -33,6 +33,11 @@ class AppAccessModeQuery(BaseModel):
register_schema_models(web_ns, AppAccessModeQuery)
register_response_schema_models(
web_ns,
fields.AccessModeResponse,
fields.BooleanResultResponse,
)
@web_ns.route("/parameters")
@ -109,6 +114,7 @@ class AppAccessMode(Resource):
500: "Internal Server Error",
}
)
@web_ns.response(200, "Success", web_ns.models[fields.AccessModeResponse.__name__])
def get(self):
raw_args = request.args.to_dict()
args = AppAccessModeQuery.model_validate(raw_args)
@ -142,6 +148,7 @@ class AppWebAuthPermission(Resource):
500: "Internal Server Error",
}
)
@web_ns.response(200, "Success", web_ns.models[fields.BooleanResultResponse.__name__])
def get(self):
user_id = "visitor"
app_code = request.headers.get(HEADER_NAME_APP_CODE)

View File

@ -5,7 +5,8 @@ from pydantic import BaseModel, Field, field_validator
from werkzeug.exceptions import InternalServerError, NotFound
import services
from controllers.common.schema import register_schema_models
from controllers.common.fields import SimpleResultResponse
from controllers.common.schema import register_response_schema_models, register_schema_models
from controllers.web import web_ns
from controllers.web.error import (
AppUnavailableError,
@ -66,6 +67,7 @@ class ChatMessagePayload(BaseModel):
register_schema_models(web_ns, CompletionMessagePayload, ChatMessagePayload)
register_response_schema_models(web_ns, SimpleResultResponse)
# define completion api for user
@ -137,6 +139,7 @@ class CompletionStopApi(WebApiResource):
500: "Internal Server Error",
}
)
@web_ns.response(200, "Success", web_ns.models[SimpleResultResponse.__name__])
def post(self, app_model, end_user, task_id):
if app_model.mode != AppMode.COMPLETION:
raise NotCompletionAppError()
@ -222,6 +225,7 @@ class ChatStopApi(WebApiResource):
500: "Internal Server Error",
}
)
@web_ns.response(200, "Success", web_ns.models[SimpleResultResponse.__name__])
def post(self, app_model, end_user, task_id):
app_mode = AppMode.value_of(app_model.mode)
if app_mode not in {AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT}:

View File

@ -6,7 +6,7 @@ from sqlalchemy.orm import sessionmaker
from werkzeug.exceptions import NotFound
from controllers.common.controller_schemas import ConversationRenamePayload
from controllers.common.schema import register_schema_models
from controllers.common.schema import register_response_schema_models, register_schema_models
from controllers.web import web_ns
from controllers.web.error import NotChatAppError
from controllers.web.wraps import WebApiResource
@ -39,6 +39,7 @@ class ConversationListQuery(BaseModel):
register_schema_models(web_ns, ConversationListQuery, ConversationRenamePayload)
register_response_schema_models(web_ns, ResultResponse)
@web_ns.route("/conversations")
@ -201,6 +202,7 @@ class ConversationPinApi(WebApiResource):
500: "Internal Server Error",
}
)
@web_ns.response(200, "Conversation pinned successfully", web_ns.models[ResultResponse.__name__])
def patch(self, app_model, end_user, c_id):
app_mode = AppMode.value_of(app_model.mode)
if app_mode not in {AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT}:
@ -231,6 +233,7 @@ class ConversationUnPinApi(WebApiResource):
500: "Internal Server Error",
}
)
@web_ns.response(200, "Conversation unpinned successfully", web_ns.models[ResultResponse.__name__])
def patch(self, app_model, end_user, c_id):
app_mode = AppMode.value_of(app_model.mode)
if app_mode not in {AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT}:

View File

@ -1,7 +1,10 @@
from flask_restx import Resource
from controllers.common.schema import register_response_schema_models
from controllers.web import web_ns
from services.feature_service import FeatureService
from services.feature_service import FeatureService, SystemFeatureModel
register_response_schema_models(web_ns, SystemFeatureModel)
@web_ns.route("/system-features")
@ -9,6 +12,11 @@ class SystemFeatureApi(Resource):
@web_ns.doc("get_system_features")
@web_ns.doc(description="Get system feature flags and configuration")
@web_ns.doc(responses={200: "System features retrieved successfully", 500: "Internal server error"})
@web_ns.response(
200,
"System features retrieved successfully",
web_ns.models[SystemFeatureModel.__name__],
)
def get(self):
"""Get system feature flags and configuration.

View File

@ -4,7 +4,8 @@ import secrets
from flask import request
from flask_restx import Resource
from controllers.common.schema import register_schema_models
from controllers.common.fields import SimpleResultDataResponse, SimpleResultResponse, VerificationTokenResponse
from controllers.common.schema import register_response_schema_models, register_schema_models
from controllers.console.auth.error import (
AuthenticationFailedError,
EmailCodeError,
@ -28,6 +29,12 @@ from services.entities.auth_entities import (
)
register_schema_models(web_ns, ForgotPasswordSendPayload, ForgotPasswordCheckPayload, ForgotPasswordResetPayload)
register_response_schema_models(
web_ns,
SimpleResultDataResponse,
SimpleResultResponse,
VerificationTokenResponse,
)
@web_ns.route("/forgot-password")
@ -46,6 +53,7 @@ class ForgotPasswordSendEmailApi(Resource):
429: "Too many requests - rate limit exceeded",
}
)
@web_ns.response(200, "Password reset email sent successfully", web_ns.models[SimpleResultDataResponse.__name__])
def post(self):
payload = ForgotPasswordSendPayload.model_validate(web_ns.payload or {})
@ -81,6 +89,7 @@ class ForgotPasswordCheckApi(Resource):
@web_ns.doc(
responses={200: "Token is valid", 400: "Bad request - invalid token format", 401: "Invalid or expired token"}
)
@web_ns.response(200, "Token is valid", web_ns.models[VerificationTokenResponse.__name__])
def post(self):
payload = ForgotPasswordCheckPayload.model_validate(web_ns.payload or {})
@ -134,6 +143,7 @@ class ForgotPasswordResetApi(Resource):
404: "Account not found",
}
)
@web_ns.response(200, "Password reset successfully", web_ns.models[SimpleResultResponse.__name__])
def post(self):
payload = ForgotPasswordResetPayload.model_validate(web_ns.payload or {})

View File

@ -8,7 +8,13 @@ from werkzeug.exceptions import Unauthorized
import services
from configs import dify_config
from controllers.common.schema import register_schema_models
from controllers.common.fields import (
AccessTokenResultResponse,
LoginStatusResponse,
SimpleResultDataResponse,
SimpleResultResponse,
)
from controllers.common.schema import register_response_schema_models, register_schema_models
from controllers.console.auth.error import (
AuthenticationFailedError,
EmailCodeError,
@ -57,6 +63,13 @@ class EmailCodeLoginVerifyPayload(BaseModel):
register_schema_models(web_ns, LoginPayload, EmailCodeLoginSendPayload, EmailCodeLoginVerifyPayload)
register_response_schema_models(
web_ns,
AccessTokenResultResponse,
LoginStatusResponse,
SimpleResultDataResponse,
SimpleResultResponse,
)
@web_ns.route("/login")
@ -77,6 +90,7 @@ class LoginApi(Resource):
404: "Account not found",
}
)
@web_ns.response(200, "Authentication successful", web_ns.models[AccessTokenResultResponse.__name__])
@decrypt_password_field
def post(self):
"""Authenticate user and login."""
@ -114,6 +128,7 @@ class LoginStatusApi(Resource):
401: "Login status",
}
)
@web_ns.response(200, "Login status", web_ns.models[LoginStatusResponse.__name__])
def get(self):
app_code = request.args.get("app_code")
user_id = request.args.get("user_id")
@ -160,6 +175,7 @@ class LogoutApi(Resource):
200: "Logout successful",
}
)
@web_ns.response(200, "Logout successful", web_ns.models[SimpleResultResponse.__name__])
def post(self):
response = make_response({"result": "success"})
# enterprise SSO sets same site to None in https deployment
@ -182,6 +198,7 @@ class EmailCodeLoginSendEmailApi(Resource):
404: "Account not found",
}
)
@web_ns.response(200, "Email code sent successfully", web_ns.models[SimpleResultDataResponse.__name__])
def post(self):
payload = EmailCodeLoginSendPayload.model_validate(web_ns.payload or {})
@ -213,6 +230,11 @@ class EmailCodeLoginApi(Resource):
404: "Account not found",
}
)
@web_ns.response(
200,
"Email code verified and login successful",
web_ns.models[AccessTokenResultResponse.__name__],
)
@decrypt_code_field
def post(self):
payload = EmailCodeLoginVerifyPayload.model_validate(web_ns.payload or {})

View File

@ -6,7 +6,7 @@ from pydantic import BaseModel, Field, TypeAdapter
from werkzeug.exceptions import InternalServerError, NotFound
from controllers.common.controller_schemas import MessageFeedbackPayload, MessageListQuery
from controllers.common.schema import register_schema_models
from controllers.common.schema import register_response_schema_models, register_schema_models
from controllers.web import web_ns
from controllers.web.error import (
AppMoreLikeThisDisabledError,
@ -47,6 +47,7 @@ class MessageMoreLikeThisQuery(BaseModel):
register_schema_models(web_ns, MessageListQuery, MessageFeedbackPayload, MessageMoreLikeThisQuery)
register_response_schema_models(web_ns, ResultResponse, SuggestedQuestionsResponse)
@web_ns.route("/messages")
@ -130,6 +131,7 @@ class MessageFeedbackApi(WebApiResource):
500: "Internal Server Error",
}
)
@web_ns.response(200, "Feedback submitted successfully", web_ns.models[ResultResponse.__name__])
def post(self, app_model, end_user, message_id):
message_id = str(message_id)
@ -206,6 +208,7 @@ class MessageMoreLikeThisApi(WebApiResource):
@web_ns.route("/messages/<uuid:message_id>/suggested-questions")
class MessageSuggestedQuestionApi(WebApiResource):
@web_ns.response(200, "Success", web_ns.models[SuggestedQuestionsResponse.__name__])
@web_ns.doc("Get Suggested Questions")
@web_ns.doc(description="Get suggested follow-up questions after a message (chat apps only).")
@web_ns.doc(params={"message_id": {"description": "Message UUID", "type": "string", "required": True}})

View File

@ -16,7 +16,7 @@ from fields.file_fields import FileWithSignedUrl, RemoteFileInfo
from graphon.file import helpers as file_helpers
from services.file_service import FileService
from ..common.schema import register_schema_models
from ..common.schema import register_response_schema_models, register_schema_models
from . import web_ns
from .wraps import WebApiResource
@ -25,7 +25,8 @@ class RemoteFileUploadPayload(BaseModel):
url: HttpUrl = Field(description="Remote file URL")
register_schema_models(web_ns, RemoteFileUploadPayload, RemoteFileInfo, FileWithSignedUrl)
register_schema_models(web_ns, RemoteFileUploadPayload)
register_response_schema_models(web_ns, RemoteFileInfo, FileWithSignedUrl)
@web_ns.route("/remote-files/<path:url>")

View File

@ -3,7 +3,7 @@ from pydantic import TypeAdapter
from werkzeug.exceptions import NotFound
from controllers.common.controller_schemas import SavedMessageCreatePayload, SavedMessageListQuery
from controllers.common.schema import register_schema_models
from controllers.common.schema import register_response_schema_models, register_schema_models
from controllers.web import web_ns
from controllers.web.error import NotCompletionAppError
from controllers.web.wraps import WebApiResource
@ -13,6 +13,7 @@ from services.errors.message import MessageNotExistsError
from services.saved_message_service import SavedMessageService
register_schema_models(web_ns, SavedMessageListQuery, SavedMessageCreatePayload)
register_response_schema_models(web_ns, ResultResponse)
@web_ns.route("/saved-messages")
@ -73,6 +74,7 @@ class SavedMessageListApi(WebApiResource):
500: "Internal Server Error",
}
)
@web_ns.response(200, "Message saved successfully", web_ns.models[ResultResponse.__name__])
def post(self, app_model, end_user):
if app_model.mode != "completion":
raise NotCompletionAppError()

View File

@ -3,7 +3,8 @@ import logging
from werkzeug.exceptions import InternalServerError
from controllers.common.controller_schemas import WorkflowRunPayload
from controllers.common.schema import register_schema_models
from controllers.common.fields import SimpleResultResponse
from controllers.common.schema import register_response_schema_models, register_schema_models
from controllers.web import web_ns
from controllers.web.error import (
CompletionRequestError,
@ -32,6 +33,7 @@ from services.errors.llm import InvokeRateLimitError
logger = logging.getLogger(__name__)
register_schema_models(web_ns, WorkflowRunPayload)
register_response_schema_models(web_ns, SimpleResultResponse)
@web_ns.route("/workflows/run")
@ -102,6 +104,7 @@ class WorkflowTaskStopApi(WebApiResource):
500: "Internal Server Error",
}
)
@web_ns.response(200, "Success", web_ns.models[SimpleResultResponse.__name__])
def post(self, app_model: App, end_user: EndUser, task_id: str):
"""
Stop workflow task

View File

@ -791,10 +791,25 @@ class PipelineGenerator(BaseAppGenerator):
all_files: list,
datasource_info: Mapping[str, Any],
next_page_parameters: dict[str, Any] | None = None,
_visited_folder_ids: set[str] | None = None,
):
"""
Get files in a folder.
Recursively lists all files inside the given folder prefix.
``_visited_folder_ids`` tracks folders already expanded so that a
self-referencing folder (where the API returns the folder as its own
child) cannot cause infinite recursion.
"""
if _visited_folder_ids is None:
_visited_folder_ids = set()
# Guard: skip folders we have already expanded to prevent infinite
# recursion from self-referencing folder entries in the API response.
if prefix in _visited_folder_ids:
return
_visited_folder_ids.add(prefix)
result_generator = datasource_runtime.online_drive_browse_files(
user_id=user_id,
request=OnlineDriveBrowseFilesRequest(
@ -806,10 +821,14 @@ class PipelineGenerator(BaseAppGenerator):
provider_type=datasource_runtime.datasource_provider_type(),
)
is_truncated = False
has_files = False
for result in result_generator:
for files in result.result:
for file in files.files:
has_files = True
if file.type == "folder":
if file.id in _visited_folder_ids:
continue
self._get_files_in_folder(
datasource_runtime,
file.id,
@ -818,6 +837,7 @@ class PipelineGenerator(BaseAppGenerator):
all_files,
datasource_info,
None,
_visited_folder_ids,
)
else:
all_files.append(
@ -830,7 +850,17 @@ class PipelineGenerator(BaseAppGenerator):
is_truncated = files.is_truncated
next_page_parameters = files.next_page_parameters
if is_truncated:
# Guard: only follow pagination when the API actually returned files.
# An empty folder that incorrectly reports ``is_truncated=True`` would
# otherwise recurse forever on the same empty page.
if is_truncated and has_files:
self._get_files_in_folder(
datasource_runtime, prefix, bucket, user_id, all_files, datasource_info, next_page_parameters
datasource_runtime,
prefix,
bucket,
user_id,
all_files,
datasource_info,
next_page_parameters,
_visited_folder_ids,
)

View File

@ -235,10 +235,11 @@ class TokenBufferMemory:
if isinstance(m.content, list):
inner_msg = ""
for content in m.content:
if isinstance(content, TextPromptMessageContent):
inner_msg += f"{content.data}\n"
elif isinstance(content, ImagePromptMessageContent):
inner_msg += "[image]\n"
match content:
case TextPromptMessageContent():
inner_msg += f"{content.data}\n"
case ImagePromptMessageContent():
inner_msg += "[image]\n"
string_messages.append(f"{role}: {inner_msg.strip()}")
else:

View File

@ -79,12 +79,13 @@ class ToolLabelManager:
:return: list of tool labels (str)
"""
if isinstance(controller, ApiToolProviderController | WorkflowToolProviderController):
provider_id = controller.provider_id
elif isinstance(controller, BuiltinToolProviderController):
return controller.tool_labels
else:
raise ValueError("Unsupported tool type")
match controller:
case ApiToolProviderController() | WorkflowToolProviderController():
provider_id = controller.provider_id
case BuiltinToolProviderController():
return controller.tool_labels
case _:
raise ValueError("Unsupported tool type")
stmt = select(ToolLabelBinding.label_name).where(
ToolLabelBinding.tool_id == provider_id,
ToolLabelBinding.tool_type == controller.provider_type,

View File

@ -0,0 +1,67 @@
"""Dify event package.
The package name intentionally stays as ``events`` for existing Dify imports. Some
third-party clients also import ``Events`` from a top-level ``events`` package, so
we expose a small compatible implementation to avoid import shadowing failures.
"""
from collections.abc import Callable, Iterator
from typing import Any
class EventsError(Exception):
"""Raised for invalid event slot operations."""
EventsException = EventsError
class _EventSlot:
"""A dynamically-created event slot supporting ``+=`` and call dispatch."""
targets: list[Callable[..., Any]]
__name__: str
def __init__(self, name: str) -> None:
self.targets = []
self.__name__ = name
def __call__(self, *args: Any, **kwargs: Any) -> None:
for target in tuple(self.targets):
target(*args, **kwargs)
def __iadd__(self, target: Callable[..., Any]) -> "_EventSlot":
self.targets.append(target)
return self
def __isub__(self, target: Callable[..., Any]) -> "_EventSlot":
while target in self.targets:
self.targets.remove(target)
return self
def __iter__(self) -> Iterator[Callable[..., Any]]:
return iter(self.targets)
def __len__(self) -> int:
return len(self.targets)
class Events:
"""A minimal C#-style event container compatible with the external Events package."""
_slots: dict[str, _EventSlot]
def __init__(self, *event_names: str) -> None:
self._slots = {}
for event_name in event_names:
self._slots[event_name] = _EventSlot(event_name)
def __getattr__(self, name: str) -> _EventSlot:
if name.startswith("_"):
raise AttributeError(name)
slot = _EventSlot(name)
self._slots[name] = slot
return slot
__all__ = ["Events", "EventsError", "EventsException"]

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