Compare commits
179 Commits
fix/remove
...
hotfix/1.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
09a855dcef | ||
|
|
a6fe278662 | ||
|
|
960018253b | ||
|
|
8abff1b0b1 | ||
|
|
eb7a1829fe | ||
|
|
9ce92d8605 | ||
|
|
ddcdbb2d14 | ||
|
|
18d3990ae6 | ||
|
|
03e4bc50a2 | ||
|
|
370efc493c | ||
|
|
cf236aed8a | ||
|
|
48315be2b1 | ||
|
|
a9035a3264 | ||
|
|
39ce9f68fc | ||
|
|
ed9d3801ba | ||
|
|
f3bb73b671 | ||
|
|
bc845ea748 | ||
|
|
1afac21690 | ||
|
|
dd2e691ea8 | ||
|
|
f360a7bf94 | ||
|
|
caf7e90ca5 | ||
|
|
1e501cd75b | ||
|
|
45cb385d77 | ||
|
|
679514d40a | ||
|
|
9643dcacce | ||
|
|
fcb380044c | ||
|
|
d4ed92cb0a | ||
|
|
12f259ecae | ||
|
|
d9ca69d34f | ||
|
|
ebb0f12cd0 | ||
|
|
667d66c4dc | ||
|
|
ef92cb7c65 | ||
|
|
c9ea15a968 | ||
|
|
b56d30b366 | ||
|
|
8d6128957f | ||
|
|
e25ca611d8 | ||
|
|
44aec257bf | ||
|
|
2be3c3d69e | ||
|
|
09b199b280 | ||
|
|
b9764c4779 | ||
|
|
ff3cf5681f | ||
|
|
cfae2515fd | ||
|
|
b5982927de | ||
|
|
28a4859218 | ||
|
|
4f8a4c20fd | ||
|
|
21d540f760 | ||
|
|
fef28097cc | ||
|
|
3de20e0ba8 | ||
|
|
703f6d1780 | ||
|
|
2e1620c1ac | ||
|
|
4847447635 | ||
|
|
7ce751ffe8 | ||
|
|
7cb9aa2d3b | ||
|
|
3ab8476d2d | ||
|
|
643e2d66bf | ||
|
|
693e1b0fc5 | ||
|
|
70a513fc62 | ||
|
|
bec4ef6ac7 | ||
|
|
0024a845f7 | ||
|
|
0ab88ef36a | ||
|
|
7b3d97cb42 | ||
|
|
429edf4d82 | ||
|
|
5a9b6cb239 | ||
|
|
bda6bf3fee | ||
|
|
24287bcb0c | ||
|
|
3f7e6cdcb4 | ||
|
|
ea6c15b68e | ||
|
|
77998e41e4 | ||
|
|
aed1672366 | ||
|
|
11c922b5d4 | ||
|
|
ccd0f4b15d | ||
|
|
ab34f9ae09 | ||
|
|
9385e583ff | ||
|
|
5018f8aa8a | ||
|
|
ed45016475 | ||
|
|
9d6c846a49 | ||
|
|
8bdf702f73 | ||
|
|
c62947f1f1 | ||
|
|
24f5e2ac69 | ||
|
|
e18310e52e | ||
|
|
07119e3f00 | ||
|
|
a9b8c84e9b | ||
|
|
35a4533889 | ||
|
|
3a8fbcbd02 | ||
|
|
23150c2319 | ||
|
|
d6147456e3 | ||
|
|
7d5c68c2e1 | ||
|
|
385e8662bb | ||
|
|
6cb96adfbe | ||
|
|
373f33fd3b | ||
|
|
faca558bf5 | ||
|
|
b38d907ede | ||
|
|
0c3d08f355 | ||
|
|
a52adef203 | ||
|
|
3b05b5e553 | ||
|
|
92978f6d9f | ||
|
|
6fe87aaca5 | ||
|
|
37a927bf63 | ||
|
|
44e89d8844 | ||
|
|
7b1339a156 | ||
|
|
2ff3f7b533 | ||
|
|
34fdac2113 | ||
|
|
7d635d9f7e | ||
|
|
0ce41bfea1 | ||
|
|
92b93c9d37 | ||
|
|
4692741aa0 | ||
|
|
4ca0a10088 | ||
|
|
6ce0976265 | ||
|
|
5827ce2adf | ||
|
|
1d79b87307 | ||
|
|
9d8dacd0c8 | ||
|
|
720e1fab9b | ||
|
|
ca6f4e11f9 | ||
|
|
a5e284e6b1 | ||
|
|
3d08b2f89a | ||
|
|
43e59d3bc5 | ||
|
|
edc805c453 | ||
|
|
0ca99312c1 | ||
|
|
785ca4b915 | ||
|
|
c7b5a455f5 | ||
|
|
0c698744ab | ||
|
|
e425e18fca | ||
|
|
f4e50f3c8a | ||
|
|
aba3f9c1d6 | ||
|
|
979c8cc34b | ||
|
|
502418f707 | ||
|
|
ccfb47d2c5 | ||
|
|
1bb9183af9 | ||
|
|
de7e43a7ef | ||
|
|
b09691e5d2 | ||
|
|
6f70ca8d26 | ||
|
|
a7a30b20af | ||
|
|
cbd78c0073 | ||
|
|
97a3039a2d | ||
|
|
005edb7d47 | ||
|
|
b034368b3b | ||
|
|
43b6d8e32e | ||
|
|
248df5dd25 | ||
|
|
f97fc01c2f | ||
|
|
a6ace29e1a | ||
|
|
013b328555 | ||
|
|
d7b1531431 | ||
|
|
63acefa6d0 | ||
|
|
e2f6418fe4 | ||
|
|
2daa69c92e | ||
|
|
72b6a4e6ed | ||
|
|
b16a24f084 | ||
|
|
6e890524db | ||
|
|
e27eb51f8a | ||
|
|
2155adff5a | ||
|
|
c2c1876484 | ||
|
|
48f338e649 | ||
|
|
b4e13ec83e | ||
|
|
11f830a17b | ||
|
|
5550ec8ebf | ||
|
|
ea59256349 | ||
|
|
0f1536585e | ||
|
|
602a646dbd | ||
|
|
54ffbc7f0f | ||
|
|
ae20d64304 | ||
|
|
ee997d00fa | ||
|
|
98c2ffeec7 | ||
|
|
8e1c259a66 | ||
|
|
ea3fd4c093 | ||
|
|
930f4e6c26 | ||
|
|
356e3a8ab6 | ||
|
|
532489babd | ||
|
|
6fa9c14490 | ||
|
|
d0149c3f94 | ||
|
|
2f6e782215 | ||
|
|
414f211cde | ||
|
|
f1b48a93a9 | ||
|
|
8874f3c80b | ||
|
|
297fdc7cb8 | ||
|
|
73b15fc562 | ||
|
|
90a6b8c998 | ||
|
|
8d18931125 | ||
|
|
7444f96a1d | ||
|
|
8a33a9adb5 |
@@ -27,7 +27,7 @@ Read only the packs matched by the diff:
|
||||
- Stable Dify runtime invariants in the named paths: [`references/dify-invariants.md`][dify-invariants]
|
||||
- General TypeScript or styling quality not owned above: [`references/code-quality.md`][code-quality]
|
||||
|
||||
Read `packages/dify-ui/README.md`, `packages/dify-ui/AGENTS.md`, `web/docs/overlay.md`, or `web/docs/test.md` only when the reviewed code falls under that contract. Check current official documentation when local code and bundled references do not settle a framework, browser, or accessibility behavior.
|
||||
Read `packages/dify-ui/README.md`, `packages/dify-ui/AGENTS.md`, `packages/dify-ui/docs/overlays.md`, or `web/docs/test.md` only when the reviewed code falls under that contract. Check current official documentation when local code and bundled references do not settle a framework, browser, or accessibility behavior.
|
||||
|
||||
## Severity And Output
|
||||
|
||||
|
||||
@@ -1,142 +1,29 @@
|
||||
# Dify UI Rules
|
||||
# Dify UI Review Routing
|
||||
|
||||
Use these rules whenever a review touches `packages/dify-ui/` or code consuming `@langgenius/dify-ui/*`.
|
||||
Use this reference when a review touches `packages/dify-ui/` or consumes
|
||||
`@langgenius/dify-ui/*`. It routes to owner documentation; it does not redefine package
|
||||
contracts.
|
||||
|
||||
Before finalizing findings for those files, read the current local docs that apply:
|
||||
Read `packages/dify-ui/AGENTS.md` and the primitive implementation first, then only the matching
|
||||
owner:
|
||||
|
||||
- `packages/dify-ui/README.md`
|
||||
- `packages/dify-ui/AGENTS.md`
|
||||
- `web/docs/overlay.md` for floating UI
|
||||
- `packages/dify-ui/src/<primitive>/index.tsx` for the primitive being changed or consumed
|
||||
| Review area | Canonical owner |
|
||||
| ------------------------------------------ | ------------------------------------------------------------------- |
|
||||
| Package boundary and document routing | `packages/dify-ui/AGENTS.md` |
|
||||
| Imports, exports, public types, and anatomy | `packages/dify-ui/docs/authoring.md` |
|
||||
| Button or icon-only actions | `packages/dify-ui/src/button/README.md`, `packages/dify-ui/src/icon-button/README.md` |
|
||||
| Compound inputs | `packages/dify-ui/src/input-group/README.md` |
|
||||
| Forms and field semantics | `packages/dify-ui/docs/forms.md` |
|
||||
| Selection and typed values | `packages/dify-ui/docs/selection.md` |
|
||||
| Portals, layers, and floating surfaces | `packages/dify-ui/docs/overlays.md` |
|
||||
| Tailwind and radius tokens | `packages/dify-ui/docs/styling.md` |
|
||||
| Package tests and Storybook | `packages/dify-ui/docs/testing.md` |
|
||||
|
||||
## Package Boundary
|
||||
For consumer code under `web/`, also read `web/AGENTS.md` for application-owned reuse policy and
|
||||
`packages/dify-ui/README.md` for the available public subpaths.
|
||||
|
||||
Flag in `packages/dify-ui`:
|
||||
|
||||
- Imports from `web/`.
|
||||
- Dependencies on Next.js, i18n, ky, Jotai, Zustand, TanStack Query, oRPC, or business APIs.
|
||||
- Business-specific component behavior that belongs in `web/`.
|
||||
- Multiple unrelated primitives in one component folder.
|
||||
|
||||
`packages/dify-ui` is a primitive layer: Base UI headless components + `cva` + `cn` + Dify design tokens.
|
||||
|
||||
## Imports And Exports
|
||||
|
||||
Flag:
|
||||
|
||||
- Consumer imports from `@langgenius/dify-ui` without a subpath.
|
||||
- Missing `package.json#exports` entry for a new primitive.
|
||||
- Internal package imports using workspace subpaths instead of relative paths.
|
||||
- Exported props using internal-only types that consumers cannot import from the component subpath.
|
||||
- Canonical primitive boundaries or their associated public types using a redundant `Root` suffix when no higher-level convenience component exists in the same subpath.
|
||||
|
||||
Consumers use subpath exports such as `@langgenius/dify-ui/button`.
|
||||
|
||||
Canonical boundaries use the primitive name and matching public types (`Select` / `SelectProps`). Keep `Root` only to distinguish a low-level anatomy root from a higher-level convenience component (`CheckboxRoot` / `Checkbox`); implementation aliases should still show their Base UI source (`BaseSelect.Root.Props`).
|
||||
|
||||
## Props And State
|
||||
|
||||
Flag:
|
||||
|
||||
- Flattened props where related values need a discriminated union, such as `value` / `defaultValue`, `multiple` / `value`, or `clearable` / `onChange`.
|
||||
- React state used only to mirror Base UI state for class names.
|
||||
- JavaScript conditional class logic for visual states that the Dify UI/Base UI primitive already exposes through `data-*` attributes or CSS variables.
|
||||
- Controlled props added when uncontrolled DOM state or CSS variables would be enough.
|
||||
- Thin wrappers that rename Base UI parts without adding semantics.
|
||||
- Generic Base UI selection primitives wrapped without preserving their value generics, such as `Select.Root<Value, Multiple>`, `RadioGroup<Value>`, or `Radio.Root<Value>`.
|
||||
- Shared select/radio option components that type selected values as `string` while callers pass enums, unions, booleans, numbers, objects, or nullable placeholder values.
|
||||
|
||||
Prefer Base UI/Dify UI data attributes and CSS variables for visual state: `data-open`, `data-checked`, `data-disabled`, `data-highlighted`, `data-popup-open`, `group-data-*`, `peer-data-*`, `has-[:focus-visible]`, and primitive CSS variables such as anchor width or transform origin. Use JS conditional classes for product/business state that the primitive does not expose.
|
||||
|
||||
For non-string `Select` and `RadioGroup` values, prefer explicit domain generics at the root and at child value carriers. JSX children do not inherit the parent generic, so `RadioGroup<PromptMode>` should compose with `Radio<PromptMode>`, `RadioItem<PromptMode>`, or option values from a typed collection. For `Select`, prefer the Base UI `items` collection pattern for typed value-to-label rendering, and flag string coercion helpers used only to recover display labels.
|
||||
|
||||
## Forms
|
||||
|
||||
Flag:
|
||||
|
||||
- Form-like UI using unrelated `Input` and `Button` pieces without a submit boundary.
|
||||
- Text-like fields not composed through `Field`, `FieldLabel`, and `Input` or `InputGroupInput` when using Dify UI form semantics.
|
||||
- Prefixes, suffixes, or actions manually layered over `Input` instead of using the canonical `InputGroup` composition.
|
||||
- Select fields using `FieldLabel` instead of `SelectLabel`.
|
||||
- Slider fields using a generic label instead of `SliderLabel`.
|
||||
- Checkbox/radio groups missing `Fieldset` and `FieldsetLegend`.
|
||||
- Field errors or descriptions rendered without `FieldDescription` / `FieldError` relationships.
|
||||
|
||||
`Form` is the submit boundary. Dify UI form primitives are not a form state-management framework; business validation and schema-driven behavior belong in `web/`.
|
||||
|
||||
## Overlay Contract
|
||||
|
||||
Flag:
|
||||
|
||||
- Legacy web overlay imports in new or modified code.
|
||||
- Manual portals around Dify UI overlay primitives.
|
||||
- Call-site `z-*` overrides on overlays.
|
||||
- Missing root `isolation: isolate` assumptions when debugging overlay stacking.
|
||||
- Repeated backdrop, z-index, or portal chrome at call sites.
|
||||
- Tooltip used for infotips, long text, or interactive content.
|
||||
|
||||
All Dify UI body-portalled overlays use `z-50`. Toast uses `z-60`. DOM order handles stacking between overlays.
|
||||
|
||||
## Primitive Selection
|
||||
|
||||
Flag:
|
||||
|
||||
- `Tabs` used for simple mode/filter/view selection where `SegmentedControl` is the semantic primitive.
|
||||
- `SegmentedControl` used where `tablist` / `tabpanel` semantics are required.
|
||||
- `Select` used for searchable or free-form input.
|
||||
- `Combobox` used for unrestricted search text where no selected option is remembered.
|
||||
- `Autocomplete` used for closed-list selection.
|
||||
- Tooltip or PreviewCard used for content that must be reachable on touch or by screen readers.
|
||||
|
||||
Use:
|
||||
|
||||
- `Autocomplete` for free-form text with optional suggestions.
|
||||
- `Combobox` for searchable selected values from a collection.
|
||||
- `Select` for closed, scannable option sets.
|
||||
- `Popover` for infotips, help text, rich content, or interactions.
|
||||
|
||||
## Bad Usage Patterns To Flag
|
||||
|
||||
Flag:
|
||||
|
||||
- Manually recreating UI behavior or chrome already owned by `@langgenius/dify-ui/*` or `web/app/components/base/*`, such as buttons, inputs, toggle groups, popovers, dropdown menus, alert dialogs, switches, avatars, scroll areas, toasts, borders, focus states, disabled states, segmented controls, or existing feature components.
|
||||
- Styling a raw Base UI primitive directly in `web/` when a Dify UI primitive exists.
|
||||
- Wrapping a Dify UI primitive in a feature component that hides its label, error, disabled, or focus contract.
|
||||
- Replacing a semantic primitive with a generic `div` plus classes to match a screenshot.
|
||||
- Using `Tooltip` because it is visually convenient when the content is actually help text or needs touch access.
|
||||
- Adding a `z-*` override to make a child popup appear over a parent dialog.
|
||||
- Adding a new app-level wrapper around Dialog, Drawer, Popover, Select, or Combobox that repeats portal/backdrop/positioner logic.
|
||||
- Using dify-ui `Input` as a drop-in replacement for legacy inputs that include search, clear, copy, unit, localized placeholder, or number normalization behavior.
|
||||
- Building a form row from loose text and controls instead of the matching Field/Form primitives.
|
||||
- Adding component state only to style `data-open`, `data-checked`, `data-disabled`, or highlighted states that Base UI already exposes.
|
||||
- Passing booleans down only so children can toggle classes already expressible with primitive `data-*` selectors.
|
||||
|
||||
## Tokens, Radius, And Styling
|
||||
|
||||
Flag:
|
||||
|
||||
- `radius-*` class names.
|
||||
- Custom Tailwind `borderRadius` extension for Figma radius values.
|
||||
- Generic colors where semantic Dify tokens exist.
|
||||
- Hardcoded design values where Dify tokens, component variants, or documented Figma radius mappings exist.
|
||||
- `!` important modifiers used to fight primitive styles instead of fixing the variant, selector, or component composition.
|
||||
- Manual class strings that duplicate primitive variants.
|
||||
- `min-w-(--anchor-width)` on picker popups when it defeats viewport clamping.
|
||||
|
||||
Use the Figma radius mapping from `packages/dify-ui/README.md`; for example `--radius/sm` maps to `rounded-md`, and `--radius/md` maps to `rounded-lg`.
|
||||
|
||||
Use `!` only for a tightly scoped compatibility override after confirming the primitive API, data attributes, and selector structure cannot express the state.
|
||||
|
||||
## Focus Details
|
||||
|
||||
Flag focus rings attached to the wrong element. For example, Base UI `Slider.Thumb` focuses an internal `input[type=range]`, so the visible thumb wrapper needs `has-[:focus-visible]` rather than direct wrapper `focus-visible`.
|
||||
|
||||
## Custom SVG Icons
|
||||
|
||||
Flag:
|
||||
|
||||
- New generated React icon components or JSON files under `web/app/components/base/icons/src/...` for custom SVG icons.
|
||||
- Custom SVG icons consumed outside the Tailwind `i-custom-*` icon class pipeline.
|
||||
- Generated `packages/iconify-collections/custom-*/icons.json` diffs where unrelated existing icons lost or changed intrinsic `width` or `height`.
|
||||
|
||||
New custom SVG icons belong in `packages/iconify-collections/assets/...`. Regenerate with `pnpm --filter @dify/iconify-collections generate`, validate with `pnpm --filter @dify/iconify-collections check:dimensions`, and consume the generated icon with Tailwind `i-custom-*` classes.
|
||||
Treat the implementation, public types, tests, and stories as evidence for the documented
|
||||
contract. If they disagree, identify the actual owner before reporting a finding. Read current
|
||||
official Base UI documentation and installed type declarations for upstream-derived behavior.
|
||||
Report only a reproducible contract violation or observable defect, not a preference inferred from
|
||||
this routing file.
|
||||
|
||||
@@ -13,4 +13,4 @@ description: Use when writing or changing Vitest or React Testing Library tests
|
||||
4. Run the focused spec before the affected suite and relevant static checks.
|
||||
5. Report the behavior verified and any remaining browser, visual, or end-to-end risk.
|
||||
|
||||
Recommend deleting low-value tests as readily as adding missing behavior coverage. Use `web/docs/test.md` for policy and Web commands; use the `packages/dify-ui/README.md` Development section for Dify UI commands.
|
||||
Recommend deleting low-value tests as readily as adding missing behavior coverage. Use `web/docs/test.md` for policy and Web commands; use `packages/dify-ui/docs/testing.md` for Dify UI commands.
|
||||
|
||||
@@ -32,7 +32,7 @@ Use this skill to route component architecture decisions to its bundled referenc
|
||||
1. Identify the behavior owner and the public contract being changed.
|
||||
2. Read the nearby implementation, tests, and only the routed skill references.
|
||||
3. Implement one coherent vertical slice. Do not expand into equivalent patterns elsewhere unless the current contract cannot be completed without them.
|
||||
4. Verify observable behavior at the narrowest sufficient boundary, then run the checks documented by the owning package: `web/docs/test.md` or `web/docs/lint.md` for Web, and the `packages/dify-ui/README.md` Development section for Dify UI.
|
||||
4. Verify observable behavior at the narrowest sufficient boundary, then run the checks documented by the owning package: `web/docs/test.md` or `web/docs/lint.md` for Web, and `packages/dify-ui/docs/testing.md` for Dify UI.
|
||||
|
||||
[data]: references/data.md
|
||||
[interactions]: references/interactions.md
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Component Interactions And Overlays
|
||||
|
||||
Read this document when a change involves application hotkeys, focus, dialogs, menus, popovers, or other secondary surfaces. Overlay primitive selection and layering are owned by the [overlay guide].
|
||||
Read this document when a change involves application hotkeys, focus, dialogs, menus, popovers, or other secondary surfaces. Overlay primitive selection and layering are owned by the [overlay contract].
|
||||
|
||||
## Focus And Semantics
|
||||
|
||||
@@ -20,12 +20,11 @@ Read this document when a change involves application hotkeys, focus, dialogs, m
|
||||
|
||||
## Secondary Surfaces
|
||||
|
||||
- Follow `web/docs/overlay.md` for primitive choice. Dify UI primitives are the default, with package-approved Web wrappers such as `Infotip` where the overlay guide allows them.
|
||||
- Follow the [overlay contract] for primitive choice and shared mechanics. The nearest consumer `AGENTS.md` owns application-specific composite reuse policy.
|
||||
- Separate behavior ownership from placement ownership: the action may own trigger, open state, and menu content while the caller owns slots, offsets, and alignment.
|
||||
- Keep menu and dialog surfaces as siblings when a menu command opens a dialog. Mount the dialog outside popup content.
|
||||
- Mount controlled overlays unconditionally unless unmounting is required for performance or reset semantics. Prefer keyed or owner-local reset over conditional wrappers.
|
||||
- Put query and mutation work inside dialog or alert-dialog content when it should mount only after opening.
|
||||
- Prefer uncontrolled roots when the primitive can own open state. Use controlled state only for business coordination, analytics, cleanup, or explicit reset behavior.
|
||||
- Do not add manual portals or call-site z-index escalation. Fix ownership and stacking structure at the shared boundary.
|
||||
|
||||
[overlay guide]: ../../../../web/docs/overlay.md
|
||||
[overlay contract]: ../../../../packages/dify-ui/docs/overlays.md
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
have_fun: false
|
||||
memory_config:
|
||||
disabled: false
|
||||
code_review:
|
||||
disable: true
|
||||
comment_severity_threshold: MEDIUM
|
||||
max_review_comments: -1
|
||||
pull_request_opened:
|
||||
help: false
|
||||
summary: false
|
||||
code_review: false
|
||||
include_drafts: false
|
||||
ignore_patterns: []
|
||||
4
.github/CODEOWNERS
vendored
4
.github/CODEOWNERS
vendored
@@ -267,5 +267,9 @@
|
||||
/web/app/auth/refresh/route.ts @iamjoel @lyzno1
|
||||
/web/service/server.ts @iamjoel @lyzno1
|
||||
|
||||
# Frontend - Browser Mode Tests
|
||||
/web/app/**/*.browser.spec.ts @lyzno1
|
||||
/web/app/**/*.browser.spec.tsx @lyzno1
|
||||
|
||||
# Docker
|
||||
/docker/* @laipz8200
|
||||
|
||||
2
.github/actions/setup-web/action.yml
vendored
2
.github/actions/setup-web/action.yml
vendored
@@ -9,7 +9,7 @@ runs:
|
||||
with:
|
||||
run_install: false
|
||||
- name: Setup Vite+
|
||||
uses: voidzero-dev/setup-vp@313600b80b104eadebb9111787d37a2e83e014ca # v1.17.0
|
||||
uses: voidzero-dev/setup-vp@1b32467adbe183473499fd9d5d372c3ed9641754 # v1.18.0
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
cache: true
|
||||
|
||||
2
.github/workflows/accessibility-e2e.yml
vendored
2
.github/workflows/accessibility-e2e.yml
vendored
@@ -36,7 +36,7 @@ concurrency:
|
||||
jobs:
|
||||
accessibility:
|
||||
name: WCAG Level ${{ inputs.level == 'a' && 'A' || 'AA' }} · ${{ inputs.page }}
|
||||
runs-on: depot-ubuntu-24.04-4
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 120
|
||||
defaults:
|
||||
run:
|
||||
|
||||
6
.github/workflows/build-push.yml
vendored
6
.github/workflows/build-push.yml
vendored
@@ -103,7 +103,7 @@ jobs:
|
||||
password: ${{ env.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Set up Depot CLI
|
||||
uses: depot/setup-action@15c09a5f77a0840ad4bce955686522a257853461 # v1.7.1
|
||||
uses: depot/setup-action@91bc8495a33ebfc504ffc89e5674379ccf23c29c # v1.7.2
|
||||
|
||||
- name: Extract metadata for Docker
|
||||
id: meta
|
||||
@@ -160,7 +160,7 @@ jobs:
|
||||
file: 'docker/Dockerfile'
|
||||
steps:
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
|
||||
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
|
||||
|
||||
- name: Validate Docker image
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
@@ -246,7 +246,7 @@ jobs:
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
|
||||
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
|
||||
25
.github/workflows/deploy-hitl-im-dev.yml
vendored
Normal file
25
.github/workflows/deploy-hitl-im-dev.yml
vendored
Normal file
@@ -0,0 +1,25 @@
|
||||
name: Deploy HITL IM Dev
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ['Build and Push API & Web']
|
||||
branches:
|
||||
- 'build/feat/hitl-im-dev'
|
||||
types:
|
||||
- completed
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: depot-ubuntu-24.04
|
||||
if: |
|
||||
github.event.workflow_run.conclusion == 'success' &&
|
||||
github.event.workflow_run.head_branch == 'build/feat/hitl-im-dev'
|
||||
steps:
|
||||
- name: Deploy to server
|
||||
uses: appleboy/ssh-action@0ff4204d59e8e51228ff73bce53f80d53301dee2 # v1.2.5
|
||||
with:
|
||||
host: ${{ secrets.HITL_SSH_HOST }}
|
||||
username: ${{ secrets.SSH_USER }}
|
||||
key: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||
script: |
|
||||
${{ vars.SSH_SCRIPT || secrets.SSH_SCRIPT }}
|
||||
4
.github/workflows/docker-build.yml
vendored
4
.github/workflows/docker-build.yml
vendored
@@ -65,7 +65,7 @@ jobs:
|
||||
file: 'docker/Dockerfile'
|
||||
steps:
|
||||
- name: Set up Depot CLI
|
||||
uses: depot/setup-action@15c09a5f77a0840ad4bce955686522a257853461 # v1.7.1
|
||||
uses: depot/setup-action@91bc8495a33ebfc504ffc89e5674379ccf23c29c # v1.7.2
|
||||
|
||||
- name: Build Docker Image
|
||||
uses: depot/build-push-action@98e78adca7817480b8185f474a400b451d74e287 # v1.18.0
|
||||
@@ -96,7 +96,7 @@ jobs:
|
||||
file: 'docker/Dockerfile'
|
||||
steps:
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
|
||||
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
|
||||
|
||||
- name: Build Docker Image
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
|
||||
2
.github/workflows/translate-i18n-claude.yml
vendored
2
.github/workflows/translate-i18n-claude.yml
vendored
@@ -162,7 +162,7 @@ jobs:
|
||||
|
||||
- name: Run Claude Code for Translation Sync
|
||||
if: steps.context.outputs.CHANGED_FILES != ''
|
||||
uses: anthropics/claude-code-action@9d7150bc8a3dae8149739a88019d192b579ad90c # v1.0.193
|
||||
uses: anthropics/claude-code-action@dcb57747bfceeaa1fa72638cae52295d1d853d4a # v1.0.199
|
||||
with:
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
2
.github/workflows/vdb-tests-full.yml
vendored
2
.github/workflows/vdb-tests-full.yml
vendored
@@ -29,7 +29,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Free Disk Space
|
||||
uses: endersonmenezes/free-disk-space@7901478139cff6e9d44df5972fd8ab8fcade4db1 # v3.2.2
|
||||
uses: endersonmenezes/free-disk-space@2a22f8c59cae9cddae7194b33e6b92d023b2b4b8 # v4.0.0
|
||||
with:
|
||||
remove_dotnet: true
|
||||
remove_haskell: true
|
||||
|
||||
2
.github/workflows/vdb-tests.yml
vendored
2
.github/workflows/vdb-tests.yml
vendored
@@ -26,7 +26,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Free Disk Space
|
||||
uses: endersonmenezes/free-disk-space@7901478139cff6e9d44df5972fd8ab8fcade4db1 # v3.2.2
|
||||
uses: endersonmenezes/free-disk-space@2a22f8c59cae9cddae7194b33e6b92d023b2b4b8 # v4.0.0
|
||||
with:
|
||||
remove_dotnet: true
|
||||
remove_haskell: true
|
||||
|
||||
2
.github/workflows/web-e2e.yml
vendored
2
.github/workflows/web-e2e.yml
vendored
@@ -14,7 +14,7 @@ permissions:
|
||||
jobs:
|
||||
test:
|
||||
name: Web Full-Stack E2E
|
||||
runs-on: depot-ubuntu-24.04-4
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 120
|
||||
defaults:
|
||||
run:
|
||||
|
||||
40
.github/workflows/web-tests.yml
vendored
40
.github/workflows/web-tests.yml
vendored
@@ -33,7 +33,7 @@ jobs:
|
||||
uses: ./.github/actions/setup-web
|
||||
|
||||
- name: Run tests
|
||||
run: vp test run --reporter=blob --reporter=minimal --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} --coverage
|
||||
run: vp test run --project unit --reporter=blob --reporter=minimal --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} --coverage
|
||||
|
||||
- name: Upload blob report
|
||||
if: ${{ !cancelled() }}
|
||||
@@ -46,7 +46,7 @@ jobs:
|
||||
|
||||
browser-test:
|
||||
name: Web Browser Tests
|
||||
runs-on: depot-ubuntu-24.04-4
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 20
|
||||
defaults:
|
||||
run:
|
||||
@@ -67,7 +67,17 @@ jobs:
|
||||
run: vp exec playwright install --with-deps --only-shell chromium
|
||||
|
||||
- name: Run browser tests
|
||||
run: vp test run --config vitest.browser.config.ts --silent=passed-only
|
||||
run: vp test run --project browser --silent=passed-only
|
||||
|
||||
- name: Upload browser failure artifacts
|
||||
if: ${{ failure() && !cancelled() }}
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: browser-test-failures
|
||||
path: web/.vitest-browser/
|
||||
if-no-files-found: ignore
|
||||
include-hidden-files: true
|
||||
retention-days: 7
|
||||
|
||||
merge-reports:
|
||||
name: Merge Test Reports
|
||||
@@ -111,7 +121,7 @@ jobs:
|
||||
|
||||
dify-ui-test:
|
||||
name: dify-ui Tests
|
||||
runs-on: depot-ubuntu-24.04-4
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 20
|
||||
env:
|
||||
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||
@@ -136,6 +146,16 @@ jobs:
|
||||
- name: Run dify-ui tests
|
||||
run: vp test run --project unit --coverage --silent=passed-only
|
||||
|
||||
- name: Upload dify-ui test failure artifacts
|
||||
if: ${{ failure() && !cancelled() }}
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: dify-ui-test-failures
|
||||
path: packages/dify-ui/.vitest-browser/
|
||||
if-no-files-found: ignore
|
||||
include-hidden-files: true
|
||||
retention-days: 7
|
||||
|
||||
- name: Report coverage
|
||||
if: ${{ env.CODECOV_TOKEN != '' }}
|
||||
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
|
||||
@@ -147,7 +167,7 @@ jobs:
|
||||
|
||||
dify-ui-storybook-test:
|
||||
name: dify-ui Storybook Tests
|
||||
runs-on: depot-ubuntu-24.04-4
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 20
|
||||
defaults:
|
||||
run:
|
||||
@@ -169,3 +189,13 @@ jobs:
|
||||
|
||||
- name: Run dify-ui Storybook tests
|
||||
run: vp run test:storybook
|
||||
|
||||
- name: Upload dify-ui Storybook test failure artifacts
|
||||
if: ${{ failure() && !cancelled() }}
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: dify-ui-storybook-test-failures
|
||||
path: packages/dify-ui/.vitest-browser/
|
||||
if-no-files-found: ignore
|
||||
include-hidden-files: true
|
||||
retention-days: 7
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -263,6 +263,7 @@ scripts/stress-test/reports/
|
||||
.qoder/*
|
||||
.context/
|
||||
# Vitest local reports
|
||||
web/.vitest-browser/
|
||||
web/.vitest-reports/
|
||||
|
||||
# dify-agent-runtime
|
||||
|
||||
84
README.md
84
README.md
@@ -129,83 +129,35 @@ All of Dify's offerings come with corresponding APIs, so you could effortlessly
|
||||
|
||||
Star Dify on GitHub and be instantly notified of new releases.
|
||||
|
||||

|
||||
<img width="1344" height="720" alt="star" src="https://github.com/user-attachments/assets/dcd086d1-af0f-471b-ae52-1ad2fa040595" />
|
||||
|
||||
## Advanced Setup
|
||||
|
||||
### Custom configurations
|
||||
|
||||
If you need to customize the configuration, edit `docker/.env`. The essential startup defaults live in [`docker/.env.example`](docker/.env.example), and optional advanced variables are split under `docker/envs/` by theme. After making any changes, re-run `docker compose up -d` from the `docker` directory. You can find the full list of available environment variables [here](https://docs.dify.ai/getting-started/install-self-hosted/environments).
|
||||
|
||||
### Metrics Monitoring with Grafana
|
||||
|
||||
Import the dashboard to Grafana, using Dify's PostgreSQL database as data source, to monitor metrics in granularity of apps, tenants, messages, and more.
|
||||
|
||||
- [Grafana Dashboard by @bowenliang123](https://github.com/bowenliang123/dify-grafana-dashboard)
|
||||
|
||||
### Deployment with Kubernetes
|
||||
|
||||
If you'd like to configure a highly available setup, there are community-contributed [Helm Charts](https://helm.sh/) and YAML files which allow Dify to be deployed on Kubernetes.
|
||||
|
||||
- [Helm Chart by @LeoQuote](https://github.com/douban/charts/tree/master/charts/dify)
|
||||
- [Helm Chart by @BorisPolonsky](https://github.com/BorisPolonsky/dify-helm)
|
||||
- [Helm Chart by @magicsong](https://github.com/magicsong/ai-charts)
|
||||
- [YAML file by @Winson-030](https://github.com/Winson-030/dify-kubernetes)
|
||||
- [YAML file by @wyy-holding](https://github.com/wyy-holding/dify-k8s)
|
||||
- [🚀 NEW! YAML files (Supports Dify v1.6.0) by @Zhoneym](https://github.com/Zhoneym/DifyAI-Kubernetes)
|
||||
|
||||
#### Using Terraform for Deployment
|
||||
|
||||
Deploy Dify to Cloud Platform with a single click using [terraform](https://www.terraform.io/)
|
||||
|
||||
##### Azure Global
|
||||
|
||||
- [Azure Terraform by @nikawang](https://github.com/nikawang/dify-azure-terraform)
|
||||
|
||||
##### Google Cloud
|
||||
|
||||
- [Google Cloud Terraform by @sotazum](https://github.com/DeNA/dify-google-cloud-terraform)
|
||||
|
||||
#### Using AWS CDK for Deployment
|
||||
|
||||
Deploy Dify to AWS with [CDK](https://aws.amazon.com/cdk/)
|
||||
|
||||
##### AWS
|
||||
|
||||
- [AWS CDK by @KevinZhao (EKS based)](https://github.com/aws-samples/solution-for-deploying-dify-on-aws)
|
||||
- [AWS CDK by @tmokmss (ECS based)](https://github.com/aws-samples/dify-self-hosted-on-aws)
|
||||
|
||||
#### Using Alibaba Cloud Computing Nest
|
||||
|
||||
Quickly deploy Dify to Alibaba cloud with [Alibaba Cloud Computing Nest](https://computenest.console.aliyun.com/service/instance/create/default?type=user&ServiceName=Dify%E7%A4%BE%E5%8C%BA%E7%89%88)
|
||||
|
||||
#### Using Alibaba Cloud Data Management
|
||||
|
||||
One-Click deploy Dify to Alibaba Cloud with [Alibaba Cloud Data Management](https://www.alibabacloud.com/help/en/dms/dify-in-invitational-preview/)
|
||||
|
||||
#### Deploy to AKS with Azure Devops Pipeline
|
||||
|
||||
One-Click deploy Dify to AKS with [Azure Devops Pipeline Helm Chart by @LeoZhang](https://github.com/Ruiruiz30/Dify-helm-chart-AKS)
|
||||
For custom configuration, observability, and deployment options, see [Advanced Setup](docs/ADVANCED_SETUP.md).
|
||||
|
||||
## Contributing
|
||||
|
||||
For those who'd like to contribute code, see our [Contribution Guide](https://github.com/langgenius/dify/blob/main/CONTRIBUTING.md).
|
||||
At the same time, please consider supporting Dify by sharing it on social media and at events and conferences.
|
||||
Dify welcomes contributions of all kinds:
|
||||
|
||||
> We are looking for contributors to help translate Dify into languages other than Mandarin or English. If you are interested in helping, please see the [i18n README](https://github.com/langgenius/dify/blob/main/web/i18n-config/README.md) for more information, and leave us a comment in the `global-users` channel of our [Discord Community Server](https://discord.gg/8Tpq4AcN9c).
|
||||
- **Code**: Read the [Contribution Guide](CONTRIBUTING.md), then browse [good first issues](https://github.com/langgenius/dify/issues?q=is%3Aissue%20state%3Aopen%20label%3A%22good%20first%20issue%22).
|
||||
- **Ideas and feedback**: Start or join a [GitHub Discussion](https://github.com/langgenius/dify/discussions).
|
||||
- **Translations**: Follow the [internationalization guide](web/i18n-config/README.md) to add or update a locale.
|
||||
- **Community**: Share the apps you build, help other users, and spread the word about Dify.
|
||||
|
||||
### Contributors
|
||||
|
||||
<a href="https://github.com/langgenius/dify/graphs/contributors">
|
||||
<img alt="Dify contributors" src="https://contrib.rocks/image?repo=langgenius/dify" />
|
||||
</a>
|
||||
|
||||
## Community & contact
|
||||
|
||||
- [GitHub Discussion](https://github.com/langgenius/dify/discussions). Best for: sharing feedback and asking questions.
|
||||
- [GitHub Issues](https://github.com/langgenius/dify/issues). Best for: bugs you encounter using Dify.AI, and feature proposals. See our [Contribution Guide](https://github.com/langgenius/dify/blob/main/CONTRIBUTING.md).
|
||||
- [Discord](https://discord.gg/FngNHpbcY7). Best for: sharing your applications and hanging out with the community.
|
||||
- [X(Twitter)](https://twitter.com/dify_ai). Best for: sharing your applications and hanging out with the community.
|
||||
Choose the channel that best fits your question:
|
||||
|
||||
**Contributors**
|
||||
|
||||
<a href="https://github.com/langgenius/dify/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=langgenius/dify" />
|
||||
</a>
|
||||
- [GitHub Discussions](https://github.com/langgenius/dify/discussions): Get help, share feedback, and propose ideas.
|
||||
- [GitHub Issues](https://github.com/langgenius/dify/issues): Report reproducible bugs and track engineering work. Read the [Contribution Guide](CONTRIBUTING.md) before opening one.
|
||||
- [Discord](https://discord.gg/FngNHpbcY7): Chat in real time, share your apps, and connect with other Dify users.
|
||||
- [X](https://x.com/dify_ai): Follow Dify for release news and project updates.
|
||||
|
||||
## Star History
|
||||
|
||||
|
||||
@@ -98,9 +98,11 @@ REDIS_KEEPALIVE=true
|
||||
CELERY_BROKER_URL=redis://:difyai123456@localhost:${REDIS_PORT}/1
|
||||
CELERY_BACKEND=redis
|
||||
|
||||
# Ops trace retry configuration
|
||||
OPS_TRACE_RETRYABLE_DISPATCH_MAX_RETRIES=60
|
||||
# Ops trace configuration
|
||||
OPS_TRACE_UNIFIED_ENABLED=false
|
||||
OPS_TRACE_RETRYABLE_DISPATCH_MAX_RETRIES=780
|
||||
OPS_TRACE_RETRYABLE_DISPATCH_DELAY_SECONDS=5
|
||||
OPS_TRACE_PARENT_CONTEXT_TTL_SECONDS=3900
|
||||
|
||||
# Database configuration
|
||||
DB_TYPE=postgresql
|
||||
@@ -492,6 +494,8 @@ SENTRY_DSN=
|
||||
TURNSTILE_SECRET_KEY=
|
||||
# Comma-separated parent or exact hostnames, for example: dify.ai,staging.dify.dev
|
||||
TURNSTILE_ALLOWED_HOSTNAMES=
|
||||
# Enable only after the compatible web client has been fully deployed.
|
||||
TURNSTILE_EMAIL_CODE_VERIFY_REQUIRED=false
|
||||
|
||||
# DEBUG
|
||||
DEBUG=false
|
||||
@@ -583,7 +587,7 @@ INDEXING_MAX_SEGMENTATION_TOKENS_LENGTH=4000
|
||||
|
||||
# Workflow runtime configuration
|
||||
WORKFLOW_MAX_EXECUTION_STEPS=500
|
||||
WORKFLOW_MAX_EXECUTION_TIME=1200
|
||||
WORKFLOW_MAX_EXECUTION_TIME=3600
|
||||
WORKFLOW_CALL_MAX_DEPTH=5
|
||||
MAX_VARIABLE_SIZE=204800
|
||||
# Maximum concurrent node-builder LLM calls per workflow generation request
|
||||
@@ -628,7 +632,7 @@ WORKFLOW_LOG_CLEANUP_BATCH_SIZE=100
|
||||
WORKFLOW_LOG_CLEANUP_SPECIFIC_WORKFLOW_IDS=
|
||||
|
||||
# App configuration
|
||||
APP_MAX_EXECUTION_TIME=1200
|
||||
APP_MAX_EXECUTION_TIME=3600
|
||||
APP_DEFAULT_ACTIVE_REQUESTS=0
|
||||
APP_MAX_ACTIVE_REQUESTS=0
|
||||
|
||||
@@ -707,6 +711,8 @@ AGENT_BACKEND_BASE_URL=http://localhost:5050
|
||||
AGENT_BACKEND_API_TOKEN=dify-agent-run-token-for-dev-only
|
||||
AGENT_BACKEND_STREAM_READ_TIMEOUT_SECONDS=30
|
||||
AGENT_BACKEND_STREAM_MAX_RECONNECTS=3
|
||||
# Client deadline for converting a Binding file to a ToolFile through the Agent backend.
|
||||
AGENT_BACKEND_BINDING_FILE_DOWNLOAD_TIMEOUT_SECONDS=240
|
||||
|
||||
# KnowledgeFS (Dataset 2.0)
|
||||
KNOWLEDGE_FS_ENABLED=false
|
||||
@@ -733,6 +739,8 @@ RESET_PASSWORD_TOKEN_EXPIRY_MINUTES=5
|
||||
EMAIL_REGISTER_TOKEN_EXPIRY_MINUTES=5
|
||||
CHANGE_EMAIL_TOKEN_EXPIRY_MINUTES=5
|
||||
OWNER_TRANSFER_TOKEN_EXPIRY_MINUTES=5
|
||||
EMAIL_CODE_LOGIN_TOKEN_EXPIRY_MINUTES=5
|
||||
EMAIL_CODE_LOGIN_MAX_ATTEMPTS=5
|
||||
|
||||
CREATE_TIDB_SERVICE_JOB_ENABLED=false
|
||||
|
||||
@@ -842,3 +850,6 @@ CONVERSATION_CLEANUP_BATCH_SIZE=100
|
||||
DIFY_ENV_NACOS_REQUEST_TIMEOUT=10.0
|
||||
# Connect timeout for Nacos requests (default: 3.0)
|
||||
DIFY_ENV_NACOS_CONNECT_TIMEOUT=3.0
|
||||
|
||||
# skill entry
|
||||
ENABLE_SKILL=true
|
||||
|
||||
@@ -15,6 +15,117 @@ root_packages =
|
||||
services
|
||||
include_external_packages = True
|
||||
|
||||
[importlinter:contract:backend-layers]
|
||||
name = Backend modules follow the transport, application, domain, and shared-library layers
|
||||
type = layers
|
||||
layers =
|
||||
controllers
|
||||
services
|
||||
core
|
||||
libs
|
||||
# Migration baseline: keep every exception exact. The default unmatched-import
|
||||
# error makes this list shrink whenever a legacy dependency is removed.
|
||||
unmatched_ignore_imports_alerting = error
|
||||
ignore_imports =
|
||||
core.app.app_config.easy_ui_based_app.dataset.manager -> services.dataset_service
|
||||
core.app.apps.advanced_chat.app_generator -> controllers.console.app.workflow
|
||||
core.app.apps.advanced_chat.app_generator -> services.conversation_service
|
||||
core.app.apps.advanced_chat.app_generator -> services.errors.conversation
|
||||
core.app.apps.advanced_chat.app_generator -> services.workflow_draft_variable_service
|
||||
core.app.apps.advanced_chat.app_runner -> services.conversation_variable_updater
|
||||
core.app.apps.agent_app.app_generator -> services.agent.composer_service
|
||||
core.app.apps.agent_app.app_generator -> services.agent.workspace_service
|
||||
core.app.apps.agent_app.app_generator -> services.conversation_service
|
||||
core.app.apps.agent_app.runtime_request_builder -> services.agent.prompt_mentions
|
||||
core.app.apps.agent_app.session_store -> services.agent.workspace_service
|
||||
core.app.apps.agent_chat.app_generator -> services.conversation_service
|
||||
core.app.apps.base_app_generator -> services.workflow_draft_variable_service
|
||||
core.app.apps.chat.app_generator -> services.conversation_service
|
||||
core.app.apps.common.workflow_response_converter -> services.variable_truncator
|
||||
core.app.apps.completion.app_generator -> services.errors.app
|
||||
core.app.apps.completion.app_generator -> services.errors.message
|
||||
core.app.apps.message_based_app_generator -> services.errors.app_model_config
|
||||
core.app.apps.message_based_app_generator -> services.errors.conversation
|
||||
core.app.apps.message_based_app_generator -> services.errors.message
|
||||
core.app.apps.pipeline.pipeline_generator -> services
|
||||
core.app.apps.pipeline.pipeline_generator -> services.dataset_service
|
||||
core.app.apps.pipeline.pipeline_generator -> services.datasource_provider_service
|
||||
core.app.apps.pipeline.pipeline_generator -> services.feature_service
|
||||
core.app.apps.pipeline.pipeline_generator -> services.workflow_draft_variable_service
|
||||
core.app.apps.pipeline.pipeline_runner -> services.dataset_ref_service
|
||||
core.app.apps.workflow.app_generator -> controllers.console.app.workflow
|
||||
core.app.apps.workflow.app_generator -> services.snippet_generate_service
|
||||
core.app.apps.workflow.app_generator -> services.workflow_draft_variable_service
|
||||
core.app.apps.workflow_app_runner -> tasks.mail_human_input_delivery_task
|
||||
core.app.features.annotation_reply.annotation_reply -> services.annotation_service
|
||||
core.app.features.annotation_reply.annotation_reply -> services.dataset_service
|
||||
core.app.llm.quota -> services.credit_pool_service
|
||||
core.app.task_pipeline.message_cycle_manager -> services.account_service
|
||||
core.app.task_pipeline.message_cycle_manager -> services.annotation_service
|
||||
core.app.workflow.layers.persistence -> services.workflow.inspector_events
|
||||
core.datasource.datasource_manager -> services.datasource_provider_service
|
||||
core.helper.creators -> services.oauth_server
|
||||
core.helper.credential_utils -> services.enterprise.plugin_manager_service
|
||||
core.helper.credential_utils -> services.feature_service
|
||||
core.indexing_runner -> services.vector_space_admission_service
|
||||
core.mcp.auth_client -> services
|
||||
core.provider_manager -> services.credential_permission_service
|
||||
core.provider_manager -> services.credit_pool_service
|
||||
core.provider_manager -> services.feature_service
|
||||
core.rag.datasource.retrieval_service -> services.external_knowledge_service
|
||||
core.rag.index_processor.index_processor -> services.vector_space_admission_service
|
||||
core.rag.index_processor.index_processor_base -> services.file_service
|
||||
core.rag.index_processor.processor.paragraph_index_processor -> services.account_service
|
||||
core.rag.index_processor.processor.paragraph_index_processor -> services.summary_index_service
|
||||
core.rag.index_processor.processor.parent_child_index_processor -> services.account_service
|
||||
core.rag.index_processor.processor.parent_child_index_processor -> services.summary_index_service
|
||||
core.rag.index_processor.processor.qa_index_processor -> services.summary_index_service
|
||||
core.rag.retrieval.dataset_retrieval -> services.external_knowledge_service
|
||||
core.rag.retrieval.dataset_retrieval -> services.feature_service
|
||||
core.rag.summary_index.summary_index -> services.summary_index_service
|
||||
core.rag.summary_index.summary_index -> tasks.generate_summary_index_task
|
||||
core.repositories.sqlalchemy_workflow_node_execution_repository -> services.file_service
|
||||
core.repositories.sqlalchemy_workflow_node_execution_repository -> services.variable_truncator
|
||||
core.tools.tool_manager -> services
|
||||
core.workflow.node_runtime -> services
|
||||
core.workflow.nodes.agent.message_transformer -> services
|
||||
core.workflow.nodes.agent_v2.agent_node -> services.agent.prompt_mentions
|
||||
core.workflow.nodes.agent_v2.agent_node -> services.agent.workspace_service
|
||||
core.workflow.nodes.agent_v2.dify_tools_builder -> services
|
||||
core.workflow.nodes.agent_v2.runtime_feature_manifest -> services.agent.knowledge_datasets
|
||||
core.workflow.nodes.agent_v2.runtime_request_builder -> services.agent.prompt_mentions
|
||||
core.workflow.nodes.agent_v2.runtime_request_builder -> services.skill_management_service
|
||||
core.workflow.nodes.agent_v2.session_store -> services.agent.workspace_service
|
||||
core.workflow.nodes.agent_v2.validators -> services.agent.knowledge_datasets
|
||||
core.workflow.nodes.agent_v2.workspace_retirement_layer -> tasks.collect_agent_resources_task
|
||||
libs.device_flow_security -> controllers.openapi._models
|
||||
libs.device_flow_security -> services.entities.feature_entities
|
||||
libs.device_flow_security -> services.feature_service
|
||||
libs.email_i18n -> services.entities.feature_entities
|
||||
libs.email_i18n -> services.feature_service
|
||||
libs.external_api -> core
|
||||
libs.external_api -> core.errors.error
|
||||
libs.external_api -> extensions.ext_logging
|
||||
libs.flask_utils -> models
|
||||
libs.helper -> core.app.features.rate_limiting.rate_limit
|
||||
libs.helper -> models
|
||||
libs.helper -> models.model
|
||||
libs.json_in_md_parser -> core.llm_generator.output_parser.errors
|
||||
libs.login -> extensions.ext_login
|
||||
libs.login -> models
|
||||
libs.login -> models.model
|
||||
libs.oauth -> core.helper.http_client_pooling
|
||||
libs.oauth_bearer -> models
|
||||
libs.oauth_data_source -> core.db.session_factory
|
||||
libs.oauth_data_source -> core.helper.http_client_pooling
|
||||
libs.rsa -> extensions.ext_storage
|
||||
libs.workspace_permission -> services.enterprise.enterprise_service
|
||||
libs.workspace_permission -> services.feature_service
|
||||
services.account_service -> controllers
|
||||
services.account_service -> controllers.console.error
|
||||
services.app_generate_service -> controllers.console.app.workflow
|
||||
services.billing_service -> controllers.console.error
|
||||
|
||||
[importlinter:contract:no-direct-rsa-imports]
|
||||
# Note: `libs` itself is deliberately excluded from source_modules -- import-linter's
|
||||
# `forbidden` contract cannot check a package against its own descendant (libs.rsa lives
|
||||
@@ -93,6 +204,33 @@ forbidden_modules =
|
||||
sqlalchemy
|
||||
werkzeug
|
||||
|
||||
[importlinter:contract:account-application-boundary]
|
||||
name = Account application services and contracts are framework and persistence neutral
|
||||
type = forbidden
|
||||
source_modules =
|
||||
services.account_avatar_service
|
||||
services.account_change_email_ports
|
||||
services.account_change_email_service
|
||||
services.account_deletion_service
|
||||
services.account_deletion_feedback_service
|
||||
services.account_education_service
|
||||
services.account_errors
|
||||
services.account_initialization_service
|
||||
services.account_integration_service
|
||||
services.account_password_service
|
||||
services.account_ports
|
||||
services.account_profile_service
|
||||
services.entities.account_entities
|
||||
forbidden_modules =
|
||||
configs
|
||||
controllers
|
||||
extensions
|
||||
flask
|
||||
models
|
||||
repositories
|
||||
sqlalchemy
|
||||
werkzeug
|
||||
|
||||
[importlinter:contract:app-definition-query-service-boundary]
|
||||
name = App definition query application service is framework and persistence neutral
|
||||
type = forbidden
|
||||
@@ -107,6 +245,41 @@ forbidden_modules =
|
||||
sqlalchemy
|
||||
werkzeug
|
||||
|
||||
[importlinter:contract:webapp-access-query-service-boundary]
|
||||
name = Web app access query application service is framework and persistence neutral
|
||||
type = forbidden
|
||||
source_modules =
|
||||
services.webapp_access_query_service
|
||||
forbidden_modules =
|
||||
configs
|
||||
controllers
|
||||
extensions
|
||||
flask
|
||||
models
|
||||
repositories
|
||||
services.enterprise
|
||||
services.feature_service
|
||||
sqlalchemy
|
||||
werkzeug
|
||||
|
||||
[importlinter:contract:web-app-runtime-query-service-boundary]
|
||||
name = Web app runtime query application service does not directly depend on configuration, feature implementation, transport, or ORM modules
|
||||
type = forbidden
|
||||
source_modules =
|
||||
services.web_app_runtime_query_service
|
||||
forbidden_modules =
|
||||
configs
|
||||
controllers
|
||||
extensions
|
||||
flask
|
||||
models
|
||||
repositories
|
||||
services.feature_service
|
||||
services.feature_service_gateway
|
||||
sqlalchemy
|
||||
werkzeug
|
||||
allow_indirect_imports = True
|
||||
|
||||
[importlinter:contract:feature-query-service-boundary]
|
||||
name = Feature query application service is framework and persistence neutral
|
||||
type = forbidden
|
||||
@@ -183,3 +356,60 @@ forbidden_modules =
|
||||
repositories
|
||||
sqlalchemy
|
||||
werkzeug
|
||||
|
||||
[importlinter:contract:tag-application-service-boundary]
|
||||
name = Tag application service is framework and persistence neutral
|
||||
type = forbidden
|
||||
source_modules =
|
||||
services.tag_application_service
|
||||
forbidden_modules =
|
||||
configs
|
||||
controllers
|
||||
extensions
|
||||
flask
|
||||
models
|
||||
repositories
|
||||
sqlalchemy
|
||||
werkzeug
|
||||
|
||||
[importlinter:contract:recommended-app-query-service-boundary]
|
||||
name = Recommended app query application service is framework and persistence neutral
|
||||
type = forbidden
|
||||
source_modules =
|
||||
services.recommended_app_query_service
|
||||
forbidden_modules =
|
||||
configs
|
||||
controllers
|
||||
extensions
|
||||
flask
|
||||
models
|
||||
repositories
|
||||
services.feature_service
|
||||
services.recommended_app_catalog_gateway
|
||||
sqlalchemy
|
||||
werkzeug
|
||||
|
||||
[importlinter:contract:recommended-app-catalog-gateway-boundary]
|
||||
name = Recommended app catalog gateway does not depend on Flask
|
||||
type = forbidden
|
||||
source_modules =
|
||||
services.recommended_app_catalog_gateway
|
||||
forbidden_modules =
|
||||
flask
|
||||
|
||||
[importlinter:contract:billing-application-boundary]
|
||||
name = Billing application services and ports are framework and infrastructure neutral
|
||||
type = forbidden
|
||||
source_modules =
|
||||
services.billing_portal_service
|
||||
services.partner_tenant_binding_service
|
||||
forbidden_modules =
|
||||
configs
|
||||
controllers
|
||||
extensions
|
||||
flask
|
||||
models
|
||||
repositories
|
||||
services.billing_service
|
||||
sqlalchemy
|
||||
werkzeug
|
||||
|
||||
@@ -8,11 +8,40 @@ classes.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from http import HTTPStatus
|
||||
from typing import Any
|
||||
|
||||
from dify_agent.client import DifyAgentHTTPError
|
||||
from dify_agent.protocol import RunFailureType
|
||||
|
||||
|
||||
def backend_reported_failure(exc: DifyAgentHTTPError) -> bool:
|
||||
"""Whether the backend itself reported the failure.
|
||||
|
||||
``DifyAgentValidationError`` is a ``DifyAgentHTTPError`` that the client also
|
||||
raises when a *successful* response fails DTO validation. Those carry a 2xx
|
||||
status and a Pydantic error list, not a backend-authored error envelope.
|
||||
"""
|
||||
return exc.status_code >= HTTPStatus.BAD_REQUEST
|
||||
|
||||
|
||||
def backend_error_detail(
|
||||
exc: DifyAgentHTTPError,
|
||||
*,
|
||||
default_code: str = "agent_backend_error",
|
||||
) -> tuple[str, str]:
|
||||
"""Split an Agent backend HTTP error into the code and message the backend itself sent."""
|
||||
detail = exc.detail
|
||||
if isinstance(detail, dict):
|
||||
code = detail.get("code")
|
||||
message = detail.get("message")
|
||||
return (
|
||||
code if isinstance(code, str) and code else default_code,
|
||||
message if isinstance(message, str) and message else str(exc),
|
||||
)
|
||||
return default_code, str(detail)
|
||||
|
||||
|
||||
class AgentBackendError(Exception):
|
||||
"""Base error for API-side Agent backend integration failures."""
|
||||
|
||||
|
||||
@@ -8,10 +8,23 @@ from clients.agent_backend.client import AgentBackendRunClient, DifyAgentBackend
|
||||
from clients.agent_backend.fake_client import FakeAgentBackendRunClient, FakeAgentBackendScenario
|
||||
|
||||
|
||||
def create_agent_backend_client(*, base_url: str, api_token: str | None = None, stream_timeout: float = 30) -> Client:
|
||||
def create_agent_backend_client(
|
||||
*,
|
||||
base_url: str,
|
||||
api_token: str | None = None,
|
||||
stream_timeout: float = 30,
|
||||
timeout: float = 30.0,
|
||||
binding_file_download_timeout: float = 240,
|
||||
) -> Client:
|
||||
api_token = api_token.strip() if api_token else None
|
||||
headers = {"Authorization": f"Bearer {api_token}"} if api_token else None
|
||||
return Client(base_url=base_url, stream_timeout=stream_timeout, headers=headers)
|
||||
return Client(
|
||||
base_url=base_url,
|
||||
stream_timeout=stream_timeout,
|
||||
timeout=timeout,
|
||||
binding_file_download_timeout=binding_file_download_timeout,
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
|
||||
def create_agent_backend_run_client(
|
||||
|
||||
@@ -28,7 +28,6 @@ from dify_agent.layers.dify_plugin import (
|
||||
DifyPluginLLMLayerConfig,
|
||||
DifyPluginToolsLayerConfig,
|
||||
)
|
||||
from dify_agent.layers.drive import DIFY_DRIVE_LAYER_TYPE_ID, DifyDriveLayerConfig
|
||||
from dify_agent.layers.execution_context import (
|
||||
DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID,
|
||||
DifyExecutionContextLayerConfig,
|
||||
@@ -56,7 +55,6 @@ AGENT_APP_USER_PROMPT_LAYER_ID = "agent_app_user_prompt"
|
||||
DIFY_EXECUTION_CONTEXT_LAYER_ID = "execution_context"
|
||||
DIFY_RUNTIME_LAYER_ID = "runtime"
|
||||
DIFY_CONFIG_LAYER_ID = "config"
|
||||
DIFY_DRIVE_LAYER_ID = "drive"
|
||||
DIFY_PLUGIN_TOOLS_LAYER_ID = "tools"
|
||||
DIFY_CORE_TOOLS_LAYER_ID = "core_tools"
|
||||
DIFY_KNOWLEDGE_BASE_LAYER_ID = "knowledge"
|
||||
@@ -72,24 +70,10 @@ def _shell_layer_deps() -> dict[str, str]:
|
||||
}
|
||||
|
||||
|
||||
def _drive_layer_deps() -> dict[str, str]:
|
||||
return {"shell": DIFY_SHELL_LAYER_ID}
|
||||
|
||||
|
||||
def _config_layer_deps() -> dict[str, str]:
|
||||
return {"shell": DIFY_SHELL_LAYER_ID}
|
||||
|
||||
|
||||
def _shell_config_with_drive_ref(
|
||||
shell_config: DifyShellLayerConfig | None,
|
||||
drive_config: DifyDriveLayerConfig | None,
|
||||
) -> DifyShellLayerConfig:
|
||||
config = shell_config or DifyShellLayerConfig()
|
||||
if drive_config is None:
|
||||
return config
|
||||
return config.model_copy(update={"agent_stub_drive_ref": drive_config.drive_ref})
|
||||
|
||||
|
||||
def _markdown_backtick_fence(text: str) -> str:
|
||||
"""Choose a fence that will not terminate inside the prompt body."""
|
||||
longest_backtick_run = max((len(match.group(0)) for match in re.finditer(r"`+", text)), default=0)
|
||||
@@ -224,9 +208,6 @@ class AgentBackendWorkflowNodeRunInput(BaseModel):
|
||||
core_tools: DifyCoreToolsLayerConfig | None = None
|
||||
knowledge: DifyKnowledgeBaseLayerConfig | None = None
|
||||
config_layer_config: DifyConfigLayerConfig | None = None
|
||||
# Drive Skills & Files declaration (dify.drive) — an index the agent pulls
|
||||
# through the back proxy, never inline content.
|
||||
drive_config: DifyDriveLayerConfig | None = None
|
||||
# Human-in-the-loop ask_human deferred tool (dify.ask_human). Present only when
|
||||
# the Agent Soul configures human involvement; a deferred call ends the run and
|
||||
# the workflow pauses via the existing HITL form mechanism (ENG-635).
|
||||
@@ -273,9 +254,6 @@ class AgentBackendAgentAppRunInput(BaseModel):
|
||||
core_tools: DifyCoreToolsLayerConfig | None = None
|
||||
knowledge: DifyKnowledgeBaseLayerConfig | None = None
|
||||
config_layer_config: DifyConfigLayerConfig | None = None
|
||||
# Drive Skills & Files declaration (dify.drive) — an index the agent pulls
|
||||
# through the back proxy, never inline content.
|
||||
drive_config: DifyDriveLayerConfig | None = None
|
||||
# Human-in-the-loop ask_human deferred tool (dify.ask_human). Present only when
|
||||
# the Agent Soul configures human involvement (ENG-635).
|
||||
ask_human_config: DifyAskHumanLayerConfig | None = None
|
||||
@@ -307,7 +285,7 @@ class AgentBackendRunRequestBuilder:
|
||||
"""Build an Agent App conversation-turn run request.
|
||||
|
||||
Layer graph: optional Agent Soul system prompt → user prompt →
|
||||
execution context → optional shell / config / drive / history
|
||||
execution context → optional shell / config / history
|
||||
(multi-turn) → LLM → optional plugin-direct tools / core-routed tools /
|
||||
knowledge search / ask_human / structured output. Mirrors the
|
||||
workflow-node layer ordering minus the workflow-job / previous-node
|
||||
@@ -345,9 +323,7 @@ class AgentBackendRunRequestBuilder:
|
||||
]
|
||||
)
|
||||
|
||||
include_shell = (
|
||||
run_input.include_shell or run_input.config_layer_config is not None or run_input.drive_config is not None
|
||||
)
|
||||
include_shell = run_input.include_shell or run_input.config_layer_config is not None
|
||||
if include_shell:
|
||||
layers.append(
|
||||
RunLayerSpec(
|
||||
@@ -357,16 +333,15 @@ class AgentBackendRunRequestBuilder:
|
||||
config=DifyRuntimeLayerConfig(backend_binding_ref=run_input.backend_binding_ref),
|
||||
)
|
||||
)
|
||||
# Sandboxed bash workspace (dify.shell). It enters before config/drive
|
||||
# so eager pulls materialize content in the same filesystem used by
|
||||
# model commands.
|
||||
# Sandboxed bash workspace (dify.shell). It enters before config so
|
||||
# eager pulls materialize content in the same filesystem used by model commands.
|
||||
layers.append(
|
||||
RunLayerSpec(
|
||||
name=DIFY_SHELL_LAYER_ID,
|
||||
type=DIFY_SHELL_LAYER_TYPE_ID,
|
||||
deps=_shell_layer_deps(),
|
||||
metadata=run_input.metadata,
|
||||
config=_shell_config_with_drive_ref(run_input.shell_config, run_input.drive_config),
|
||||
config=run_input.shell_config or DifyShellLayerConfig(),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -381,19 +356,6 @@ class AgentBackendRunRequestBuilder:
|
||||
)
|
||||
)
|
||||
|
||||
if run_input.drive_config is not None:
|
||||
# Drive Skills & Files declaration (dify.drive): the catalog plus
|
||||
# prompt-mentioned entries eagerly pulled through the shell layer.
|
||||
layers.append(
|
||||
RunLayerSpec(
|
||||
name=DIFY_DRIVE_LAYER_ID,
|
||||
type=DIFY_DRIVE_LAYER_TYPE_ID,
|
||||
deps=_drive_layer_deps(),
|
||||
metadata=run_input.metadata,
|
||||
config=run_input.drive_config,
|
||||
)
|
||||
)
|
||||
|
||||
if run_input.include_history:
|
||||
layers.append(
|
||||
RunLayerSpec(
|
||||
@@ -495,7 +457,7 @@ class AgentBackendRunRequestBuilder:
|
||||
"""Build a workflow Agent Node run request without defining another wire schema.
|
||||
|
||||
Layer graph mirrors the workflow surface: prompts → execution context →
|
||||
optional shell / config / drive / history → LLM → optional
|
||||
optional shell / config / history → LLM → optional
|
||||
plugin-direct tools / core-routed tools / knowledge search /
|
||||
ask_human / structured output.
|
||||
"""
|
||||
@@ -537,9 +499,7 @@ class AgentBackendRunRequestBuilder:
|
||||
]
|
||||
)
|
||||
|
||||
include_shell = (
|
||||
run_input.include_shell or run_input.config_layer_config is not None or run_input.drive_config is not None
|
||||
)
|
||||
include_shell = run_input.include_shell or run_input.config_layer_config is not None
|
||||
if include_shell:
|
||||
layers.append(
|
||||
RunLayerSpec(
|
||||
@@ -549,16 +509,15 @@ class AgentBackendRunRequestBuilder:
|
||||
config=DifyRuntimeLayerConfig(backend_binding_ref=run_input.backend_binding_ref),
|
||||
)
|
||||
)
|
||||
# Sandboxed bash workspace (dify.shell). It enters before drive so
|
||||
# drive can materialize mentioned targets with `dify-agent drive pull`
|
||||
# in the same shell-visible filesystem used by model commands.
|
||||
# Sandboxed bash workspace (dify.shell). It enters before config so
|
||||
# eager pulls materialize content in the same filesystem used by model commands.
|
||||
layers.append(
|
||||
RunLayerSpec(
|
||||
name=DIFY_SHELL_LAYER_ID,
|
||||
type=DIFY_SHELL_LAYER_TYPE_ID,
|
||||
deps=_shell_layer_deps(),
|
||||
metadata=run_input.metadata,
|
||||
config=_shell_config_with_drive_ref(run_input.shell_config, run_input.drive_config),
|
||||
config=run_input.shell_config or DifyShellLayerConfig(),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -573,19 +532,6 @@ class AgentBackendRunRequestBuilder:
|
||||
)
|
||||
)
|
||||
|
||||
if run_input.drive_config is not None:
|
||||
# Drive Skills & Files declaration (dify.drive): the catalog plus
|
||||
# prompt-mentioned entries eagerly pulled through the shell layer.
|
||||
layers.append(
|
||||
RunLayerSpec(
|
||||
name=DIFY_DRIVE_LAYER_ID,
|
||||
type=DIFY_DRIVE_LAYER_TYPE_ID,
|
||||
deps=_drive_layer_deps(),
|
||||
metadata=run_input.metadata,
|
||||
config=run_input.drive_config,
|
||||
)
|
||||
)
|
||||
|
||||
if run_input.include_history:
|
||||
layers.append(
|
||||
RunLayerSpec(
|
||||
|
||||
@@ -20,7 +20,7 @@ def reset_password(email, new_password, password_confirm):
|
||||
Reset password of owner account
|
||||
Only available in SELF_HOSTED mode
|
||||
"""
|
||||
if str(new_password).strip() != str(password_confirm).strip():
|
||||
if new_password.strip() != password_confirm.strip():
|
||||
click.echo(click.style("Passwords do not match.", fg="red"))
|
||||
return
|
||||
normalized_email = email.strip().lower()
|
||||
@@ -62,7 +62,7 @@ def reset_email(email, new_email, email_confirm):
|
||||
Replace account email
|
||||
:return:
|
||||
"""
|
||||
if str(new_email).strip() != str(email_confirm).strip():
|
||||
if new_email.strip() != email_confirm.strip():
|
||||
click.echo(click.style("New emails do not match.", fg="red"))
|
||||
return
|
||||
normalized_new_email = new_email.strip().lower()
|
||||
|
||||
@@ -83,11 +83,6 @@ class EnterpriseTelemetryConfig(BaseSettings):
|
||||
default=False,
|
||||
)
|
||||
|
||||
ENTERPRISE_SERVICE_NAME: str = Field(
|
||||
description="Service name for OTEL resource.",
|
||||
default="dify",
|
||||
)
|
||||
|
||||
ENTERPRISE_OTEL_SAMPLING_RATE: float = Field(
|
||||
description="Sampling rate for enterprise traces (0.0 to 1.0, default 1.0 = 100%).",
|
||||
default=1.0,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from configs.extra.agent_backend_config import AgentBackendConfig
|
||||
from configs.extra.archive_config import ArchiveStorageConfig
|
||||
from configs.extra.knowledge_fs_config import KnowledgeFSConfig
|
||||
from configs.extra.logstore_config import LogStoreConfig
|
||||
from configs.extra.notion_config import NotionConfig
|
||||
from configs.extra.sentry_config import SentryConfig
|
||||
from configs.extra.turnstile_config import TurnstileConfig
|
||||
@@ -11,6 +12,7 @@ class ExtraServiceConfig(
|
||||
AgentBackendConfig,
|
||||
ArchiveStorageConfig,
|
||||
KnowledgeFSConfig,
|
||||
LogStoreConfig,
|
||||
NotionConfig,
|
||||
SentryConfig,
|
||||
TurnstileConfig,
|
||||
|
||||
@@ -37,6 +37,19 @@ class AgentBackendConfig(BaseSettings):
|
||||
default=3,
|
||||
)
|
||||
|
||||
AGENT_BACKEND_HOME_SNAPSHOT_TIMEOUT_SECONDS: PositiveFloat = Field(
|
||||
description=(
|
||||
"Client timeout for Agent backend calls that may carry a Home Snapshot transfer: "
|
||||
"snapshot capture and delete, and Execution Binding creation that restores one. "
|
||||
),
|
||||
default=45.0,
|
||||
)
|
||||
|
||||
AGENT_BACKEND_BINDING_FILE_DOWNLOAD_TIMEOUT_SECONDS: PositiveFloat = Field(
|
||||
description="Client timeout for converting a Binding file to a ToolFile through the Agent backend.",
|
||||
default=240,
|
||||
)
|
||||
|
||||
AGENT_SHELL_ENABLED: bool = Field(
|
||||
description=(
|
||||
"Inject the Home, Workspace, Sandbox, and Shell runtime layers into Agent runs. "
|
||||
|
||||
11
api/configs/extra/logstore_config.py
Normal file
11
api/configs/extra/logstore_config.py
Normal file
@@ -0,0 +1,11 @@
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
|
||||
class LogStoreConfig(BaseSettings):
|
||||
"""Migration controls for repositories backed by Aliyun LogStore."""
|
||||
|
||||
LOGSTORE_DUAL_WRITE_ENABLED: bool = False
|
||||
|
||||
# Keep workflow graphs in LogStore by default. Deployments may disable this
|
||||
# while migrating large graph payloads to another persistence owner.
|
||||
LOGSTORE_ENABLE_PUT_GRAPH_FIELD: bool = True
|
||||
@@ -13,6 +13,13 @@ class TurnstileConfig(BaseSettings):
|
||||
default="",
|
||||
description="Comma-separated parent or exact hostnames accepted from Turnstile.",
|
||||
)
|
||||
TURNSTILE_EMAIL_CODE_VERIFY_REQUIRED: bool = Field(
|
||||
default=False,
|
||||
description=(
|
||||
"Require a separate Turnstile challenge when verifying email login codes on Dify Cloud. "
|
||||
"Enable after the compatible web client has been deployed."
|
||||
),
|
||||
)
|
||||
|
||||
@field_validator("TURNSTILE_SECRET_KEY", mode="before")
|
||||
@classmethod
|
||||
|
||||
@@ -12,6 +12,7 @@ from pydantic import (
|
||||
PositiveInt,
|
||||
computed_field,
|
||||
field_validator,
|
||||
model_validator,
|
||||
)
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
@@ -83,7 +84,7 @@ class AppExecutionConfig(BaseSettings):
|
||||
|
||||
APP_MAX_EXECUTION_TIME: PositiveInt = Field(
|
||||
description="Maximum allowed execution time for the application in seconds",
|
||||
default=1200,
|
||||
default=3600,
|
||||
)
|
||||
APP_DEFAULT_ACTIVE_REQUESTS: NonNegativeInt = Field(
|
||||
description="Default number of concurrent active requests per app (0 for unlimited)",
|
||||
@@ -889,7 +890,7 @@ class WorkflowConfig(BaseSettings):
|
||||
|
||||
WORKFLOW_MAX_EXECUTION_TIME: PositiveInt = Field(
|
||||
description="Maximum execution time in seconds for a single workflow",
|
||||
default=1200,
|
||||
default=3600,
|
||||
)
|
||||
|
||||
WORKFLOW_CALL_MAX_DEPTH: PositiveInt = Field(
|
||||
@@ -1290,6 +1291,13 @@ class DataSetConfig(BaseSettings):
|
||||
)
|
||||
|
||||
|
||||
class SkillConfig(BaseSettings):
|
||||
ENABLE_SKILL: bool = Field(
|
||||
description="Enable or disable Skill feature entry points",
|
||||
default=True,
|
||||
)
|
||||
|
||||
|
||||
class WorkspaceConfig(BaseSettings):
|
||||
"""
|
||||
Configuration for workspace management
|
||||
@@ -1336,9 +1344,16 @@ class NewAgentBetaConfig(BaseSettings):
|
||||
|
||||
|
||||
class OpsTraceConfig(BaseSettings):
|
||||
OPS_TRACE_UNIFIED_ENABLED: bool = Field(
|
||||
description="Enable unified ops tracing for providers registered in the unified registry.",
|
||||
default=False,
|
||||
)
|
||||
|
||||
# Include scheduling and export grace after the parent workflow's maximum execution time.
|
||||
# Recommended: max_retries >= ceil((WORKFLOW_MAX_EXECUTION_TIME + grace_seconds) / delay_seconds).
|
||||
OPS_TRACE_RETRYABLE_DISPATCH_MAX_RETRIES: PositiveInt = Field(
|
||||
description="Maximum retry attempts for transient ops trace provider dispatch failures.",
|
||||
default=60,
|
||||
default=780,
|
||||
)
|
||||
|
||||
OPS_TRACE_RETRYABLE_DISPATCH_DELAY_SECONDS: PositiveInt = Field(
|
||||
@@ -1346,6 +1361,20 @@ class OpsTraceConfig(BaseSettings):
|
||||
default=5,
|
||||
)
|
||||
|
||||
OPS_TRACE_PARENT_CONTEXT_TTL_SECONDS: PositiveInt = Field(
|
||||
description="Retention in seconds for unified tracing parent contexts.",
|
||||
default=3900,
|
||||
)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_parent_context_retention(self) -> "OpsTraceConfig":
|
||||
if not self.OPS_TRACE_UNIFIED_ENABLED:
|
||||
return self
|
||||
retry_window = self.OPS_TRACE_RETRYABLE_DISPATCH_MAX_RETRIES * self.OPS_TRACE_RETRYABLE_DISPATCH_DELAY_SECONDS
|
||||
if retry_window > self.OPS_TRACE_PARENT_CONTEXT_TTL_SECONDS:
|
||||
raise ValueError("OPS_TRACE_PARENT_CONTEXT_TTL_SECONDS must cover the retry window")
|
||||
return self
|
||||
|
||||
|
||||
class CeleryBeatConfig(BaseSettings):
|
||||
CELERY_BEAT_SCHEDULER_TIME: int = Field(
|
||||
@@ -1549,6 +1578,10 @@ class LoginConfig(BaseSettings):
|
||||
description="expiry time in minutes for email code login token",
|
||||
default=5,
|
||||
)
|
||||
EMAIL_CODE_LOGIN_MAX_ATTEMPTS: PositiveInt = Field(
|
||||
description="maximum number of verification attempts for an email code login challenge",
|
||||
default=5,
|
||||
)
|
||||
ALLOW_REGISTER: bool = Field(
|
||||
description="whether to enable register",
|
||||
default=False,
|
||||
@@ -1659,6 +1692,7 @@ class FeatureConfig(
|
||||
RepositoryConfig,
|
||||
SandboxExpiredRecordsCleanConfig,
|
||||
SecurityConfig,
|
||||
SkillConfig,
|
||||
TenantIsolatedTaskQueueConfig,
|
||||
ToolConfig,
|
||||
UpdateConfig,
|
||||
|
||||
@@ -167,8 +167,8 @@ class ApolloClient:
|
||||
old_value = old_kv.get(key)
|
||||
if old_value is None:
|
||||
self._change_listener("add", namespace, key, new_value)
|
||||
except BaseException as e:
|
||||
logger.warning(str(e))
|
||||
except BaseException:
|
||||
logger.warning("Apollo config change listener failed", exc_info=True)
|
||||
|
||||
def _path_checker(self) -> None:
|
||||
if not os.path.isdir(self._cache_file_path):
|
||||
@@ -235,8 +235,8 @@ class ApolloClient:
|
||||
return
|
||||
else:
|
||||
logger.warning("Sleep...")
|
||||
except Exception as e:
|
||||
logger.warning(str(e))
|
||||
except Exception:
|
||||
logger.warning("Apollo long polling failed", exc_info=True)
|
||||
|
||||
def _get_net_and_set_local(self, namespace: str, n_id: int, call_change: bool = False) -> None:
|
||||
namespace_data = self.get_json_from_net(namespace)
|
||||
|
||||
@@ -36,6 +36,7 @@ _UNSTRUCTURED_DOCUMENT_EXTENSION_BASE: frozenset[str] = frozenset(
|
||||
"pptx",
|
||||
"xml",
|
||||
"epub",
|
||||
"odt",
|
||||
)
|
||||
)
|
||||
_DEFAULT_DOCUMENT_EXTENSION_BASE: frozenset[str] = frozenset(
|
||||
@@ -53,6 +54,7 @@ _DEFAULT_DOCUMENT_EXTENSION_BASE: frozenset[str] = frozenset(
|
||||
"csv",
|
||||
"vtt",
|
||||
"properties",
|
||||
"odt",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -166,6 +166,7 @@
|
||||
"categories": ["Workflow"],
|
||||
"copyright": null,
|
||||
"description": "Based on users' choice, retrieve external knowledge to more accurately summarize articles.",
|
||||
"is_learn_dify": true,
|
||||
"is_listed": true,
|
||||
"position": 5,
|
||||
"privacy_policy": null
|
||||
@@ -294,6 +295,7 @@
|
||||
"categories": ["Workflow"],
|
||||
"copyright": null,
|
||||
"description": "Basic Workflow Template, a chatbot capable of identifying intents alongside with a knowledge base.",
|
||||
"is_learn_dify": true,
|
||||
"is_listed": true,
|
||||
"position": 4,
|
||||
"privacy_policy": null
|
||||
@@ -326,6 +328,7 @@
|
||||
"categories": ["Workflow"],
|
||||
"copyright": null,
|
||||
"description": "Basic Workflow Template, A chatbot with a knowledge base. ",
|
||||
"is_learn_dify": true,
|
||||
"is_listed": true,
|
||||
"position": 4,
|
||||
"privacy_policy": null
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
TTS_AUTO_PLAY_TIMEOUT = 5
|
||||
|
||||
# sleep 20 ms ( 40ms => 1280 byte audio file,20ms => 640 byte audio file)
|
||||
TTS_AUTO_PLAY_YIELD_CPU_TIME = 0.02
|
||||
@@ -72,7 +72,6 @@ from .app import (
|
||||
agent_app_feature,
|
||||
agent_app_sandbox,
|
||||
agent_config_inspector,
|
||||
agent_drive_inspector,
|
||||
annotation,
|
||||
app,
|
||||
audio,
|
||||
@@ -158,6 +157,7 @@ from .workspace import (
|
||||
models,
|
||||
plugin,
|
||||
rbac,
|
||||
skills,
|
||||
snippets,
|
||||
tool_providers,
|
||||
trigger_providers,
|
||||
@@ -176,7 +176,6 @@ __all__ = [
|
||||
"agent_app_sandbox",
|
||||
"agent_composer",
|
||||
"agent_config_inspector",
|
||||
"agent_drive_inspector",
|
||||
"agent_providers",
|
||||
"agent_roster",
|
||||
"annotation",
|
||||
@@ -238,6 +237,7 @@ __all__ = [
|
||||
"saved_message",
|
||||
"setup",
|
||||
"site",
|
||||
"skills",
|
||||
"snippet_workflow",
|
||||
"snippet_workflow_draft_variable",
|
||||
"snippets",
|
||||
|
||||
@@ -182,14 +182,7 @@ class WorkflowAgentComposerValidateApi(Resource):
|
||||
AgentComposerService.validate_knowledge_datasets(
|
||||
session=session, tenant_id=tenant_id, agent_soul=req_data.agent_soul
|
||||
)
|
||||
findings = AgentComposerService.collect_validation_findings(
|
||||
session=session,
|
||||
tenant_id=tenant_id,
|
||||
payload=req_data,
|
||||
agent_id=AgentComposerService.resolve_workflow_node_agent_id(
|
||||
session=session, tenant_id=tenant_id, app_id=app_model.id, node_id=node_id
|
||||
),
|
||||
)
|
||||
findings = AgentComposerService.collect_validation_findings(payload=req_data)
|
||||
return dump_response(AgentComposerValidateResponse, {"result": "success", "errors": [], **findings})
|
||||
|
||||
|
||||
@@ -413,22 +406,12 @@ class SnippetAgentComposerValidateApi(Resource):
|
||||
@with_session(write=False)
|
||||
@model_validate(ComposerSavePayload)
|
||||
def post(self, req_data: ComposerSavePayload, session: Session, tenant_id: str, snippet_id: UUID, node_id: str):
|
||||
app_id = _require_snippet_app_id(session=session, tenant_id=tenant_id, snippet_id=snippet_id)
|
||||
_require_snippet_app_id(session=session, tenant_id=tenant_id, snippet_id=snippet_id)
|
||||
ComposerConfigValidator.validate_publish_payload(req_data)
|
||||
AgentComposerService.validate_knowledge_datasets(
|
||||
session=session, tenant_id=tenant_id, agent_soul=req_data.agent_soul
|
||||
)
|
||||
findings = AgentComposerService.collect_validation_findings(
|
||||
session=session,
|
||||
tenant_id=tenant_id,
|
||||
payload=req_data,
|
||||
agent_id=AgentComposerService.resolve_workflow_node_agent_id(
|
||||
session=session,
|
||||
tenant_id=tenant_id,
|
||||
app_id=app_id,
|
||||
node_id=node_id,
|
||||
),
|
||||
)
|
||||
findings = AgentComposerService.collect_validation_findings(payload=req_data)
|
||||
return dump_response(AgentComposerValidateResponse, {"result": "success", "errors": [], **findings})
|
||||
|
||||
|
||||
@@ -580,12 +563,7 @@ class AgentComposerValidateApi(Resource):
|
||||
AgentComposerService.validate_knowledge_datasets(
|
||||
session=session, tenant_id=tenant_id, agent_soul=req_data.agent_soul
|
||||
)
|
||||
findings = AgentComposerService.collect_validation_findings(
|
||||
session=session,
|
||||
tenant_id=tenant_id,
|
||||
payload=req_data,
|
||||
agent_id=str(agent_id),
|
||||
)
|
||||
findings = AgentComposerService.collect_validation_findings(payload=req_data)
|
||||
return dump_response(AgentComposerValidateResponse, {"result": "success", "errors": [], **findings})
|
||||
|
||||
|
||||
|
||||
@@ -1,21 +1,12 @@
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from flask import request
|
||||
from flask_restx import Resource
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from controllers.common.schema import (
|
||||
query_params_from_model,
|
||||
query_params_from_request,
|
||||
register_response_schema_models,
|
||||
register_schema_models,
|
||||
)
|
||||
from controllers.common.schema import query_params_from_model, register_response_schema_models
|
||||
from controllers.common.session import with_session
|
||||
from controllers.console import console_ns
|
||||
from controllers.console.agent.app_helpers import resolve_agent_runtime_app_model
|
||||
from controllers.console.app.wraps import get_app_model
|
||||
from controllers.console.wraps import (
|
||||
RBACPermission,
|
||||
@@ -24,41 +15,13 @@ from controllers.console.wraps import (
|
||||
model_validate,
|
||||
rbac_permission_required,
|
||||
setup_required,
|
||||
with_current_tenant_id,
|
||||
with_current_user,
|
||||
)
|
||||
from fields.base import ResponseModel
|
||||
from libs.helper import uuid_value
|
||||
from libs.login import login_required
|
||||
from models import Account
|
||||
from models.model import App, AppMode, UploadFile
|
||||
from services.agent.composer_service import AgentComposerService
|
||||
from services.agent.skill_package_service import SkillManifest, SkillPackageError
|
||||
from services.agent.skill_standardize_service import SkillStandardizeService
|
||||
from services.agent.skill_tool_inference_service import (
|
||||
SkillToolInferenceError,
|
||||
SkillToolInferenceResult,
|
||||
SkillToolInferenceService,
|
||||
)
|
||||
from services.agent_drive_service import (
|
||||
AgentDriveError,
|
||||
AgentDriveService,
|
||||
DriveCommitItem,
|
||||
DriveFileRef,
|
||||
normalize_drive_key,
|
||||
)
|
||||
from models.model import App, AppMode
|
||||
from services.agent_service import AgentService
|
||||
|
||||
_WORKFLOW_AGENT_DRIVE_APP_MODES = [AppMode.WORKFLOW, AppMode.ADVANCED_CHAT]
|
||||
_AGENT_SKILL_UPLOAD_PARAMS = {
|
||||
"file": {
|
||||
"in": "formData",
|
||||
"type": "file",
|
||||
"required": True,
|
||||
"description": "Skill package (.zip or .skill).",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class AgentLogQuery(BaseModel):
|
||||
message_id: str = Field(..., description="Message UUID")
|
||||
@@ -70,27 +33,6 @@ class AgentLogQuery(BaseModel):
|
||||
return uuid_value(value)
|
||||
|
||||
|
||||
class AgentDriveFilePayload(BaseModel):
|
||||
upload_file_id: str = Field(..., description="UploadFile UUID from POST /console/api/files/upload")
|
||||
|
||||
@field_validator("upload_file_id")
|
||||
@classmethod
|
||||
def validate_upload_file_id(cls, value: str) -> str:
|
||||
return uuid_value(value)
|
||||
|
||||
|
||||
class AgentDriveMutationQuery(BaseModel):
|
||||
node_id: str | None = Field(default=None, description="Workflow node ID (workflow composer variant)")
|
||||
|
||||
|
||||
class AgentDriveDeleteFileQuery(AgentDriveMutationQuery):
|
||||
key: str = Field(min_length=1, description="Drive key, e.g. files/sample.pdf")
|
||||
|
||||
|
||||
class AgentDriveDeleteFileByAgentQuery(BaseModel):
|
||||
key: str = Field(min_length=1, description="Drive key, e.g. files/sample.pdf")
|
||||
|
||||
|
||||
class AgentLogMetaResponse(ResponseModel):
|
||||
status: str
|
||||
executor: str
|
||||
@@ -128,204 +70,7 @@ class AgentLogResponse(ResponseModel):
|
||||
files: list[Any] = Field(default_factory=list)
|
||||
|
||||
|
||||
class AgentUploadedSkillResponse(ResponseModel):
|
||||
name: str
|
||||
description: str
|
||||
path: str
|
||||
skill_md_key: str
|
||||
archive_key: str | None = None
|
||||
|
||||
|
||||
class AgentSkillUploadResponse(ResponseModel):
|
||||
skill: AgentUploadedSkillResponse
|
||||
manifest: SkillManifest
|
||||
|
||||
|
||||
class AgentDriveFileResponse(ResponseModel):
|
||||
name: str
|
||||
drive_key: str
|
||||
file_id: str
|
||||
size: int | None = None
|
||||
mime_type: str | None = None
|
||||
|
||||
|
||||
class AgentDriveFileCommitResponse(ResponseModel):
|
||||
file: AgentDriveFileResponse
|
||||
|
||||
|
||||
class AgentDriveDeleteResponse(ResponseModel):
|
||||
result: str
|
||||
removed_keys: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
register_schema_models(console_ns, AgentLogQuery, AgentDriveFilePayload, AgentDriveDeleteFileByAgentQuery)
|
||||
register_response_schema_models(
|
||||
console_ns,
|
||||
AgentDriveDeleteResponse,
|
||||
AgentDriveFileCommitResponse,
|
||||
AgentDriveFileResponse,
|
||||
AgentLogResponse,
|
||||
AgentUploadedSkillResponse,
|
||||
AgentSkillUploadResponse,
|
||||
SkillToolInferenceResult,
|
||||
)
|
||||
|
||||
|
||||
def _resolve_agent_id(session: Session, app_model: App, node_id: str | None) -> str | None:
|
||||
if node_id and app_model.mode != AppMode.AGENT:
|
||||
return AgentComposerService.resolve_workflow_node_agent_id(
|
||||
session=session, tenant_id=app_model.tenant_id, app_id=app_model.id, node_id=node_id
|
||||
)
|
||||
return app_model.bound_agent_id_with_session(session=session)
|
||||
|
||||
|
||||
def _agent_not_bound() -> tuple[dict[str, str], int]:
|
||||
return {"code": "agent_not_bound", "message": "no agent is bound for this app/node"}, 400
|
||||
|
||||
|
||||
def _upload_skill_for_app(*, session: Session, current_user: Account, app_model: App):
|
||||
"""Upload one skill package and commit its normalized files into the agent drive."""
|
||||
|
||||
query = query_params_from_request(AgentDriveMutationQuery)
|
||||
agent_id = _resolve_agent_id(session, app_model, query.node_id)
|
||||
if not agent_id:
|
||||
return _agent_not_bound()
|
||||
if "file" not in request.files:
|
||||
return {"code": "no_file", "message": "no skill file uploaded"}, 400
|
||||
if len(request.files) > 1:
|
||||
return {"code": "too_many_files", "message": "only one skill file is allowed"}, 400
|
||||
|
||||
upload = request.files["file"]
|
||||
content = upload.stream.read()
|
||||
try:
|
||||
result = SkillStandardizeService().standardize(
|
||||
content=content,
|
||||
filename=upload.filename or "",
|
||||
tenant_id=app_model.tenant_id,
|
||||
user_id=current_user.id,
|
||||
agent_id=agent_id,
|
||||
session=session,
|
||||
)
|
||||
except (SkillPackageError, AgentDriveError) as exc:
|
||||
return {"code": exc.code, "message": exc.message}, exc.status_code
|
||||
return result, 201
|
||||
|
||||
|
||||
def _commit_drive_file_for_app(*, session: Session, current_user: Account, app_model: App, allow_node_id: bool = True):
|
||||
payload = AgentDriveFilePayload.model_validate(console_ns.payload or {})
|
||||
query = query_params_from_request(AgentDriveMutationQuery)
|
||||
node_id = query.node_id if allow_node_id else None
|
||||
agent_id = _resolve_agent_id(session, app_model, node_id)
|
||||
if not agent_id:
|
||||
return _agent_not_bound()
|
||||
|
||||
upload_file = session.scalar(
|
||||
select(UploadFile).where(
|
||||
UploadFile.id == payload.upload_file_id,
|
||||
UploadFile.tenant_id == app_model.tenant_id,
|
||||
)
|
||||
)
|
||||
if upload_file is None:
|
||||
return {"code": "upload_file_not_found", "message": "upload file not found in this workspace"}, 404
|
||||
|
||||
try:
|
||||
key = normalize_drive_key(f"files/{upload_file.name}")
|
||||
committed = AgentDriveService().commit(
|
||||
tenant_id=app_model.tenant_id,
|
||||
user_id=current_user.id,
|
||||
agent_id=agent_id,
|
||||
items=[
|
||||
DriveCommitItem(
|
||||
key=key,
|
||||
file_ref=DriveFileRef(kind="upload_file", id=upload_file.id),
|
||||
# ADD FILE uploads exist solely to live in the drive, so the
|
||||
# drive owns (and physically cleans) the value on delete.
|
||||
value_owned_by_drive=True,
|
||||
)
|
||||
],
|
||||
session=session,
|
||||
)
|
||||
except AgentDriveError as exc:
|
||||
return {"code": exc.code, "message": exc.message}, exc.status_code
|
||||
|
||||
row = committed[0]
|
||||
return {
|
||||
"file": {
|
||||
"name": upload_file.name,
|
||||
"drive_key": row["key"],
|
||||
"file_id": upload_file.id,
|
||||
"size": row.get("size"),
|
||||
"mime_type": row.get("mime_type"),
|
||||
},
|
||||
}, 201
|
||||
|
||||
|
||||
def _delete_drive_file_for_app(*, session: Session, current_user: Account, app_model: App, allow_node_id: bool = True):
|
||||
query = query_params_from_request(AgentDriveDeleteFileQuery)
|
||||
node_id = query.node_id if allow_node_id else None
|
||||
agent_id = _resolve_agent_id(session, app_model, node_id)
|
||||
if not agent_id:
|
||||
return _agent_not_bound()
|
||||
try:
|
||||
key = normalize_drive_key(query.key)
|
||||
except AgentDriveError as exc:
|
||||
return {"code": exc.code, "message": exc.message}, exc.status_code
|
||||
|
||||
try:
|
||||
result = AgentDriveService().commit(
|
||||
tenant_id=app_model.tenant_id,
|
||||
user_id=current_user.id,
|
||||
agent_id=agent_id,
|
||||
items=[DriveCommitItem(key=key, file_ref=None)],
|
||||
session=session,
|
||||
)
|
||||
except AgentDriveError as exc:
|
||||
return {"code": exc.code, "message": exc.message}, exc.status_code
|
||||
removed_keys = [item["key"] for item in result if item.get("removed")]
|
||||
return {"result": "success", "removed_keys": removed_keys}
|
||||
|
||||
|
||||
def _delete_skill_for_app(
|
||||
*, session: Session, current_user: Account, app_model: App, slug: str, allow_node_id: bool = True
|
||||
):
|
||||
query = query_params_from_request(AgentDriveMutationQuery)
|
||||
node_id = query.node_id if allow_node_id else None
|
||||
agent_id = _resolve_agent_id(session, app_model, node_id)
|
||||
if not agent_id:
|
||||
return _agent_not_bound()
|
||||
if "/" in slug or not slug.strip():
|
||||
return {"code": "drive_key_invalid", "message": "skill slug must be a single path segment"}, 400
|
||||
|
||||
try:
|
||||
result = AgentDriveService().commit(
|
||||
tenant_id=app_model.tenant_id,
|
||||
user_id=current_user.id,
|
||||
agent_id=agent_id,
|
||||
items=[
|
||||
DriveCommitItem(key=f"{slug}/SKILL.md", file_ref=None),
|
||||
DriveCommitItem(key=f"{slug}/.DIFY-SKILL-FULL.zip", file_ref=None),
|
||||
],
|
||||
session=session,
|
||||
)
|
||||
except AgentDriveError as exc:
|
||||
return {"code": exc.code, "message": exc.message}, exc.status_code
|
||||
removed_keys = [item["key"] for item in result if item.get("removed")]
|
||||
return {"result": "success", "removed_keys": removed_keys}
|
||||
|
||||
|
||||
def _infer_skill_tools_for_app(*, session: Session, app_model: App, slug: str):
|
||||
query = query_params_from_request(AgentDriveMutationQuery)
|
||||
agent_id = _resolve_agent_id(session, app_model, query.node_id)
|
||||
if not agent_id:
|
||||
return _agent_not_bound()
|
||||
if "/" in slug or not slug.strip():
|
||||
return {"code": "drive_key_invalid", "message": "skill slug must be a single path segment"}, 400
|
||||
try:
|
||||
return SkillToolInferenceService().infer(
|
||||
tenant_id=app_model.tenant_id, agent_id=agent_id, slug=slug, session=session
|
||||
)
|
||||
except SkillToolInferenceError as exc:
|
||||
return {"code": exc.code, "message": exc.message}, exc.status_code
|
||||
register_response_schema_models(console_ns, AgentLogResponse)
|
||||
|
||||
|
||||
@console_ns.route("/apps/<uuid:app_id>/agent/logs")
|
||||
@@ -344,209 +89,6 @@ class AgentLogApi(Resource):
|
||||
@get_app_model(mode=[AppMode.AGENT_CHAT])
|
||||
@model_validate(AgentLogQuery)
|
||||
def get(self, req_data: AgentLogQuery, session: Session, app_model: App):
|
||||
"""Get agent logs"""
|
||||
"""Get agent logs."""
|
||||
|
||||
return AgentService.get_agent_logs(app_model, req_data.conversation_id, req_data.message_id, session)
|
||||
|
||||
|
||||
@console_ns.route("/agent/<uuid:agent_id>/skills/upload")
|
||||
class AgentSkillUploadByAgentApi(Resource):
|
||||
@console_ns.doc("upload_agent_skill_by_agent")
|
||||
@console_ns.doc(description="Upload + standardize a Skill into an Agent App drive")
|
||||
@console_ns.doc(consumes=["multipart/form-data"], params={"agent_id": "Agent ID", **_AGENT_SKILL_UPLOAD_PARAMS})
|
||||
@console_ns.response(201, "Skill uploaded into drive", console_ns.models[AgentSkillUploadResponse.__name__])
|
||||
@console_ns.response(400, "Invalid skill package or no bound agent")
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
@with_session
|
||||
def post(self, session: Session, tenant_id: str, current_user: Account, agent_id: UUID):
|
||||
app_model = resolve_agent_runtime_app_model(session=session, tenant_id=tenant_id, agent_id=agent_id)
|
||||
return _upload_skill_for_app(session=session, current_user=current_user, app_model=app_model)
|
||||
|
||||
|
||||
@console_ns.route("/apps/<uuid:app_id>/agent/skills/upload")
|
||||
class AgentSkillUploadApi(Resource):
|
||||
@console_ns.doc("upload_agent_skill")
|
||||
@console_ns.doc(description="Upload + standardize a Skill into the agent drive")
|
||||
@console_ns.doc(
|
||||
consumes=["multipart/form-data"],
|
||||
params={
|
||||
"app_id": "Application ID",
|
||||
**query_params_from_model(AgentDriveMutationQuery),
|
||||
**_AGENT_SKILL_UPLOAD_PARAMS,
|
||||
},
|
||||
)
|
||||
@console_ns.response(201, "Skill uploaded into drive", console_ns.models[AgentSkillUploadResponse.__name__])
|
||||
@console_ns.response(400, "Invalid skill package or no bound agent")
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_current_user
|
||||
@with_session
|
||||
@get_app_model(mode=_WORKFLOW_AGENT_DRIVE_APP_MODES)
|
||||
def post(self, session: Session, current_user: Account, app_model: App):
|
||||
"""Upload a Skill, validate it, and commit drive-backed skill files."""
|
||||
return _upload_skill_for_app(session=session, current_user=current_user, app_model=app_model)
|
||||
|
||||
|
||||
@console_ns.route("/agent/<uuid:agent_id>/files")
|
||||
class AgentDriveFilesByAgentApi(Resource):
|
||||
@console_ns.doc("commit_agent_drive_file_by_agent")
|
||||
@console_ns.doc(description="Commit an uploaded file into the Agent App drive under files/<name>")
|
||||
@console_ns.doc(params={"agent_id": "Agent ID"})
|
||||
@console_ns.expect(console_ns.models[AgentDriveFilePayload.__name__])
|
||||
@console_ns.response(
|
||||
201, "File committed into the agent drive", console_ns.models[AgentDriveFileCommitResponse.__name__]
|
||||
)
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
@with_session
|
||||
def post(self, session: Session, tenant_id: str, current_user: Account, agent_id: UUID):
|
||||
app_model = resolve_agent_runtime_app_model(session=session, tenant_id=tenant_id, agent_id=agent_id)
|
||||
return _commit_drive_file_for_app(
|
||||
session=session, current_user=current_user, app_model=app_model, allow_node_id=False
|
||||
)
|
||||
|
||||
@console_ns.doc("delete_agent_drive_file_by_agent")
|
||||
@console_ns.doc(description="Delete one Agent App drive file by key")
|
||||
@console_ns.doc(params={"agent_id": "Agent ID", **query_params_from_model(AgentDriveDeleteFileByAgentQuery)})
|
||||
@console_ns.response(200, "File removed", console_ns.models[AgentDriveDeleteResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
@with_session
|
||||
def delete(self, session: Session, tenant_id: str, current_user: Account, agent_id: UUID):
|
||||
app_model = resolve_agent_runtime_app_model(session=session, tenant_id=tenant_id, agent_id=agent_id)
|
||||
return _delete_drive_file_for_app(
|
||||
session=session, current_user=current_user, app_model=app_model, allow_node_id=False
|
||||
)
|
||||
|
||||
|
||||
@console_ns.route("/apps/<uuid:app_id>/agent/files")
|
||||
class AgentDriveFilesApi(Resource):
|
||||
@console_ns.doc("commit_agent_drive_file")
|
||||
@console_ns.doc(description="Commit an uploaded file into the agent drive under files/<name> (ENG-625 D3)")
|
||||
@console_ns.doc(params={"app_id": "Application ID", **query_params_from_model(AgentDriveMutationQuery)})
|
||||
@console_ns.expect(console_ns.models[AgentDriveFilePayload.__name__])
|
||||
@console_ns.response(
|
||||
201, "File committed into the agent drive", console_ns.models[AgentDriveFileCommitResponse.__name__]
|
||||
)
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_current_user
|
||||
@with_session
|
||||
@get_app_model(mode=_WORKFLOW_AGENT_DRIVE_APP_MODES)
|
||||
def post(self, session: Session, current_user: Account, app_model: App):
|
||||
"""ADD FILE: commit one uploaded file into the bound agent's drive."""
|
||||
return _commit_drive_file_for_app(session=session, current_user=current_user, app_model=app_model)
|
||||
|
||||
@console_ns.doc("delete_agent_drive_file")
|
||||
@console_ns.doc(description="Delete one drive file by key via drive commit-null semantics")
|
||||
@console_ns.doc(params={"app_id": "Application ID", **query_params_from_model(AgentDriveDeleteFileQuery)})
|
||||
@console_ns.response(200, "File removed", console_ns.models[AgentDriveDeleteResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_current_user
|
||||
@with_session
|
||||
@get_app_model(mode=_WORKFLOW_AGENT_DRIVE_APP_MODES)
|
||||
def delete(self, session: Session, current_user: Account, app_model: App):
|
||||
return _delete_drive_file_for_app(session=session, current_user=current_user, app_model=app_model)
|
||||
|
||||
|
||||
@console_ns.route("/agent/<uuid:agent_id>/skills/<string:slug>")
|
||||
class AgentSkillByAgentApi(Resource):
|
||||
@console_ns.doc("delete_agent_skill_by_agent")
|
||||
@console_ns.doc(description="Delete a standardized skill from an Agent App drive")
|
||||
@console_ns.doc(params={"agent_id": "Agent ID", "slug": "Skill slug (single path segment)"})
|
||||
@console_ns.response(200, "Skill removed", console_ns.models[AgentDriveDeleteResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
@with_session
|
||||
def delete(self, session: Session, tenant_id: str, current_user: Account, agent_id: UUID, slug: str):
|
||||
app_model = resolve_agent_runtime_app_model(session=session, tenant_id=tenant_id, agent_id=agent_id)
|
||||
return _delete_skill_for_app(
|
||||
session=session, current_user=current_user, app_model=app_model, slug=slug, allow_node_id=False
|
||||
)
|
||||
|
||||
|
||||
@console_ns.route("/apps/<uuid:app_id>/agent/skills/<string:slug>")
|
||||
class AgentSkillApi(Resource):
|
||||
@console_ns.doc("delete_agent_skill")
|
||||
@console_ns.doc(description="Delete a standardized skill by removing its known drive keys via commit-null")
|
||||
@console_ns.doc(
|
||||
params={
|
||||
"app_id": "Application ID",
|
||||
"slug": "Skill slug (single path segment)",
|
||||
**query_params_from_model(AgentDriveMutationQuery),
|
||||
}
|
||||
)
|
||||
@console_ns.response(200, "Skill removed", console_ns.models[AgentDriveDeleteResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_current_user
|
||||
@with_session
|
||||
@get_app_model(mode=_WORKFLOW_AGENT_DRIVE_APP_MODES)
|
||||
def delete(self, session: Session, current_user: Account, app_model: App, slug: str):
|
||||
return _delete_skill_for_app(session=session, current_user=current_user, app_model=app_model, slug=slug)
|
||||
|
||||
|
||||
@console_ns.route("/agent/<uuid:agent_id>/skills/<string:slug>/infer-tools")
|
||||
class AgentSkillInferToolsByAgentApi(Resource):
|
||||
@console_ns.doc("infer_agent_skill_tools_by_agent")
|
||||
@console_ns.doc(description="Infer CLI tool + ENV suggestions from a standardized Agent App skill")
|
||||
@console_ns.doc(params={"agent_id": "Agent ID", "slug": "Skill slug (single path segment)"})
|
||||
@console_ns.response(
|
||||
200,
|
||||
"Inference result (draft suggestions, nothing persisted)",
|
||||
console_ns.models[SkillToolInferenceResult.__name__],
|
||||
)
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_current_tenant_id
|
||||
@with_session(write=False)
|
||||
def post(self, session: Session, tenant_id: str, agent_id: UUID, slug: str):
|
||||
app_model = resolve_agent_runtime_app_model(session=session, tenant_id=tenant_id, agent_id=agent_id)
|
||||
return _infer_skill_tools_for_app(session=session, app_model=app_model, slug=slug)
|
||||
|
||||
|
||||
@console_ns.route("/apps/<uuid:app_id>/agent/skills/<string:slug>/infer-tools")
|
||||
class AgentSkillInferToolsApi(Resource):
|
||||
@console_ns.doc("infer_agent_skill_tools")
|
||||
@console_ns.doc(
|
||||
description="Infer CLI tool + ENV suggestions from a standardized skill's SKILL.md (draft only, ENG-371)"
|
||||
)
|
||||
@console_ns.doc(
|
||||
params={
|
||||
"app_id": "Application ID",
|
||||
"slug": "Skill slug (single path segment)",
|
||||
**query_params_from_model(AgentDriveMutationQuery),
|
||||
}
|
||||
)
|
||||
@console_ns.response(
|
||||
200,
|
||||
"Inference result (draft suggestions, nothing persisted)",
|
||||
console_ns.models[SkillToolInferenceResult.__name__],
|
||||
)
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_session(write=False)
|
||||
@get_app_model(mode=_WORKFLOW_AGENT_DRIVE_APP_MODES)
|
||||
def post(self, session: Session, app_model: App, slug: str):
|
||||
"""Suggest CLI tools/env for a skill. Saving still goes through composer validation."""
|
||||
return _infer_skill_tools_for_app(session=session, app_model=app_model, slug=slug)
|
||||
|
||||
@@ -10,10 +10,11 @@ from __future__ import annotations
|
||||
from typing import Literal
|
||||
from uuid import UUID
|
||||
|
||||
from dify_agent.client import DifyAgentClientError, DifyAgentHTTPError, DifyAgentTimeoutError
|
||||
from dify_agent.client import DifyAgentClientError, DifyAgentHTTPError
|
||||
from flask_restx import Resource
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from clients.agent_backend.errors import backend_error_detail, backend_reported_failure
|
||||
from controllers.common.schema import (
|
||||
query_params_from_model,
|
||||
query_params_from_request,
|
||||
@@ -135,15 +136,10 @@ register_response_schema_models(
|
||||
def _handle(exc: Exception) -> tuple[dict[str, object], int]:
|
||||
if isinstance(exc, AgentSandboxInspectorError):
|
||||
return {"code": exc.code, "message": exc.message}, exc.status_code
|
||||
if isinstance(exc, DifyAgentHTTPError):
|
||||
detail = exc.detail
|
||||
if isinstance(detail, dict):
|
||||
return {
|
||||
"code": detail.get("code", "agent_backend_error"),
|
||||
"message": detail.get("message", str(exc)),
|
||||
}, exc.status_code
|
||||
return {"code": "agent_backend_error", "message": str(detail)}, exc.status_code
|
||||
if isinstance(exc, DifyAgentTimeoutError | DifyAgentClientError):
|
||||
if isinstance(exc, DifyAgentHTTPError) and backend_reported_failure(exc):
|
||||
code, message = backend_error_detail(exc)
|
||||
return {"code": code, "message": message}, exc.status_code
|
||||
if isinstance(exc, DifyAgentClientError):
|
||||
return {"code": "agent_backend_unreachable", "message": str(exc)}, 502
|
||||
raise exc
|
||||
|
||||
|
||||
@@ -1,434 +0,0 @@
|
||||
"""Console read-only inspector for the agent drive (ENG-624).
|
||||
|
||||
``agent-drive`` looks at the *static* drive assets (standardized skills and
|
||||
committed files); the sibling ``agent-sandbox`` routes look at a *runtime*
|
||||
sandbox workspace. Unlike the sandbox routes this never proxies to the agent
|
||||
backend — drive data lives in the API's own DB/storage, served straight from
|
||||
``AgentDriveService``. Download hands the browser an **external** signed URL
|
||||
(the inner manifest hands agents internal ones — the two must never mix).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from flask import Response
|
||||
from flask_restx import Resource
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from controllers.common.schema import (
|
||||
query_params_from_model,
|
||||
query_params_from_request,
|
||||
register_response_schema_models,
|
||||
)
|
||||
from controllers.common.session import with_session
|
||||
from controllers.console import console_ns
|
||||
from controllers.console.agent.app_helpers import resolve_agent_runtime_app_model
|
||||
from controllers.console.app.wraps import get_app_model
|
||||
from controllers.console.wraps import account_initialization_required, setup_required, with_current_tenant_id
|
||||
from fields.base import ResponseModel
|
||||
from libs.login import login_required
|
||||
from models.model import App, AppMode
|
||||
from services.agent.composer_service import AgentComposerService
|
||||
from services.agent_drive_service import AgentDriveError, AgentDriveService
|
||||
|
||||
|
||||
class AgentDriveListQuery(BaseModel):
|
||||
prefix: str = Field(default="", description="Key prefix filter: '<slug>/' for one skill, 'files/' for files")
|
||||
node_id: str | None = Field(default=None, description="Workflow node ID (workflow composer variant)")
|
||||
|
||||
|
||||
class AgentDriveListByAgentQuery(BaseModel):
|
||||
prefix: str = Field(default="", description="Key prefix filter: '<slug>/' for one skill, 'files/' for files")
|
||||
|
||||
|
||||
class AgentDriveFileQuery(BaseModel):
|
||||
key: str = Field(min_length=1, description="Drive key, e.g. tender-analyzer/SKILL.md")
|
||||
node_id: str | None = Field(default=None, description="Workflow node ID (workflow composer variant)")
|
||||
|
||||
|
||||
class AgentDriveFileByAgentQuery(BaseModel):
|
||||
key: str = Field(min_length=1, description="Drive key, e.g. tender-analyzer/SKILL.md")
|
||||
|
||||
|
||||
class AgentDriveSkillInspectQuery(BaseModel):
|
||||
node_id: str | None = Field(default=None, description="Workflow node ID (workflow composer variant)")
|
||||
|
||||
|
||||
class AgentDriveItemResponse(ResponseModel):
|
||||
key: str
|
||||
size: int | None = None
|
||||
mime_type: str | None = None
|
||||
hash: str | None = None
|
||||
file_kind: str
|
||||
created_at: int | None = None
|
||||
is_skill: bool | None = None
|
||||
skill_metadata: str | None = None
|
||||
|
||||
|
||||
class AgentDriveListResponse(ResponseModel):
|
||||
items: list[AgentDriveItemResponse] = Field(default_factory=list)
|
||||
|
||||
|
||||
class AgentDriveSkillItemResponse(ResponseModel):
|
||||
path: str
|
||||
skill_md_key: str
|
||||
archive_key: str | None = None
|
||||
name: str
|
||||
description: str
|
||||
size: int | None = None
|
||||
mime_type: str | None = None
|
||||
hash: str | None = None
|
||||
created_at: int | None = None
|
||||
|
||||
|
||||
class AgentDriveSkillListResponse(ResponseModel):
|
||||
items: list[AgentDriveSkillItemResponse] = Field(default_factory=list)
|
||||
|
||||
|
||||
class AgentDriveSkillFileResponse(ResponseModel):
|
||||
path: str
|
||||
name: str
|
||||
type: str
|
||||
drive_key: str | None = None
|
||||
available_in_drive: bool
|
||||
|
||||
|
||||
class AgentDriveSkillMarkdownResponse(ResponseModel):
|
||||
key: str
|
||||
size: int | None = None
|
||||
truncated: bool
|
||||
binary: bool
|
||||
text: str | None = None
|
||||
|
||||
|
||||
class AgentDriveSkillInspectResponse(ResponseModel):
|
||||
path: str
|
||||
skill_md_key: str
|
||||
archive_key: str | None = None
|
||||
name: str
|
||||
description: str
|
||||
size: int | None = None
|
||||
mime_type: str | None = None
|
||||
hash: str | None = None
|
||||
created_at: int | None = None
|
||||
source: str
|
||||
files: list[AgentDriveSkillFileResponse] = Field(default_factory=list)
|
||||
file_tree: list[dict[str, Any]] = Field(default_factory=list)
|
||||
skill_md: AgentDriveSkillMarkdownResponse
|
||||
warnings: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class AgentDrivePreviewResponse(ResponseModel):
|
||||
key: str
|
||||
size: int | None = None
|
||||
truncated: bool
|
||||
binary: bool
|
||||
text: str | None = None
|
||||
|
||||
|
||||
class AgentDriveDownloadResponse(ResponseModel):
|
||||
url: str
|
||||
|
||||
|
||||
register_response_schema_models(
|
||||
console_ns,
|
||||
AgentDriveDownloadResponse,
|
||||
AgentDriveListResponse,
|
||||
AgentDrivePreviewResponse,
|
||||
AgentDriveSkillInspectResponse,
|
||||
AgentDriveSkillListResponse,
|
||||
)
|
||||
|
||||
|
||||
def _resolve_agent_id(session: Session, app_model: App, node_id: str | None) -> str | None:
|
||||
"""Agent identity for the drive: app-bound agent, or the workflow node binding."""
|
||||
if node_id:
|
||||
return AgentComposerService.resolve_workflow_node_agent_id(
|
||||
session=session, tenant_id=app_model.tenant_id, app_id=app_model.id, node_id=node_id
|
||||
)
|
||||
return app_model.bound_agent_id_with_session(session=session)
|
||||
|
||||
|
||||
def _agent_not_bound() -> tuple[dict[str, object], int]:
|
||||
return {"code": "agent_not_bound", "message": "no agent is bound for this app/node"}, 400
|
||||
|
||||
|
||||
def _handle(exc: AgentDriveError) -> tuple[dict[str, object], int]:
|
||||
return {"code": exc.code, "message": exc.message}, exc.status_code
|
||||
|
||||
|
||||
def _json_response(data: Mapping[str, Any]):
|
||||
return Response(
|
||||
response=json.dumps(data, ensure_ascii=False, separators=(",", ":")),
|
||||
content_type="application/json; charset=utf-8",
|
||||
)
|
||||
|
||||
|
||||
_WORKFLOW_APP_MODES = [AppMode.WORKFLOW, AppMode.ADVANCED_CHAT]
|
||||
|
||||
|
||||
@console_ns.route("/agent/<uuid:agent_id>/drive/files")
|
||||
class AgentDriveListByAgentApi(Resource):
|
||||
@console_ns.doc("list_agent_drive_files_by_agent")
|
||||
@console_ns.doc(description="List agent drive entries for an Agent App")
|
||||
@console_ns.doc(params={"agent_id": "Agent ID", **query_params_from_model(AgentDriveListByAgentQuery)})
|
||||
@console_ns.response(200, "Drive entries", console_ns.models[AgentDriveListResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_current_tenant_id
|
||||
@with_session(write=False)
|
||||
def get(self, session: Session, tenant_id: str, agent_id: UUID):
|
||||
query = query_params_from_request(AgentDriveListByAgentQuery)
|
||||
resolve_agent_runtime_app_model(session=session, tenant_id=tenant_id, agent_id=agent_id)
|
||||
try:
|
||||
items = AgentDriveService().manifest(
|
||||
tenant_id=tenant_id, agent_id=str(agent_id), prefix=query.prefix, session=session
|
||||
)
|
||||
except AgentDriveError as exc:
|
||||
return _handle(exc)
|
||||
return {"items": [{k: v for k, v in item.items() if k != "file_id"} for item in items]}
|
||||
|
||||
|
||||
@console_ns.route("/agent/<uuid:agent_id>/drive/skills")
|
||||
class AgentDriveSkillListByAgentApi(Resource):
|
||||
@console_ns.doc("list_agent_drive_skills_by_agent")
|
||||
@console_ns.doc(description="List drive-backed skills for an Agent App")
|
||||
@console_ns.doc(params={"agent_id": "Agent ID"})
|
||||
@console_ns.response(200, "Drive skills", console_ns.models[AgentDriveSkillListResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_current_tenant_id
|
||||
@with_session(write=False)
|
||||
def get(self, session: Session, tenant_id: str, agent_id: UUID):
|
||||
resolve_agent_runtime_app_model(session=session, tenant_id=tenant_id, agent_id=agent_id)
|
||||
try:
|
||||
items = AgentDriveService().list_skills(tenant_id=tenant_id, agent_id=str(agent_id), session=session)
|
||||
except AgentDriveError as exc:
|
||||
return _handle(exc)
|
||||
return {"items": items}
|
||||
|
||||
|
||||
@console_ns.route("/agent/<uuid:agent_id>/drive/skills/<path:skill_path>/inspect")
|
||||
class AgentDriveSkillInspectByAgentApi(Resource):
|
||||
@console_ns.doc("inspect_agent_drive_skill_by_agent")
|
||||
@console_ns.doc(description="Inspect one drive-backed skill for slash-menu hover/detail UI")
|
||||
@console_ns.doc(params={"agent_id": "Agent ID", "skill_path": "Skill path/slug, e.g. tender-analyzer"})
|
||||
@console_ns.response(200, "Drive skill inspect view", console_ns.models[AgentDriveSkillInspectResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_current_tenant_id
|
||||
@with_session(write=False)
|
||||
def get(self, session: Session, tenant_id: str, agent_id: UUID, skill_path: str):
|
||||
resolve_agent_runtime_app_model(session=session, tenant_id=tenant_id, agent_id=agent_id)
|
||||
try:
|
||||
return _json_response(
|
||||
AgentDriveService().inspect_skill(
|
||||
tenant_id=tenant_id,
|
||||
agent_id=str(agent_id),
|
||||
skill_path=skill_path,
|
||||
session=session,
|
||||
)
|
||||
)
|
||||
except AgentDriveError as exc:
|
||||
return _handle(exc)
|
||||
|
||||
|
||||
@console_ns.route("/agent/<uuid:agent_id>/drive/files/preview")
|
||||
class AgentDrivePreviewByAgentApi(Resource):
|
||||
@console_ns.doc("preview_agent_drive_file_by_agent")
|
||||
@console_ns.doc(description="Truncated text preview of one Agent App drive value")
|
||||
@console_ns.doc(params={"agent_id": "Agent ID", **query_params_from_model(AgentDriveFileByAgentQuery)})
|
||||
@console_ns.response(200, "Preview", console_ns.models[AgentDrivePreviewResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_current_tenant_id
|
||||
@with_session(write=False)
|
||||
def get(self, session: Session, tenant_id: str, agent_id: UUID):
|
||||
query = query_params_from_request(AgentDriveFileByAgentQuery)
|
||||
resolve_agent_runtime_app_model(session=session, tenant_id=tenant_id, agent_id=agent_id)
|
||||
try:
|
||||
return AgentDriveService().preview(
|
||||
tenant_id=tenant_id, agent_id=str(agent_id), key=query.key, session=session
|
||||
)
|
||||
except AgentDriveError as exc:
|
||||
return _handle(exc)
|
||||
|
||||
|
||||
@console_ns.route("/agent/<uuid:agent_id>/drive/files/download")
|
||||
class AgentDriveDownloadByAgentApi(Resource):
|
||||
@console_ns.doc("download_agent_drive_file_by_agent")
|
||||
@console_ns.doc(description="Time-limited external signed URL for one Agent App drive value")
|
||||
@console_ns.doc(params={"agent_id": "Agent ID", **query_params_from_model(AgentDriveFileByAgentQuery)})
|
||||
@console_ns.response(200, "Signed URL", console_ns.models[AgentDriveDownloadResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_current_tenant_id
|
||||
@with_session(write=False)
|
||||
def get(self, session: Session, tenant_id: str, agent_id: UUID):
|
||||
query = query_params_from_request(AgentDriveFileByAgentQuery)
|
||||
resolve_agent_runtime_app_model(session=session, tenant_id=tenant_id, agent_id=agent_id)
|
||||
try:
|
||||
url = AgentDriveService().download_url(
|
||||
tenant_id=tenant_id, agent_id=str(agent_id), key=query.key, session=session
|
||||
)
|
||||
except AgentDriveError as exc:
|
||||
return _handle(exc)
|
||||
return {"url": url}
|
||||
|
||||
|
||||
@console_ns.route("/apps/<uuid:app_id>/agent/drive/files")
|
||||
class AgentDriveListApi(Resource):
|
||||
@console_ns.doc("list_agent_drive_files")
|
||||
@console_ns.doc(description="List agent drive entries (read-only inspector; one endpoint for both tabs)")
|
||||
@console_ns.doc(params={"app_id": "Application ID", **query_params_from_model(AgentDriveListQuery)})
|
||||
@console_ns.response(200, "Drive entries", console_ns.models[AgentDriveListResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_session(write=False)
|
||||
@get_app_model(mode=_WORKFLOW_APP_MODES)
|
||||
def get(self, session: Session, app_model: App):
|
||||
query = query_params_from_request(AgentDriveListQuery)
|
||||
agent_id = _resolve_agent_id(session, app_model, query.node_id)
|
||||
if not agent_id:
|
||||
return _agent_not_bound()
|
||||
try:
|
||||
items = AgentDriveService().manifest(
|
||||
tenant_id=app_model.tenant_id, agent_id=agent_id, prefix=query.prefix, session=session
|
||||
)
|
||||
except AgentDriveError as exc:
|
||||
return _handle(exc)
|
||||
# the inner manifest exposes file_id for agent-side pulls; the console
|
||||
# inspector is a pure read surface and does not need value pointers
|
||||
return {"items": [{k: v for k, v in item.items() if k != "file_id"} for item in items]}
|
||||
|
||||
|
||||
@console_ns.route("/apps/<uuid:app_id>/agent/drive/skills")
|
||||
class AgentDriveSkillListApi(Resource):
|
||||
@console_ns.doc("list_agent_drive_skills")
|
||||
@console_ns.doc(description="List drive-backed skills for the bound agent")
|
||||
@console_ns.doc(params={"app_id": "Application ID", **query_params_from_model(AgentDriveListQuery)})
|
||||
@console_ns.response(200, "Drive skills", console_ns.models[AgentDriveSkillListResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_session(write=False)
|
||||
@get_app_model(mode=_WORKFLOW_APP_MODES)
|
||||
def get(self, session: Session, app_model: App):
|
||||
query = query_params_from_request(AgentDriveListQuery)
|
||||
agent_id = _resolve_agent_id(session, app_model, query.node_id)
|
||||
if not agent_id:
|
||||
return _agent_not_bound()
|
||||
try:
|
||||
items = AgentDriveService().list_skills(tenant_id=app_model.tenant_id, agent_id=agent_id, session=session)
|
||||
except AgentDriveError as exc:
|
||||
return _handle(exc)
|
||||
return {"items": items}
|
||||
|
||||
|
||||
@console_ns.route("/apps/<uuid:app_id>/agent/drive/skills/<path:skill_path>/inspect")
|
||||
class AgentDriveSkillInspectApi(Resource):
|
||||
@console_ns.doc("inspect_agent_drive_skill")
|
||||
@console_ns.doc(description="Inspect one drive-backed skill for slash-menu hover/detail UI")
|
||||
@console_ns.doc(
|
||||
params={
|
||||
"app_id": "Application ID",
|
||||
"skill_path": "Skill path/slug, e.g. tender-analyzer",
|
||||
**query_params_from_model(AgentDriveSkillInspectQuery),
|
||||
}
|
||||
)
|
||||
@console_ns.response(200, "Drive skill inspect view", console_ns.models[AgentDriveSkillInspectResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_session(write=False)
|
||||
@get_app_model(mode=_WORKFLOW_APP_MODES)
|
||||
def get(self, session: Session, app_model: App, skill_path: str):
|
||||
query = query_params_from_request(AgentDriveSkillInspectQuery)
|
||||
agent_id = _resolve_agent_id(session, app_model, query.node_id)
|
||||
if not agent_id:
|
||||
return _agent_not_bound()
|
||||
try:
|
||||
return _json_response(
|
||||
AgentDriveService().inspect_skill(
|
||||
tenant_id=app_model.tenant_id,
|
||||
agent_id=agent_id,
|
||||
skill_path=skill_path,
|
||||
session=session,
|
||||
)
|
||||
)
|
||||
except AgentDriveError as exc:
|
||||
return _handle(exc)
|
||||
|
||||
|
||||
@console_ns.route("/apps/<uuid:app_id>/agent/drive/files/preview")
|
||||
class AgentDrivePreviewApi(Resource):
|
||||
@console_ns.doc("preview_agent_drive_file")
|
||||
@console_ns.doc(description="Truncated text preview of one drive value (binary-safe; SKILL.md is the main case)")
|
||||
@console_ns.doc(params={"app_id": "Application ID", **query_params_from_model(AgentDriveFileQuery)})
|
||||
@console_ns.response(200, "Preview", console_ns.models[AgentDrivePreviewResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_session(write=False)
|
||||
@get_app_model(mode=_WORKFLOW_APP_MODES)
|
||||
def get(self, session: Session, app_model: App):
|
||||
query = query_params_from_request(AgentDriveFileQuery)
|
||||
agent_id = _resolve_agent_id(session, app_model, query.node_id)
|
||||
if not agent_id:
|
||||
return _agent_not_bound()
|
||||
try:
|
||||
return AgentDriveService().preview(
|
||||
tenant_id=app_model.tenant_id, agent_id=agent_id, key=query.key, session=session
|
||||
)
|
||||
except AgentDriveError as exc:
|
||||
return _handle(exc)
|
||||
|
||||
|
||||
@console_ns.route("/apps/<uuid:app_id>/agent/drive/files/download")
|
||||
class AgentDriveDownloadApi(Resource):
|
||||
@console_ns.doc("download_agent_drive_file")
|
||||
@console_ns.doc(description="Time-limited external signed URL for one drive value (no streaming proxy)")
|
||||
@console_ns.doc(params={"app_id": "Application ID", **query_params_from_model(AgentDriveFileQuery)})
|
||||
@console_ns.response(200, "Signed URL", console_ns.models[AgentDriveDownloadResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_session(write=False)
|
||||
@get_app_model(mode=_WORKFLOW_APP_MODES)
|
||||
def get(self, session: Session, app_model: App):
|
||||
query = query_params_from_request(AgentDriveFileQuery)
|
||||
agent_id = _resolve_agent_id(session, app_model, query.node_id)
|
||||
if not agent_id:
|
||||
return _agent_not_bound()
|
||||
try:
|
||||
url = AgentDriveService().download_url(
|
||||
tenant_id=app_model.tenant_id, agent_id=agent_id, key=query.key, session=session
|
||||
)
|
||||
except AgentDriveError as exc:
|
||||
return _handle(exc)
|
||||
return {"url": url}
|
||||
|
||||
|
||||
__all__ = [
|
||||
"AgentDriveDownloadApi",
|
||||
"AgentDriveDownloadByAgentApi",
|
||||
"AgentDriveListApi",
|
||||
"AgentDriveListByAgentApi",
|
||||
"AgentDrivePreviewApi",
|
||||
"AgentDrivePreviewByAgentApi",
|
||||
"AgentDriveSkillInspectApi",
|
||||
"AgentDriveSkillInspectByAgentApi",
|
||||
"AgentDriveSkillListApi",
|
||||
"AgentDriveSkillListByAgentApi",
|
||||
]
|
||||
@@ -364,6 +364,7 @@ class InstructionGenerateApi(Resource):
|
||||
case "llm":
|
||||
return LLMGenerator.generate_rule_config(
|
||||
current_tenant_id,
|
||||
app_id=req_data.flow_id,
|
||||
args=RuleGeneratePayload(
|
||||
instruction=req_data.instruction,
|
||||
model_config=req_data.model_config_data,
|
||||
@@ -373,6 +374,7 @@ class InstructionGenerateApi(Resource):
|
||||
case "agent":
|
||||
return LLMGenerator.generate_rule_config(
|
||||
current_tenant_id,
|
||||
app_id=req_data.flow_id,
|
||||
args=RuleGeneratePayload(
|
||||
instruction=req_data.instruction,
|
||||
model_config=req_data.model_config_data,
|
||||
@@ -382,6 +384,7 @@ class InstructionGenerateApi(Resource):
|
||||
case "code":
|
||||
return LLMGenerator.generate_code(
|
||||
tenant_id=current_tenant_id,
|
||||
app_id=req_data.flow_id,
|
||||
args=RuleCodeGeneratePayload(
|
||||
instruction=req_data.instruction,
|
||||
model_config=req_data.model_config_data,
|
||||
|
||||
@@ -190,7 +190,7 @@ class AppMCPServerRefreshController(Resource):
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@edit_permission_required
|
||||
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_VIEW_LAYOUT)
|
||||
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_EDIT)
|
||||
@with_current_tenant_id
|
||||
@get_app_model
|
||||
def post(self, current_tenant_id: str, app_model: App):
|
||||
|
||||
@@ -20,6 +20,8 @@ from sqlalchemy.orm import Session, sessionmaker
|
||||
from werkzeug.exceptions import BadRequest, Forbidden, InternalServerError, NotFound
|
||||
|
||||
import services
|
||||
from configs import dify_config
|
||||
from controllers.common.app_access import resolve_app_access_filter
|
||||
from controllers.common.controller_schemas import DefaultBlockConfigQuery, WorkflowListQuery, WorkflowUpdatePayload
|
||||
from controllers.common.errors import InvalidArgumentError
|
||||
from controllers.common.fields import GeneratedAppResponse, NewAppResponse, SimpleResultResponse
|
||||
@@ -53,6 +55,7 @@ from core.app.apps.base_app_queue_manager import AppQueueManager
|
||||
from core.app.apps.workflow.app_generator import SKIP_PREPARE_USER_INPUTS_KEY
|
||||
from core.app.entities.app_invoke_entities import InvokeFrom
|
||||
from core.app.file_access import DatabaseFileAccessController
|
||||
from core.db.session_factory import session_factory
|
||||
from core.helper import encrypter
|
||||
from core.helper.trace_id_helper import get_external_trace_id
|
||||
from core.plugin.impl.exc import PluginInvokeError
|
||||
@@ -95,7 +98,6 @@ from services.errors.app import IsDraftWorkflowError, WorkflowHashNotEqualError,
|
||||
from services.errors.llm import InvokeRateLimitError
|
||||
from services.workflow_ref_service import WorkflowRefService
|
||||
from services.workflow_service import DraftWorkflowDeletionError, WorkflowInUseError, WorkflowService
|
||||
from tasks.collect_agent_resources_task import enqueue_agent_resource_collection
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -1303,7 +1305,7 @@ class PublishedWorkflowApi(Resource):
|
||||
|
||||
workflow_service = WorkflowService()
|
||||
with sessionmaker(db.engine).begin() as session:
|
||||
workflow, retirement_candidates = workflow_service.publish_workflow(
|
||||
workflow = workflow_service.publish_workflow(
|
||||
session=session,
|
||||
app_model=app_model,
|
||||
account=current_user,
|
||||
@@ -1320,16 +1322,6 @@ class PublishedWorkflowApi(Resource):
|
||||
|
||||
workflow_created_at = TimestampField().format(workflow.created_at)
|
||||
|
||||
binding_ids, home_snapshot_ids = WorkflowAgentRetirementService.retire_unowned(
|
||||
tenant_id=app_model.tenant_id,
|
||||
agent_ids=retirement_candidates,
|
||||
account_id=current_user.id,
|
||||
)
|
||||
enqueue_agent_resource_collection(
|
||||
tenant_id=app_model.tenant_id,
|
||||
binding_ids=binding_ids,
|
||||
home_snapshot_ids=home_snapshot_ids,
|
||||
)
|
||||
return {
|
||||
"result": "success",
|
||||
"created_at": workflow_created_at,
|
||||
@@ -1620,10 +1612,11 @@ class WorkflowByIdApi(Resource):
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW])
|
||||
@with_current_user
|
||||
@edit_permission_required
|
||||
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_EDIT)
|
||||
@console_ns.response(204, "Workflow deleted successfully")
|
||||
def delete(self, app_model: App, workflow_id: str):
|
||||
def delete(self, current_user: Account, app_model: App, workflow_id: str):
|
||||
"""
|
||||
Delete workflow
|
||||
"""
|
||||
@@ -1633,7 +1626,7 @@ class WorkflowByIdApi(Resource):
|
||||
# Create a session and manage the transaction
|
||||
with sessionmaker(db.engine).begin() as session:
|
||||
try:
|
||||
workflow_service.delete_workflow(
|
||||
retirement_candidates = workflow_service.delete_workflow(
|
||||
session=session,
|
||||
workflow_ref=workflow_ref,
|
||||
)
|
||||
@@ -1644,6 +1637,11 @@ class WorkflowByIdApi(Resource):
|
||||
except ValueError as e:
|
||||
raise NotFound(str(e))
|
||||
|
||||
WorkflowAgentRetirementService.retire_unowned(
|
||||
tenant_id=app_model.tenant_id,
|
||||
agent_ids=retirement_candidates,
|
||||
account_id=current_user.id,
|
||||
)
|
||||
return None, 204
|
||||
|
||||
|
||||
@@ -1929,8 +1927,9 @@ class WorkflowOnlineUsersApi(Resource):
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
def post(self, current_tenant_id: str):
|
||||
def post(self, current_tenant_id: str, current_user: Account):
|
||||
args = WorkflowOnlineUsersPayload.model_validate(console_ns.payload or {})
|
||||
|
||||
app_ids = args.app_ids
|
||||
@@ -1940,8 +1939,20 @@ class WorkflowOnlineUsersApi(Resource):
|
||||
if not app_ids:
|
||||
return {"data": []}
|
||||
|
||||
access_filter = None
|
||||
workflow_service = WorkflowService()
|
||||
accessible_app_ids = workflow_service.get_accessible_app_ids(app_ids, current_tenant_id, session=db.session())
|
||||
with session_factory.create_session() as session:
|
||||
if dify_config.RBAC_ENABLED:
|
||||
access_filter = resolve_app_access_filter(current_tenant_id, current_user.id, session=session)
|
||||
app_maintainers = workflow_service.get_tenant_app_maintainers(app_ids, current_tenant_id, session=session)
|
||||
|
||||
accessible_app_ids = set(app_maintainers)
|
||||
if access_filter is not None:
|
||||
accessible_app_ids = {
|
||||
app_id
|
||||
for app_id, maintainer in app_maintainers.items()
|
||||
if access_filter.is_app_accessible(app_id, maintainer, current_user.id)
|
||||
}
|
||||
ordered_accessible_app_ids = [app_id for app_id in app_ids if app_id in accessible_app_ids]
|
||||
|
||||
users_json_by_app_id: dict[str, Any] = {}
|
||||
|
||||
@@ -3,6 +3,7 @@ from datetime import datetime
|
||||
|
||||
from flask_restx import Resource
|
||||
from pydantic import BaseModel, Field, TypeAdapter, computed_field, field_validator
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from controllers.common.schema import register_response_schema_models, register_schema_models
|
||||
from controllers.console import console_ns
|
||||
@@ -21,6 +22,15 @@ from fields.member_fields import AccountWithRole
|
||||
from libs.helper import build_avatar_url, dump_response, to_timestamp
|
||||
from libs.login import login_required
|
||||
from models import Account, App
|
||||
from models.comment import (
|
||||
WorkflowComment as WorkflowCommentModel,
|
||||
)
|
||||
from models.comment import (
|
||||
WorkflowCommentMention as WorkflowCommentMentionModel,
|
||||
)
|
||||
from models.comment import (
|
||||
WorkflowCommentReply as WorkflowCommentReplyModel,
|
||||
)
|
||||
from services.account_service import TenantService
|
||||
from services.workflow_comment_service import WorkflowCommentService
|
||||
|
||||
@@ -133,6 +143,73 @@ class WorkflowCommentDetail(ResponseModel):
|
||||
return to_timestamp(value)
|
||||
|
||||
|
||||
def _workflow_comment_reply_response(reply: WorkflowCommentReplyModel, *, session: Session) -> WorkflowCommentReply:
|
||||
return WorkflowCommentReply.model_validate(
|
||||
{
|
||||
"id": reply.id,
|
||||
"content": reply.content,
|
||||
"created_by": reply.created_by,
|
||||
"created_by_account": reply.created_by_account(session),
|
||||
"created_at": reply.created_at,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _workflow_comment_mention_response(
|
||||
mention: WorkflowCommentMentionModel, *, session: Session
|
||||
) -> WorkflowCommentMention:
|
||||
return WorkflowCommentMention.model_validate(
|
||||
{
|
||||
"mentioned_user_id": mention.mentioned_user_id,
|
||||
"mentioned_user_account": mention.mentioned_user_account(session),
|
||||
"reply_id": mention.reply_id,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _workflow_comment_basic_response(comment: WorkflowCommentModel, *, session: Session) -> WorkflowCommentBasic:
|
||||
return WorkflowCommentBasic.model_validate(
|
||||
{
|
||||
"id": comment.id,
|
||||
"position_x": comment.position_x,
|
||||
"position_y": comment.position_y,
|
||||
"content": comment.content,
|
||||
"created_by": comment.created_by,
|
||||
"created_by_account": comment.created_by_account(session),
|
||||
"created_at": comment.created_at,
|
||||
"updated_at": comment.updated_at,
|
||||
"resolved": comment.resolved,
|
||||
"resolved_at": comment.resolved_at,
|
||||
"resolved_by": comment.resolved_by,
|
||||
"resolved_by_account": comment.resolved_by_account(session),
|
||||
"reply_count": comment.reply_count,
|
||||
"mention_count": comment.mention_count,
|
||||
"participants": comment.participants(session),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _workflow_comment_detail_response(comment: WorkflowCommentModel, *, session: Session) -> WorkflowCommentDetail:
|
||||
return WorkflowCommentDetail.model_validate(
|
||||
{
|
||||
"id": comment.id,
|
||||
"position_x": comment.position_x,
|
||||
"position_y": comment.position_y,
|
||||
"content": comment.content,
|
||||
"created_by": comment.created_by,
|
||||
"created_by_account": comment.created_by_account(session),
|
||||
"created_at": comment.created_at,
|
||||
"updated_at": comment.updated_at,
|
||||
"resolved": comment.resolved,
|
||||
"resolved_at": comment.resolved_at,
|
||||
"resolved_by": comment.resolved_by,
|
||||
"resolved_by_account": comment.resolved_by_account(session),
|
||||
"replies": [_workflow_comment_reply_response(reply, session=session) for reply in comment.replies],
|
||||
"mentions": [_workflow_comment_mention_response(mention, session=session) for mention in comment.mentions],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class WorkflowCommentCreate(ResponseModel):
|
||||
id: str
|
||||
created_at: int | None = None
|
||||
@@ -225,8 +302,11 @@ class WorkflowCommentListApi(Resource):
|
||||
def get(self, current_tenant_id: str, app_model: App):
|
||||
"""Get all comments for a workflow."""
|
||||
comments = WorkflowCommentService.get_comments(tenant_id=current_tenant_id, app_id=app_model.id)
|
||||
session = db.session()
|
||||
|
||||
return WorkflowCommentBasicList.model_validate({"data": comments}).model_dump(mode="json")
|
||||
return WorkflowCommentBasicList.model_validate(
|
||||
{"data": [_workflow_comment_basic_response(comment, session=session) for comment in comments]}
|
||||
).model_dump(mode="json")
|
||||
|
||||
@console_ns.doc("create_workflow_comment")
|
||||
@console_ns.doc(description="Create a new workflow comment")
|
||||
@@ -281,8 +361,9 @@ class WorkflowCommentDetailApi(Resource):
|
||||
comment = WorkflowCommentService.get_comment(
|
||||
tenant_id=current_tenant_id, app_id=app_model.id, comment_id=comment_id
|
||||
)
|
||||
session = db.session()
|
||||
|
||||
return dump_response(WorkflowCommentDetail, comment)
|
||||
return dump_response(WorkflowCommentDetail, _workflow_comment_detail_response(comment, session=session))
|
||||
|
||||
@console_ns.doc("update_workflow_comment")
|
||||
@console_ns.doc(description="Update a workflow comment")
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
"""Controller decorators for console app resources.
|
||||
|
||||
`get_app_model` still supports legacy handlers backed by Flask-SQLAlchemy's
|
||||
scoped session. Trial app handlers compose `get_app_model_with_trial` under
|
||||
`controllers.common.session.with_session` and always reuse that request session.
|
||||
scoped session. Preview handlers compose `get_previewable_app_model` under
|
||||
`controllers.common.session.with_session`; preview admission finishes before
|
||||
the request Session loads the accepted App.
|
||||
"""
|
||||
|
||||
from collections.abc import Callable
|
||||
@@ -16,16 +17,17 @@ from configs import dify_config
|
||||
from controllers.common.session import with_session
|
||||
from controllers.common.wraps import RBACPermission, RBACResourceScope, enforce_rbac_access
|
||||
from controllers.console.app.error import AppNotFoundError
|
||||
from extensions.ext_application_services import application_services
|
||||
from extensions.ext_database import db
|
||||
from libs.login import current_account_with_tenant
|
||||
from models import App, AppMode, TrialApp
|
||||
from models import App, AppMode
|
||||
from models.agent import AgentScope
|
||||
from services.recommended_app_service import RecommendedAppService
|
||||
from services.app_service import AppService
|
||||
|
||||
__all__ = [
|
||||
"agent_manage_required_for_agent_app",
|
||||
"get_app_model",
|
||||
"get_app_model_with_trial",
|
||||
"get_previewable_app_model",
|
||||
"with_session",
|
||||
]
|
||||
|
||||
@@ -48,12 +50,11 @@ def _load_app_model_from_scoped_session(app_id: str) -> App | None:
|
||||
return app_model
|
||||
|
||||
|
||||
def _load_app_model_with_trial(session: Session, app_id: str) -> App | None:
|
||||
"""Load a normal app through its trial registration without applying current-tenant scope."""
|
||||
app_model = session.scalar(
|
||||
select(App).join(TrialApp, TrialApp.app_id == App.id).where(App.id == app_id, App.status == "normal").limit(1)
|
||||
)
|
||||
return app_model
|
||||
def _load_previewable_app_model(session: Session, app_id: str) -> App | None:
|
||||
"""Load a normal App after preview admission completes outside the request Session."""
|
||||
if not application_services().recommended_app_queries.is_previewable(app_id):
|
||||
return None
|
||||
return AppService.get_normal_app_by_id(app_id, session)
|
||||
|
||||
|
||||
def agent_manage_required_for_agent_app[**P, R](view: Callable[P, R]) -> Callable[P, R]:
|
||||
@@ -183,7 +184,7 @@ def get_app_model[**P, R](
|
||||
|
||||
|
||||
@overload
|
||||
def get_app_model_with_trial[**P, R](
|
||||
def get_previewable_app_model[**P, R](
|
||||
view: Callable[P, R],
|
||||
*,
|
||||
mode: AppMode | list[AppMode] | None = None,
|
||||
@@ -191,19 +192,25 @@ def get_app_model_with_trial[**P, R](
|
||||
|
||||
|
||||
@overload
|
||||
def get_app_model_with_trial[**P, R](
|
||||
def get_previewable_app_model[**P, R](
|
||||
view: None = None,
|
||||
*,
|
||||
mode: AppMode | list[AppMode] | None = None,
|
||||
) -> Callable[[Callable[P, R]], Callable[P, R]]: ...
|
||||
|
||||
|
||||
def get_app_model_with_trial[**P, R](
|
||||
def get_previewable_app_model[**P, R](
|
||||
view: Callable[P, R] | None = None,
|
||||
*,
|
||||
mode: AppMode | list[AppMode] | None = None,
|
||||
) -> Callable[P, R] | Callable[[Callable[P, R]], Callable[P, R]]:
|
||||
"""Inject a trial-registered or recommended App using the Session supplied by `with_session`."""
|
||||
"""Inject an App authorized for read-only template preview.
|
||||
|
||||
Preview reads accept either an explicit TrialApp registration or membership
|
||||
in the recommended catalog. This does not grant trial execution, which is
|
||||
separately protected by TrialAppResource's feature, registration, and quota
|
||||
checks.
|
||||
"""
|
||||
|
||||
def decorator(view_func: Callable[P, R]) -> Callable[P, R]:
|
||||
@wraps(view_func)
|
||||
@@ -218,10 +225,8 @@ def get_app_model_with_trial[**P, R](
|
||||
|
||||
session = _get_injected_session(args)
|
||||
if session is None:
|
||||
raise RuntimeError("get_app_model_with_trial requires @with_session")
|
||||
app_model = _load_app_model_with_trial(session, app_id)
|
||||
if app_model is None:
|
||||
app_model = RecommendedAppService.get_app(app_id, session=session)
|
||||
raise RuntimeError("get_previewable_app_model requires @with_session")
|
||||
app_model = _load_previewable_app_model(session, app_id)
|
||||
|
||||
if not app_model:
|
||||
raise AppNotFoundError()
|
||||
|
||||
@@ -6,11 +6,14 @@ from constants.languages import supported_language
|
||||
from controllers.common.schema import query_params_from_model, register_schema_models
|
||||
from controllers.console import console_ns
|
||||
from controllers.console.auth.error import InvitationAccountMismatchError as InvitationAccountMismatchHTTPError
|
||||
from controllers.console.error import AccountInFreezeError, AlreadyActivateError
|
||||
from controllers.console.error import AccountInFreezeError, AlreadyActivateError, EmailDomainSuspendedError
|
||||
from extensions.ext_application_services import application_services
|
||||
from libs.helper import EmailStr, dump_response, timezone
|
||||
from libs.login import current_account_with_tenant
|
||||
from libs.token import extract_access_token
|
||||
from services.account_activation_service import (
|
||||
EmailDomainSuspendedError as EmailDomainSuspendedRegistrationError,
|
||||
)
|
||||
from services.account_activation_service import (
|
||||
FrozenAccountError,
|
||||
InvalidInvitationError,
|
||||
@@ -141,6 +144,8 @@ class ActivateApi(Resource):
|
||||
raise AlreadyActivateError() from None
|
||||
except InvitationAccountMismatchError:
|
||||
raise InvitationAccountMismatchHTTPError() from None
|
||||
except EmailDomainSuspendedRegistrationError:
|
||||
raise EmailDomainSuspendedError() from None
|
||||
except FrozenAccountError:
|
||||
raise AccountInFreezeError() from None
|
||||
|
||||
|
||||
@@ -24,9 +24,15 @@ from libs.password import valid_password
|
||||
from models import Account
|
||||
from services.account_service import AccountService
|
||||
from services.billing_service import BillingService
|
||||
from services.errors.account import AccountRegisterError, SeatsLimitExceededError
|
||||
from services.errors.account import (
|
||||
AccountRegisterError,
|
||||
SeatsLimitExceededError,
|
||||
)
|
||||
from services.errors.account import (
|
||||
EmailDomainSuspendedError as EmailDomainSuspendedRegistrationError,
|
||||
)
|
||||
|
||||
from ..error import AccountInFreezeError, EmailSendIpLimitError, SeatsLimitExceeded
|
||||
from ..error import AccountInFreezeError, EmailDomainSuspendedError, EmailSendIpLimitError, SeatsLimitExceeded
|
||||
from ..wraps import email_password_login_enabled, email_register_enabled, model_validate, setup_required
|
||||
|
||||
|
||||
@@ -99,10 +105,12 @@ class EmailRegisterSendEmailApi(Resource):
|
||||
if req_data.language is not None and req_data.language in languages:
|
||||
language = req_data.language
|
||||
|
||||
if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD and BillingService.is_email_in_freeze(
|
||||
normalized_email
|
||||
):
|
||||
raise AccountInFreezeError()
|
||||
if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD:
|
||||
freeze_type = BillingService.get_email_freeze_type(normalized_email)
|
||||
if freeze_type:
|
||||
if freeze_type == "email_domain_suspended":
|
||||
raise EmailDomainSuspendedError()
|
||||
raise AccountInFreezeError()
|
||||
|
||||
account = AccountService.get_account_by_email_with_case_fallback(req_data.email, session=db.session())
|
||||
token = AccountService.send_email_register_email(email=normalized_email, account=account, language=language)
|
||||
@@ -217,5 +225,7 @@ class EmailRegisterResetApi(Resource):
|
||||
)
|
||||
except SeatsLimitExceededError:
|
||||
raise SeatsLimitExceeded()
|
||||
except AccountRegisterError:
|
||||
raise AccountInFreezeError()
|
||||
except EmailDomainSuspendedRegistrationError as exc:
|
||||
raise EmailDomainSuspendedError() from exc
|
||||
except AccountRegisterError as exc:
|
||||
raise AccountInFreezeError() from exc
|
||||
|
||||
@@ -95,6 +95,12 @@ class EmailCodeError(BaseHTTPException):
|
||||
code = 400
|
||||
|
||||
|
||||
class EmailCodeLoginServiceUnavailableError(BaseHTTPException):
|
||||
error_code = "email_code_login_service_unavailable"
|
||||
description = "Email code verification is temporarily unavailable. Please try again later."
|
||||
code = 503
|
||||
|
||||
|
||||
class EmailOrPasswordMismatchError(BaseHTTPException):
|
||||
error_code = "email_or_password_mismatch"
|
||||
description = "The email or password is mismatched."
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import logging
|
||||
from uuid import UUID
|
||||
|
||||
import flask_login
|
||||
from flask import make_response, request
|
||||
@@ -22,6 +23,7 @@ from controllers.console import console_ns
|
||||
from controllers.console.auth.error import (
|
||||
AuthenticationFailedError,
|
||||
EmailCodeError,
|
||||
EmailCodeLoginServiceUnavailableError,
|
||||
EmailPasswordLoginLimitError,
|
||||
InvalidEmailError,
|
||||
InvalidTokenError,
|
||||
@@ -32,6 +34,7 @@ from controllers.console.error import (
|
||||
AccountBannedError,
|
||||
AccountInFreezeError,
|
||||
AccountNotFound,
|
||||
EmailDomainSuspendedError,
|
||||
EmailSendIpLimitError,
|
||||
NotAllowedCreateWorkspace,
|
||||
SeatsLimitExceeded,
|
||||
@@ -61,6 +64,10 @@ from libs.token import (
|
||||
from models.account import Account
|
||||
from services.account_service import AccountService, InvitationDetailDict, RegisterService, TenantService
|
||||
from services.billing_service import BillingService
|
||||
from services.email_code_login_challenge import (
|
||||
EmailCodeLoginChallengeStatus,
|
||||
EmailCodeLoginChallengeUnavailableError,
|
||||
)
|
||||
from services.entities.auth_entities import LoginFailureReason, LoginPayloadBase
|
||||
from services.errors.account import (
|
||||
AccountRegisterError,
|
||||
@@ -68,9 +75,13 @@ from services.errors.account import (
|
||||
RefreshTokenNotFoundError,
|
||||
SeatsLimitExceededError,
|
||||
)
|
||||
from services.errors.account import (
|
||||
EmailDomainSuspendedError as EmailDomainSuspendedRegistrationError,
|
||||
)
|
||||
from services.errors.workspace import WorkSpaceNotAllowedCreateError, WorkspacesLimitExceededError
|
||||
from services.feature_service import FeatureService
|
||||
from services.turnstile_service import (
|
||||
EMAIL_CODE_VERIFY_ACTION,
|
||||
TurnstileChallengeRejectedError,
|
||||
TurnstileService,
|
||||
TurnstileUpstreamError,
|
||||
@@ -92,14 +103,20 @@ class EmailPayload(BaseModel):
|
||||
class EmailCodeSendPayload(EmailPayload):
|
||||
turnstile_token: str | None = Field(
|
||||
default=None,
|
||||
max_length=2048,
|
||||
description="Cloudflare Turnstile token. Required at runtime for Dify Cloud.",
|
||||
)
|
||||
|
||||
|
||||
class EmailCodeLoginPayload(BaseModel):
|
||||
email: EmailStr = Field(...)
|
||||
code: str = Field(...)
|
||||
token: str = Field(...)
|
||||
code: str
|
||||
token: UUID
|
||||
turnstile_token: str | None = Field(
|
||||
default=None,
|
||||
max_length=2048,
|
||||
description="Cloudflare Turnstile token for email-code verification.",
|
||||
)
|
||||
language: str | None = Field(default=None)
|
||||
timezone: str | None = Field(default=None)
|
||||
|
||||
@@ -136,11 +153,13 @@ class LoginApi(Resource):
|
||||
request_email = req_data.email
|
||||
normalized_email = request_email.lower()
|
||||
|
||||
if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD and BillingService.is_email_in_freeze(
|
||||
normalized_email
|
||||
):
|
||||
_log_console_login_failure(email=normalized_email, reason=LoginFailureReason.ACCOUNT_IN_FREEZE)
|
||||
raise AccountInFreezeError()
|
||||
if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD:
|
||||
freeze_type = BillingService.get_email_freeze_type(normalized_email)
|
||||
if freeze_type:
|
||||
_log_console_login_failure(email=normalized_email, reason=LoginFailureReason.ACCOUNT_IN_FREEZE)
|
||||
if freeze_type == "email_domain_suspended":
|
||||
raise EmailDomainSuspendedError()
|
||||
raise AccountInFreezeError()
|
||||
|
||||
is_login_error_rate_limit = AccountService.is_login_error_rate_limit(normalized_email)
|
||||
if is_login_error_rate_limit:
|
||||
@@ -242,8 +261,10 @@ class ResetPasswordSendEmailApi(Resource):
|
||||
language = "en-US"
|
||||
try:
|
||||
account = _get_account_with_case_fallback(req_data.email)
|
||||
except AccountRegisterError:
|
||||
raise AccountInFreezeError()
|
||||
except EmailDomainSuspendedRegistrationError as exc:
|
||||
raise EmailDomainSuspendedError() from exc
|
||||
except AccountRegisterError as exc:
|
||||
raise AccountInFreezeError() from exc
|
||||
|
||||
token = AccountService.send_reset_password_email(
|
||||
email=normalized_email,
|
||||
@@ -284,8 +305,10 @@ class EmailCodeLoginSendEmailApi(Resource):
|
||||
language = "en-US"
|
||||
try:
|
||||
account = _get_account_with_case_fallback(req_data.email)
|
||||
except AccountRegisterError:
|
||||
raise AccountInFreezeError()
|
||||
except EmailDomainSuspendedRegistrationError as exc:
|
||||
raise EmailDomainSuspendedError() from exc
|
||||
except AccountRegisterError as exc:
|
||||
raise AccountInFreezeError() from exc
|
||||
|
||||
if account is None:
|
||||
if FeatureService.get_system_features().is_allow_register:
|
||||
@@ -310,31 +333,66 @@ class EmailCodeLoginApi(Resource):
|
||||
original_email = req_data.email
|
||||
user_email = original_email.lower()
|
||||
language = req_data.language
|
||||
ip_address = extract_remote_ip(request)
|
||||
|
||||
token_data = AccountService.get_email_code_login_data(req_data.token)
|
||||
if token_data is None:
|
||||
_log_console_login_failure(email=user_email, reason=LoginFailureReason.INVALID_EMAIL_CODE_TOKEN)
|
||||
raise InvalidTokenError()
|
||||
|
||||
token_email = token_data.get("email")
|
||||
normalized_token_email = token_email.lower() if isinstance(token_email, str) else token_email
|
||||
if normalized_token_email != user_email:
|
||||
_log_console_login_failure(email=user_email, reason=LoginFailureReason.EMAIL_CODE_EMAIL_MISMATCH)
|
||||
raise InvalidEmailError()
|
||||
|
||||
if token_data["code"] != req_data.code:
|
||||
# ``code`` is Base64 on the wire and is decoded by
|
||||
# ``decrypt_code_field`` before model validation reaches this handler.
|
||||
if len(req_data.code) != 6 or not req_data.code.isascii() or not req_data.code.isdigit():
|
||||
_log_console_login_failure(email=user_email, reason=LoginFailureReason.INVALID_EMAIL_CODE)
|
||||
raise EmailCodeError()
|
||||
|
||||
if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD and (
|
||||
dify_config.TURNSTILE_EMAIL_CODE_VERIFY_REQUIRED or req_data.turnstile_token
|
||||
):
|
||||
try:
|
||||
TurnstileService.verify(
|
||||
token=req_data.turnstile_token,
|
||||
remote_ip=ip_address,
|
||||
expected_action=EMAIL_CODE_VERIFY_ACTION,
|
||||
)
|
||||
except TurnstileChallengeRejectedError as exc:
|
||||
logger.info("Turnstile rejected an email-code verification challenge")
|
||||
raise TurnstileVerificationFailedError() from exc
|
||||
except TurnstileUpstreamError as exc:
|
||||
logger.warning("Turnstile verification is unavailable", exc_info=True)
|
||||
raise TurnstileServiceUnavailableError() from exc
|
||||
|
||||
try:
|
||||
verification = AccountService.verify_email_code_login_challenge(
|
||||
email=user_email,
|
||||
code=req_data.code,
|
||||
token=str(req_data.token),
|
||||
)
|
||||
except EmailCodeLoginChallengeUnavailableError as exc:
|
||||
logger.warning("Email-code challenge verification is unavailable", exc_info=True)
|
||||
raise EmailCodeLoginServiceUnavailableError() from exc
|
||||
|
||||
if verification.status == EmailCodeLoginChallengeStatus.INVALID_TOKEN:
|
||||
_log_console_login_failure(email=user_email, reason=LoginFailureReason.INVALID_EMAIL_CODE_TOKEN)
|
||||
raise InvalidTokenError()
|
||||
|
||||
if verification.status == EmailCodeLoginChallengeStatus.EMAIL_MISMATCH:
|
||||
_log_console_login_failure(email=user_email, reason=LoginFailureReason.EMAIL_CODE_EMAIL_MISMATCH)
|
||||
raise InvalidEmailError()
|
||||
|
||||
if verification.status in {
|
||||
EmailCodeLoginChallengeStatus.INVALID_CODE,
|
||||
EmailCodeLoginChallengeStatus.EXHAUSTED,
|
||||
}:
|
||||
_log_console_login_failure(email=user_email, reason=LoginFailureReason.INVALID_EMAIL_CODE)
|
||||
raise EmailCodeError()
|
||||
|
||||
AccountService.revoke_email_code_login_token(req_data.token)
|
||||
try:
|
||||
account = _get_account_with_case_fallback(original_email)
|
||||
except Unauthorized as exc:
|
||||
_log_console_login_failure(email=user_email, reason=LoginFailureReason.ACCOUNT_BANNED)
|
||||
raise AccountBannedError() from exc
|
||||
except AccountRegisterError:
|
||||
except EmailDomainSuspendedRegistrationError as exc:
|
||||
_log_console_login_failure(email=user_email, reason=LoginFailureReason.ACCOUNT_IN_FREEZE)
|
||||
raise AccountInFreezeError()
|
||||
raise EmailDomainSuspendedError() from exc
|
||||
except AccountRegisterError as exc:
|
||||
_log_console_login_failure(email=user_email, reason=LoginFailureReason.ACCOUNT_IN_FREEZE)
|
||||
raise AccountInFreezeError() from exc
|
||||
if account:
|
||||
tenants = TenantService.get_join_tenants(account, session=db.session())
|
||||
if not tenants:
|
||||
@@ -346,7 +404,6 @@ class EmailCodeLoginApi(Resource):
|
||||
else:
|
||||
TenantService.create_owner_tenant(account, session=db.session())
|
||||
|
||||
ip_address = extract_remote_ip(request)
|
||||
if account is None:
|
||||
try:
|
||||
account = AccountService.create_account_and_tenant(
|
||||
@@ -361,9 +418,12 @@ class EmailCodeLoginApi(Resource):
|
||||
raise NotAllowedCreateWorkspace()
|
||||
except SeatsLimitExceededError:
|
||||
raise SeatsLimitExceeded()
|
||||
except AccountRegisterError:
|
||||
except EmailDomainSuspendedRegistrationError as exc:
|
||||
_log_console_login_failure(email=user_email, reason=LoginFailureReason.ACCOUNT_IN_FREEZE)
|
||||
raise AccountInFreezeError()
|
||||
raise EmailDomainSuspendedError() from exc
|
||||
except AccountRegisterError as exc:
|
||||
_log_console_login_failure(email=user_email, reason=LoginFailureReason.ACCOUNT_IN_FREEZE)
|
||||
raise AccountInFreezeError() from exc
|
||||
except WorkspacesLimitExceededError:
|
||||
raise WorkspacesLimitExceeded()
|
||||
token_pair = AccountService.login(account, session=db.session(), ip_address=ip_address)
|
||||
|
||||
@@ -12,6 +12,7 @@ from configs import dify_config
|
||||
from constants.languages import languages
|
||||
from controllers.common.fields import RedirectResponse
|
||||
from controllers.common.schema import query_params_from_model, register_response_schema_model, register_schema_models
|
||||
from controllers.console.error import AccountInFreezeError, EmailDomainSuspendedError
|
||||
from enums import DeploymentEdition
|
||||
from extensions.ext_database import db
|
||||
from libs.datetime_utils import naive_utc_now
|
||||
@@ -26,7 +27,14 @@ from libs.token import (
|
||||
from models import Account, AccountStatus
|
||||
from services.account_service import AccountService, RegisterService, TenantService
|
||||
from services.billing_service import BillingService
|
||||
from services.errors.account import AccountNotFoundError, AccountRegisterError, SeatsLimitExceededError
|
||||
from services.errors.account import (
|
||||
AccountNotFoundError,
|
||||
AccountRegisterError,
|
||||
SeatsLimitExceededError,
|
||||
)
|
||||
from services.errors.account import (
|
||||
EmailDomainSuspendedError as EmailDomainSuspendedRegistrationError,
|
||||
)
|
||||
from services.errors.workspace import WorkSpaceNotAllowedCreateError, WorkSpaceNotFoundError
|
||||
from services.feature_service import FeatureService
|
||||
|
||||
@@ -249,8 +257,10 @@ class OAuthCallback(Resource):
|
||||
)
|
||||
except SeatsLimitExceededError:
|
||||
return redirect(f"{dify_config.CONSOLE_WEB_URL}/signin?message=Licensed seats limit exceeded.")
|
||||
except AccountRegisterError as e:
|
||||
return redirect(f"{dify_config.CONSOLE_WEB_URL}/signin?message={e.description}")
|
||||
except EmailDomainSuspendedRegistrationError:
|
||||
return redirect(f"{dify_config.CONSOLE_WEB_URL}/signin?message={EmailDomainSuspendedError.description}")
|
||||
except AccountRegisterError as exc:
|
||||
return redirect(f"{dify_config.CONSOLE_WEB_URL}/signin?message={exc.description}")
|
||||
|
||||
# Check account status
|
||||
if account.status == AccountStatus.BANNED:
|
||||
@@ -309,15 +319,12 @@ def _generate_account(
|
||||
normalized_email = user_info.email.lower()
|
||||
oauth_new_user = True
|
||||
if not FeatureService.get_system_features().is_allow_register:
|
||||
if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD and BillingService.is_email_in_freeze(
|
||||
normalized_email
|
||||
):
|
||||
raise AccountRegisterError(
|
||||
description=(
|
||||
"This email account has been deleted within the past "
|
||||
"30 days and is temporarily unavailable for new account registration"
|
||||
)
|
||||
)
|
||||
if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD:
|
||||
freeze_type = BillingService.get_email_freeze_type(normalized_email)
|
||||
if freeze_type:
|
||||
if freeze_type == "email_domain_suspended":
|
||||
raise EmailDomainSuspendedRegistrationError()
|
||||
raise AccountRegisterError(description=AccountInFreezeError.description or "")
|
||||
raise AccountRegisterError(description=("Invalid email or password"))
|
||||
account_name = user_info.name or "Dify"
|
||||
interface_language = _preferred_interface_language(language)
|
||||
|
||||
@@ -7,20 +7,23 @@ from werkzeug.exceptions import BadRequest
|
||||
|
||||
from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models
|
||||
from controllers.console import console_ns
|
||||
from controllers.console.wraps import (
|
||||
account_initialization_required,
|
||||
model_validate,
|
||||
only_edition_cloud,
|
||||
setup_required,
|
||||
with_current_tenant_id,
|
||||
with_current_user,
|
||||
from controllers.console.billing.error import (
|
||||
BillingOperationFailedErrorResponse,
|
||||
BillingUnavailableErrorResponse,
|
||||
BillingUnprocessableEntityErrorResponse,
|
||||
to_billing_request_error,
|
||||
)
|
||||
from enums import CloudPlan
|
||||
from extensions.ext_database import db
|
||||
from controllers.console.flask_admission import console_account_admission
|
||||
from controllers.console.wraps import model_validate
|
||||
from enums import CloudPlan, DeploymentEdition
|
||||
from extensions.ext_application_services import application_services
|
||||
from fields.base import ResponseModel
|
||||
from libs.login import login_required
|
||||
from models import Account
|
||||
from services.billing_service import BillingService
|
||||
from libs.helper import dump_response
|
||||
from machinery.context import RequestContext
|
||||
from models.account import TenantAccountRole
|
||||
from services.errors.billing import BillingError
|
||||
|
||||
_BILLING_PORTAL_ALLOWED_ROLES = frozenset({TenantAccountRole.OWNER, TenantAccountRole.ADMIN})
|
||||
|
||||
|
||||
class SubscriptionQuery(BaseModel):
|
||||
@@ -45,37 +48,78 @@ class BillingSubscriptionResponse(ResponseModel):
|
||||
|
||||
|
||||
register_schema_models(console_ns, SubscriptionQuery, PartnerTenantsPayload)
|
||||
register_response_schema_models(console_ns, BillingResponse, BillingInvoiceResponse, BillingSubscriptionResponse)
|
||||
register_response_schema_models(
|
||||
console_ns,
|
||||
BillingOperationFailedErrorResponse,
|
||||
BillingUnprocessableEntityErrorResponse,
|
||||
BillingUnavailableErrorResponse,
|
||||
BillingResponse,
|
||||
BillingInvoiceResponse,
|
||||
BillingSubscriptionResponse,
|
||||
)
|
||||
|
||||
|
||||
@console_ns.route("/billing/subscription")
|
||||
class Subscription(Resource):
|
||||
@console_ns.doc(params=query_params_from_model(SubscriptionQuery))
|
||||
@console_ns.response(200, "Success", console_ns.models[BillingSubscriptionResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@only_edition_cloud
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
@console_ns.response(403, "Forbidden")
|
||||
@console_ns.response(
|
||||
422,
|
||||
"Invalid subscription query",
|
||||
console_ns.models[BillingUnprocessableEntityErrorResponse.__name__],
|
||||
)
|
||||
@console_ns.response(
|
||||
502,
|
||||
"Billing operation failed",
|
||||
console_ns.models[BillingOperationFailedErrorResponse.__name__],
|
||||
)
|
||||
@console_ns.response(
|
||||
503,
|
||||
"Billing unavailable",
|
||||
console_ns.models[BillingUnavailableErrorResponse.__name__],
|
||||
)
|
||||
@console_account_admission(
|
||||
editions=frozenset({DeploymentEdition.CLOUD}),
|
||||
allowed_roles=_BILLING_PORTAL_ALLOWED_ROLES,
|
||||
)
|
||||
@model_validate(SubscriptionQuery)
|
||||
def get(self, req_data: SubscriptionQuery, current_tenant_id: str, current_user: Account):
|
||||
BillingService.is_tenant_owner_or_admin(current_user, session=db.session())
|
||||
return BillingService.get_subscription(req_data.plan, req_data.interval, current_user.email, current_tenant_id)
|
||||
def get(self, req_data: SubscriptionQuery, request_context: RequestContext):
|
||||
try:
|
||||
data = application_services().billing_portal.get_subscription(
|
||||
request_context,
|
||||
plan=req_data.plan,
|
||||
interval=req_data.interval,
|
||||
)
|
||||
except BillingError as error:
|
||||
raise to_billing_request_error(error) from error
|
||||
return dump_response(BillingSubscriptionResponse, data)
|
||||
|
||||
|
||||
@console_ns.route("/billing/invoices")
|
||||
class Invoices(Resource):
|
||||
@console_ns.response(200, "Success", console_ns.models[BillingInvoiceResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@only_edition_cloud
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
def get(self, current_tenant_id: str, current_user: Account):
|
||||
BillingService.is_tenant_owner_or_admin(current_user, session=db.session())
|
||||
return BillingService.get_invoices(current_user.email, current_tenant_id)
|
||||
@console_ns.response(403, "Forbidden")
|
||||
@console_ns.response(
|
||||
502,
|
||||
"Billing operation failed",
|
||||
console_ns.models[BillingOperationFailedErrorResponse.__name__],
|
||||
)
|
||||
@console_ns.response(
|
||||
503,
|
||||
"Billing unavailable",
|
||||
console_ns.models[BillingUnavailableErrorResponse.__name__],
|
||||
)
|
||||
@console_account_admission(
|
||||
editions=frozenset({DeploymentEdition.CLOUD}),
|
||||
allowed_roles=_BILLING_PORTAL_ALLOWED_ROLES,
|
||||
)
|
||||
def get(self, request_context: RequestContext):
|
||||
try:
|
||||
data = application_services().billing_portal.get_invoices(request_context)
|
||||
except BillingError as error:
|
||||
raise to_billing_request_error(error) from error
|
||||
return dump_response(BillingInvoiceResponse, data)
|
||||
|
||||
|
||||
@console_ns.route("/billing/partners/<string:partner_key>/tenants")
|
||||
@@ -86,20 +130,20 @@ class PartnerTenants(Resource):
|
||||
@console_ns.expect(console_ns.models[PartnerTenantsPayload.__name__])
|
||||
@console_ns.response(200, "Tenants synced to partner successfully", console_ns.models[BillingResponse.__name__])
|
||||
@console_ns.response(400, "Invalid partner information")
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@only_edition_cloud
|
||||
@with_current_user
|
||||
@console_account_admission(editions=frozenset({DeploymentEdition.CLOUD}))
|
||||
@model_validate(PartnerTenantsPayload)
|
||||
def put(self, req_data: PartnerTenantsPayload, current_user: Account, partner_key: str):
|
||||
def put(self, req_data: PartnerTenantsPayload, request_context: RequestContext, partner_key: str):
|
||||
try:
|
||||
click_id = req_data.click_id
|
||||
decoded_partner_key = base64.b64decode(partner_key).decode("utf-8")
|
||||
except Exception as e:
|
||||
raise BadRequest("Invalid partner_key") from e
|
||||
|
||||
if not click_id or not decoded_partner_key or not current_user.id:
|
||||
if not click_id or not decoded_partner_key:
|
||||
raise BadRequest("Invalid partner information")
|
||||
|
||||
return BillingService.sync_partner_tenants_bindings(current_user.id, decoded_partner_key, click_id)
|
||||
return application_services().partner_tenant_bindings.sync(
|
||||
account_id=request_context.account_id,
|
||||
partner_key=decoded_partner_key,
|
||||
click_id=click_id,
|
||||
)
|
||||
|
||||
47
api/controllers/console/billing/error.py
Normal file
47
api/controllers/console/billing/error.py
Normal file
@@ -0,0 +1,47 @@
|
||||
from typing import Literal
|
||||
|
||||
from fields.base import ResponseModel
|
||||
from libs.exception import BaseHTTPException
|
||||
from services.errors.billing import (
|
||||
BillingError,
|
||||
BillingUpstreamInvalidResponseError,
|
||||
BillingUpstreamUnavailableError,
|
||||
)
|
||||
|
||||
|
||||
class BillingUnprocessableEntityErrorResponse(ResponseModel):
|
||||
code: Literal["unprocessable_entity"]
|
||||
message: str
|
||||
status: Literal[422]
|
||||
|
||||
|
||||
class BillingOperationFailedErrorResponse(ResponseModel):
|
||||
code: Literal["billing_operation_failed"]
|
||||
message: str
|
||||
status: Literal[502]
|
||||
|
||||
|
||||
class BillingUnavailableErrorResponse(ResponseModel):
|
||||
code: Literal["billing_unavailable"]
|
||||
message: str
|
||||
status: Literal[503]
|
||||
|
||||
|
||||
class BillingOperationFailedError(BaseHTTPException):
|
||||
error_code = "billing_operation_failed"
|
||||
description = "We couldn't complete this request. Please try again. If the problem persists, contact support."
|
||||
code = 502
|
||||
|
||||
|
||||
class BillingUnavailableError(BaseHTTPException):
|
||||
error_code = "billing_unavailable"
|
||||
description = "This operation is temporarily unavailable. Please try again later."
|
||||
code = 503
|
||||
|
||||
|
||||
def to_billing_request_error(error: BillingError) -> BaseHTTPException:
|
||||
if isinstance(error, BillingUpstreamInvalidResponseError):
|
||||
return BillingOperationFailedError()
|
||||
if isinstance(error, BillingUpstreamUnavailableError):
|
||||
return BillingUnavailableError()
|
||||
raise TypeError(f"Unsupported billing error: {type(error).__name__}")
|
||||
@@ -139,6 +139,8 @@ register_response_schema_models(
|
||||
class DataSourceApi(Resource):
|
||||
@setup_required
|
||||
@login_required
|
||||
@is_admin_or_owner_required
|
||||
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.CREDENTIAL_MANAGE, resource_required=False)
|
||||
@account_initialization_required
|
||||
@console_ns.response(200, "Success", console_ns.models[DataSourceIntegrateListResponse.__name__])
|
||||
@with_current_tenant_id
|
||||
|
||||
@@ -668,10 +668,12 @@ class DatasetApi(Resource):
|
||||
dataset = DatasetService.get_dataset(dataset_id_str, session)
|
||||
if dataset is None:
|
||||
raise NotFound("Dataset not found.")
|
||||
try:
|
||||
DatasetService.check_dataset_permission(dataset, current_user, session)
|
||||
except services.errors.account.NoPermissionError as e:
|
||||
raise Forbidden(str(e))
|
||||
|
||||
if not dify_config.RBAC_ENABLED:
|
||||
try:
|
||||
DatasetService.check_dataset_permission(dataset, current_user, session)
|
||||
except services.errors.account.NoPermissionError as e:
|
||||
raise Forbidden(str(e))
|
||||
permissions = enterprise_rbac_service.RBACService.MyPermissions.get(
|
||||
current_tenant_id,
|
||||
current_user.id,
|
||||
|
||||
@@ -631,12 +631,16 @@ class DatasetDocumentSegmentBatchImportApi(Resource):
|
||||
):
|
||||
# check dataset
|
||||
dataset_id_str = str(dataset_id)
|
||||
dataset = DatasetService.get_dataset(dataset_id_str, session)
|
||||
dataset = DatasetService.get_dataset_for_tenant(dataset_id_str, current_tenant_id, session=session)
|
||||
if not dataset:
|
||||
raise NotFound("Dataset not found.")
|
||||
|
||||
# check document
|
||||
document_id_str = str(document_id)
|
||||
document = DocumentService.get_document(dataset_id_str, document_id_str, session=session)
|
||||
document_ref = DatasetRefService.create_document_ref_from_id(
|
||||
DatasetRefService.create_dataset_ref(dataset), document_id_str
|
||||
)
|
||||
document = DatasetRefService.get_document_by_ref(document_ref, session=session)
|
||||
if not document:
|
||||
raise NotFound("Document not found.")
|
||||
|
||||
|
||||
@@ -336,6 +336,8 @@ class DatasourceAuth(Resource):
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@edit_permission_required
|
||||
@rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.CREDENTIAL_MANAGE, resource_required=False)
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
def get(self, current_tenant_id: str, user: Account, provider_id: str):
|
||||
|
||||
@@ -61,6 +61,7 @@ from models import Account
|
||||
from models.dataset import Pipeline
|
||||
from models.model import EndUser
|
||||
from models.workflow import Workflow
|
||||
from services.agent.retirement_service import WorkflowAgentRetirementService
|
||||
from services.dataset_service import DatasetService
|
||||
from services.errors.app import IsDraftWorkflowError, WorkflowHashNotEqualError, WorkflowNotFoundError
|
||||
from services.errors.llm import InvokeRateLimitError
|
||||
@@ -770,8 +771,9 @@ class RagPipelineByIdApi(Resource):
|
||||
@account_initialization_required
|
||||
@edit_permission_required
|
||||
@rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_EDIT)
|
||||
@with_current_user
|
||||
@get_rag_pipeline
|
||||
def delete(self, pipeline: Pipeline, workflow_id: str):
|
||||
def delete(self, current_user: Account, pipeline: Pipeline, workflow_id: str):
|
||||
"""
|
||||
Delete a published workflow version that is not currently active on the pipeline.
|
||||
"""
|
||||
@@ -783,7 +785,7 @@ class RagPipelineByIdApi(Resource):
|
||||
|
||||
with sessionmaker(db.engine).begin() as session:
|
||||
try:
|
||||
workflow_service.delete_workflow(
|
||||
retirement_candidates = workflow_service.delete_workflow(
|
||||
session=session,
|
||||
workflow_ref=workflow_ref,
|
||||
)
|
||||
@@ -794,6 +796,11 @@ class RagPipelineByIdApi(Resource):
|
||||
except ValueError as e:
|
||||
raise NotFound(str(e))
|
||||
|
||||
WorkflowAgentRetirementService.retire_unowned(
|
||||
tenant_id=pipeline.tenant_id,
|
||||
agent_ids=retirement_candidates,
|
||||
account_id=current_user.id,
|
||||
)
|
||||
return None, 204
|
||||
|
||||
|
||||
|
||||
@@ -92,11 +92,17 @@ class AccountInFreezeError(BaseHTTPException):
|
||||
error_code = "account_in_freeze"
|
||||
code = 400
|
||||
description = (
|
||||
"This email account has been deleted within the past 30 days"
|
||||
"This email account has been deleted within the past 30 days "
|
||||
"and is temporarily unavailable for new account registration."
|
||||
)
|
||||
|
||||
|
||||
class EmailDomainSuspendedError(BaseHTTPException):
|
||||
error_code = "email_domain_suspended"
|
||||
code = 400
|
||||
description = "This email domain has been suspended."
|
||||
|
||||
|
||||
class EducationVerifyLimitError(BaseHTTPException):
|
||||
error_code = "education_verify_limit"
|
||||
description = "Rate limit exceeded"
|
||||
|
||||
@@ -31,6 +31,12 @@ class AppAccessDeniedError(BaseHTTPException):
|
||||
code = 403
|
||||
|
||||
|
||||
class RecommendedAppNotFoundError(BaseHTTPException):
|
||||
error_code = "recommended_app_not_found"
|
||||
description = "Recommended app not found."
|
||||
code = 404
|
||||
|
||||
|
||||
class TrialAppNotAllowed(BaseHTTPException):
|
||||
"""*403* `Trial App Not Allowed`
|
||||
|
||||
@@ -51,3 +57,9 @@ class TrialAppLimitExceeded(BaseHTTPException):
|
||||
error_code = "trial_app_limit_exceeded"
|
||||
code = 403
|
||||
description = "The user has exceeded the trial app limit."
|
||||
|
||||
|
||||
class TrialAppFeatureDisabledError(BaseHTTPException):
|
||||
error_code = "trial_app_feature_disabled"
|
||||
code = 403
|
||||
description = "Trial app feature is not enabled."
|
||||
|
||||
@@ -2,18 +2,18 @@ from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from flask_restx import Resource
|
||||
from pydantic import BaseModel, Field, RootModel, computed_field, field_validator
|
||||
from pydantic import BaseModel, Field, computed_field, field_validator
|
||||
|
||||
from constants.languages import languages
|
||||
from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models
|
||||
from controllers.console import console_ns
|
||||
from controllers.console.explore.error import RecommendedAppNotFoundError
|
||||
from controllers.console.wraps import account_initialization_required, model_validate, with_current_user
|
||||
from extensions.ext_database import db
|
||||
from extensions.ext_application_services import application_services
|
||||
from fields.base import ResponseModel
|
||||
from libs.helper import build_icon_url, dump_response
|
||||
from libs.login import login_required
|
||||
from models import Account
|
||||
from services.recommended_app_service import RecommendedAppService
|
||||
from services.recommended_app_query_service import RecommendedAppNotFoundError as RecommendedAppQueryNotFoundError
|
||||
|
||||
|
||||
class RecommendedAppsQuery(BaseModel):
|
||||
@@ -79,10 +79,6 @@ class RecommendedAppDetailResponse(ResponseModel):
|
||||
can_trial: bool
|
||||
|
||||
|
||||
class RecommendedAppDetailNullableResponse(RootModel[RecommendedAppDetailResponse | None]):
|
||||
pass
|
||||
|
||||
|
||||
register_schema_models(
|
||||
console_ns,
|
||||
RecommendedAppsQuery,
|
||||
@@ -94,18 +90,9 @@ register_response_schema_models(
|
||||
RecommendedAppListResponse,
|
||||
LearnDifyAppListResponse,
|
||||
RecommendedAppDetailResponse,
|
||||
RecommendedAppDetailNullableResponse,
|
||||
)
|
||||
|
||||
|
||||
def _resolve_language(language: str | None, user: Account) -> str:
|
||||
if language and language in languages:
|
||||
return language
|
||||
if user.interface_language:
|
||||
return user.interface_language
|
||||
return languages[0]
|
||||
|
||||
|
||||
@console_ns.route("/explore/apps")
|
||||
class RecommendedAppListApi(Resource):
|
||||
@console_ns.doc(params=query_params_from_model(RecommendedAppsQuery))
|
||||
@@ -115,12 +102,12 @@ class RecommendedAppListApi(Resource):
|
||||
@with_current_user
|
||||
@model_validate(RecommendedAppsQuery)
|
||||
def get(self, req_data: RecommendedAppsQuery, current_user: Account):
|
||||
# language args
|
||||
language_prefix = _resolve_language(req_data.language, current_user)
|
||||
|
||||
return dump_response(
|
||||
RecommendedAppListResponse,
|
||||
RecommendedAppService.get_recommended_apps_and_categories(language_prefix, session=db.session()),
|
||||
application_services().recommended_app_queries.list_recommended(
|
||||
requested_language=req_data.language,
|
||||
interface_language=current_user.interface_language,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -133,19 +120,24 @@ class LearnDifyAppListApi(Resource):
|
||||
@with_current_user
|
||||
@model_validate(RecommendedAppsQuery)
|
||||
def get(self, req_data: RecommendedAppsQuery, current_user: Account):
|
||||
language_prefix = _resolve_language(req_data.language, current_user)
|
||||
|
||||
return dump_response(
|
||||
LearnDifyAppListResponse,
|
||||
RecommendedAppService.get_learn_dify_apps(language_prefix, session=db.session()),
|
||||
application_services().recommended_app_queries.list_learn_dify(
|
||||
requested_language=req_data.language,
|
||||
interface_language=current_user.interface_language,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@console_ns.route("/explore/apps/<uuid:app_id>")
|
||||
class RecommendedAppApi(Resource):
|
||||
@console_ns.response(200, "Success", console_ns.models[RecommendedAppDetailNullableResponse.__name__])
|
||||
@console_ns.response(200, "Success", console_ns.models[RecommendedAppDetailResponse.__name__])
|
||||
@console_ns.response(404, "Recommended app not found")
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
def get(self, app_id: UUID):
|
||||
result = RecommendedAppService.get_recommend_app_detail(str(app_id), session=db.session())
|
||||
return RecommendedAppDetailNullableResponse.model_validate(result).model_dump(mode="json")
|
||||
try:
|
||||
result = application_services().recommended_app_queries.get_detail(str(app_id))
|
||||
except RecommendedAppQueryNotFoundError:
|
||||
raise RecommendedAppNotFoundError() from None
|
||||
return dump_response(RecommendedAppDetailResponse, result)
|
||||
|
||||
@@ -38,7 +38,7 @@ from controllers.console.app.error import (
|
||||
SpeechToTextDisabledError,
|
||||
UnsupportedAudioTypeError,
|
||||
)
|
||||
from controllers.console.app.wraps import get_app_model_with_trial, with_session
|
||||
from controllers.console.app.wraps import get_previewable_app_model, with_session
|
||||
from controllers.console.explore.error import (
|
||||
AppSuggestedQuestionsAfterAnswerDisabledError,
|
||||
NotChatAppError,
|
||||
@@ -96,7 +96,6 @@ from services.errors.message import (
|
||||
SuggestedQuestionsAfterAnswerDisabledError,
|
||||
)
|
||||
from services.message_service import MessageService
|
||||
from services.recommended_app_service import RecommendedAppService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -511,7 +510,7 @@ class TrialAppWorkflowRunApi(TrialAppResource):
|
||||
invoke_from=InvokeFrom.EXPLORE,
|
||||
streaming=True,
|
||||
)
|
||||
RecommendedAppService.add_trial_app_record(app_id, user_id, session=session)
|
||||
application_services().trial_app_usage.record(app_id=app_id, account_id=user_id)
|
||||
# response-contract:ignore compact_generate_response
|
||||
return helper.compact_generate_response(response)
|
||||
except ProviderTokenNotInitError as ex:
|
||||
@@ -589,7 +588,7 @@ class TrialChatApi(TrialAppResource):
|
||||
invoke_from=InvokeFrom.EXPLORE,
|
||||
streaming=True,
|
||||
)
|
||||
RecommendedAppService.add_trial_app_record(app_id, user_id, session=session)
|
||||
application_services().trial_app_usage.record(app_id=app_id, account_id=user_id)
|
||||
# response-contract:ignore compact_generate_response
|
||||
return helper.compact_generate_response(response)
|
||||
except services.errors.conversation.ConversationNotExistsError:
|
||||
@@ -675,7 +674,7 @@ class TrialChatAudioApi(TrialAppResource):
|
||||
session=db.session(),
|
||||
end_user=None,
|
||||
)
|
||||
RecommendedAppService.add_trial_app_record(app_id, user_id, session=db.session())
|
||||
application_services().trial_app_usage.record(app_id=app_id, account_id=user_id)
|
||||
return response
|
||||
except services.errors.app_model_config.AppModelConfigBrokenError:
|
||||
logger.exception("App model config broken.")
|
||||
@@ -736,7 +735,7 @@ class TrialChatTextApi(TrialAppResource):
|
||||
voice=voice,
|
||||
message_ref=message_ref,
|
||||
)
|
||||
RecommendedAppService.add_trial_app_record(app_id, user_id, session=db.session())
|
||||
application_services().trial_app_usage.record(app_id=app_id, account_id=user_id)
|
||||
return response
|
||||
except services.errors.app_model_config.AppModelConfigBrokenError:
|
||||
logger.exception("App model config broken.")
|
||||
@@ -794,7 +793,7 @@ class TrialCompletionApi(TrialAppResource):
|
||||
streaming=streaming,
|
||||
)
|
||||
|
||||
RecommendedAppService.add_trial_app_record(app_id, user_id, session=session)
|
||||
application_services().trial_app_usage.record(app_id=app_id, account_id=user_id)
|
||||
# response-contract:ignore compact_generate_response
|
||||
return helper.compact_generate_response(response)
|
||||
except services.errors.conversation.ConversationNotExistsError:
|
||||
@@ -824,7 +823,7 @@ class TrialSitApi(Resource):
|
||||
|
||||
@console_ns.response(200, "Success", console_ns.models[SiteResponse.__name__])
|
||||
@with_session(write=False)
|
||||
@get_app_model_with_trial(None)
|
||||
@get_previewable_app_model(None)
|
||||
def get(self, session: Session, app_model):
|
||||
"""Retrieve app site info.
|
||||
|
||||
@@ -848,7 +847,7 @@ class TrialAppParameterApi(Resource):
|
||||
|
||||
@console_ns.response(200, "Success", console_ns.models[ParametersResponse.__name__])
|
||||
@with_session(write=False)
|
||||
@get_app_model_with_trial(None)
|
||||
@get_previewable_app_model(None)
|
||||
def get(self, session: Session, app_model):
|
||||
"""Retrieve app parameters."""
|
||||
|
||||
@@ -866,7 +865,7 @@ class TrialAppParameterApi(Resource):
|
||||
class AppApi(Resource):
|
||||
@console_ns.response(200, "Success", console_ns.models[TrialAppDetailResponse.__name__])
|
||||
@with_session(write=False)
|
||||
@get_app_model_with_trial(None)
|
||||
@get_previewable_app_model(None)
|
||||
def get(self, session: Session, app_model):
|
||||
"""Get app detail"""
|
||||
|
||||
@@ -882,7 +881,7 @@ class AppApi(Resource):
|
||||
class AppWorkflowApi(Resource):
|
||||
@console_ns.response(200, "Success", console_ns.models[TrialWorkflowResponse.__name__])
|
||||
@with_session(write=False)
|
||||
@get_app_model_with_trial(None)
|
||||
@get_previewable_app_model(None)
|
||||
def get(self, session: Session, app_model):
|
||||
"""Get workflow detail"""
|
||||
if not app_model.workflow_id:
|
||||
@@ -902,7 +901,7 @@ class DatasetListApi(Resource):
|
||||
@console_ns.doc(params=query_params_from_model(TrialDatasetListQuery))
|
||||
@console_ns.response(200, "Success", console_ns.models[TrialDatasetListResponse.__name__])
|
||||
@with_session(write=False)
|
||||
@get_app_model_with_trial(None)
|
||||
@get_previewable_app_model(None)
|
||||
def get(self, session: Session, app_model):
|
||||
page = request.args.get("page", default=1, type=int)
|
||||
limit = request.args.get("limit", default=20, type=int)
|
||||
|
||||
@@ -2,19 +2,23 @@ from collections.abc import Callable
|
||||
from functools import wraps
|
||||
from typing import Concatenate
|
||||
|
||||
from flask import abort
|
||||
from flask_restx import Resource
|
||||
from sqlalchemy import select
|
||||
from werkzeug.exceptions import NotFound
|
||||
|
||||
from controllers.console.explore.error import AppAccessDeniedError, TrialAppLimitExceeded, TrialAppNotAllowed
|
||||
from controllers.console.explore.error import (
|
||||
AppAccessDeniedError,
|
||||
TrialAppFeatureDisabledError,
|
||||
TrialAppLimitExceeded,
|
||||
TrialAppNotAllowed,
|
||||
)
|
||||
from controllers.console.wraps import account_initialization_required
|
||||
from extensions.ext_application_services import application_services
|
||||
from extensions.ext_database import db
|
||||
from libs.login import current_account_with_tenant, login_required
|
||||
from models import AccountTrialAppRecord, App, InstalledApp, TrialApp
|
||||
from services.enterprise.enterprise_service import EnterpriseService
|
||||
from services.feature_service import FeatureService
|
||||
from services.recommended_app_service import RecommendedAppService
|
||||
|
||||
|
||||
def installed_app_required[**P, R](view: Callable[Concatenate[InstalledApp, P], R] | None = None):
|
||||
@@ -107,8 +111,8 @@ def trial_app_required[**P, R](view: Callable[Concatenate[App, P], R] | None = N
|
||||
def trial_feature_enable[**P, R](view: Callable[P, R]):
|
||||
@wraps(view)
|
||||
def decorated(*args: P.args, **kwargs: P.kwargs):
|
||||
if not RecommendedAppService.is_trial_app_enabled():
|
||||
abort(403, "Trial app feature is not enabled.")
|
||||
if not application_services().recommended_app_queries.is_trial_enabled():
|
||||
raise TrialAppFeatureDisabledError()
|
||||
return view(*args, **kwargs)
|
||||
|
||||
return decorated
|
||||
|
||||
@@ -75,18 +75,18 @@ class FeatureVectorSpaceApi(Resource):
|
||||
@console_ns.route("/trial-models")
|
||||
class TrialModelsApi(Resource):
|
||||
@console_ns.doc("get_trial_models")
|
||||
@console_ns.doc(description="Get hosted trial model provider configuration")
|
||||
@console_ns.doc(description="Get hosted credit model provider configuration for the current workspace")
|
||||
@console_ns.response(
|
||||
200,
|
||||
"Success",
|
||||
console_ns.models[TrialModelsResponse.__name__],
|
||||
)
|
||||
@console_account_admission()
|
||||
def get(self, _request_context: RequestContext):
|
||||
"""Get hosted trial model provider configuration for model-provider pages."""
|
||||
def get(self, request_context: RequestContext):
|
||||
"""Get hosted credit provider configuration for the current workspace."""
|
||||
return dump_response(
|
||||
TrialModelsResponse,
|
||||
{"trial_models": application_services().feature_queries.get_trial_models()},
|
||||
{"trial_models": application_services().feature_queries.get_trial_models(request_context)},
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -9,7 +9,12 @@ from werkzeug.exceptions import Forbidden
|
||||
|
||||
from configs import dify_config
|
||||
from controllers.common.wraps import enforce_rbac_access
|
||||
from controllers.console.wraps import account_initialization_required, enterprise_license_required, setup_required
|
||||
from controllers.console.wraps import (
|
||||
account_initialization_required,
|
||||
enable_change_email,
|
||||
enterprise_license_required,
|
||||
setup_required,
|
||||
)
|
||||
from core.logging.context import get_request_id, get_trace_id
|
||||
from core.rbac import RBACPermission, RBACResourceScope
|
||||
from enums import DeploymentEdition
|
||||
@@ -22,6 +27,8 @@ from models.account import TenantAccountRole
|
||||
def console_account_admission[T, **P, R](
|
||||
*,
|
||||
editions: frozenset[DeploymentEdition] | None = None,
|
||||
require_change_email_enabled: bool = False,
|
||||
require_initialized: bool = True,
|
||||
require_valid_enterprise_license: bool = False,
|
||||
allowed_roles: frozenset[TenantAccountRole] | None = None,
|
||||
rbac_resource_scope: RBACResourceScope | None = None,
|
||||
@@ -34,9 +41,9 @@ def console_account_admission[T, **P, R](
|
||||
"""Declare Console account admission and inject a stable RequestContext.
|
||||
|
||||
All combinations use this decorator factory. Requirements are data, while
|
||||
the execution order stays fixed: edition, setup, login/CSRF, account
|
||||
initialization, optional enterprise license, role/RBAC checks, then context
|
||||
construction.
|
||||
the execution order stays fixed: edition, setup, login/CSRF, optional
|
||||
account initialization, optional enterprise license, role/RBAC checks, then
|
||||
context construction.
|
||||
"""
|
||||
|
||||
if (rbac_resource_scope is None) != (rbac_permission is None):
|
||||
@@ -70,9 +77,12 @@ def console_account_admission[T, **P, R](
|
||||
return view(self, request_context, *args, **kwargs)
|
||||
|
||||
admitted: Callable[Concatenate[T, P], R | Response] = inject_request_context
|
||||
if require_change_email_enabled:
|
||||
admitted = enable_change_email(admitted)
|
||||
if require_valid_enterprise_license:
|
||||
admitted = enterprise_license_required(admitted)
|
||||
admitted = account_initialization_required(admitted)
|
||||
if require_initialized:
|
||||
admitted = account_initialization_required(admitted)
|
||||
admitted = login_required(admitted)
|
||||
admitted = setup_required(admitted)
|
||||
|
||||
|
||||
@@ -167,3 +167,9 @@ class IncludeSecretQuery(BaseModel):
|
||||
"""Query parameter for including secret variables in export."""
|
||||
|
||||
include_secret: str = Field(default="false", description="Whether to include secret variables")
|
||||
|
||||
|
||||
class SnippetExportQuery(IncludeSecretQuery):
|
||||
"""Query parameters for exporting a snippet workflow as DSL."""
|
||||
|
||||
workflow_id: str | None = Field(default=None, description="Specific published workflow version to export")
|
||||
|
||||
@@ -57,12 +57,11 @@ from libs.helper import TimestampField
|
||||
from libs.login import current_account_with_tenant, login_required
|
||||
from models import Account
|
||||
from models.snippet import CustomizedSnippet
|
||||
from services.agent.retirement_service import WorkflowAgentRetirementService
|
||||
from services.agent.workflow_publish_service import WorkflowAgentPublishService
|
||||
from services.errors.app import IsDraftWorkflowError, WorkflowHashNotEqualError, WorkflowNotFoundError
|
||||
from services.errors.workflow_service import DraftWorkflowDeletionError, WorkflowInUseError
|
||||
from services.snippet_generate_service import SnippetGenerateService
|
||||
from services.snippet_service import SnippetService
|
||||
from tasks.collect_agent_resources_task import enqueue_agent_resource_collection
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -298,9 +297,8 @@ class SnippetPublishedWorkflowApi(Resource):
|
||||
|
||||
with Session(db.engine) as session:
|
||||
snippet = session.merge(snippet)
|
||||
tenant_id = snippet.tenant_id
|
||||
try:
|
||||
workflow, retirement_candidates = snippet_service.publish_workflow(
|
||||
workflow = snippet_service.publish_workflow(
|
||||
session=session,
|
||||
snippet=snippet,
|
||||
account=current_user,
|
||||
@@ -310,16 +308,6 @@ class SnippetPublishedWorkflowApi(Resource):
|
||||
except ValueError as e:
|
||||
return {"message": str(e)}, 400
|
||||
|
||||
binding_ids, home_snapshot_ids = WorkflowAgentRetirementService.retire_unowned(
|
||||
tenant_id=tenant_id,
|
||||
agent_ids=retirement_candidates,
|
||||
account_id=current_user.id,
|
||||
)
|
||||
enqueue_agent_resource_collection(
|
||||
tenant_id=tenant_id,
|
||||
binding_ids=binding_ids,
|
||||
home_snapshot_ids=home_snapshot_ids,
|
||||
)
|
||||
return {
|
||||
"result": "success",
|
||||
"created_at": workflow_created_at,
|
||||
@@ -480,6 +468,39 @@ class SnippetWorkflowByIdApi(Resource):
|
||||
response["input_fields"] = snippet.input_fields_list
|
||||
return response
|
||||
|
||||
@console_ns.doc("delete_snippet_workflow_by_id")
|
||||
@console_ns.doc(description="Delete a published snippet workflow version")
|
||||
@console_ns.doc(params={"snippet_id": "Snippet ID", "workflow_id": "Workflow ID"})
|
||||
@console_ns.response(204, "Workflow deleted successfully")
|
||||
@console_ns.response(400, "Workflow is in use")
|
||||
@console_ns.response(404, "Workflow not found")
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@get_snippet
|
||||
@edit_permission_required
|
||||
@rbac_permission_required(
|
||||
RBACResourceScope.WORKSPACE, RBACPermission.SNIPPETS_CREATE_AND_MODIFY, resource_required=False
|
||||
)
|
||||
def delete(self, snippet: CustomizedSnippet, workflow_id: str):
|
||||
"""Delete a published snippet workflow version."""
|
||||
snippet_service = _snippet_service()
|
||||
with _snippet_session_maker().begin() as session:
|
||||
try:
|
||||
snippet_service.delete_workflow(
|
||||
session=session,
|
||||
snippet=snippet,
|
||||
workflow_id=workflow_id,
|
||||
)
|
||||
except WorkflowInUseError as e:
|
||||
raise BadRequest(str(e))
|
||||
except DraftWorkflowDeletionError as e:
|
||||
raise BadRequest(str(e))
|
||||
except ValueError as e:
|
||||
raise NotFound(str(e))
|
||||
|
||||
return None, 204
|
||||
|
||||
|
||||
@console_ns.route("/snippets/<uuid:snippet_id>/workflow-runs")
|
||||
class SnippetWorkflowRunsApi(Resource):
|
||||
|
||||
@@ -3,37 +3,32 @@ from uuid import UUID
|
||||
|
||||
from flask_restx import Resource
|
||||
from pydantic import BaseModel, Field, RootModel, field_validator
|
||||
from sqlalchemy import select
|
||||
from werkzeug.exceptions import Forbidden
|
||||
from werkzeug.exceptions import Forbidden, NotFound
|
||||
|
||||
from configs import dify_config
|
||||
from controllers.common.fields import SimpleResultResponse
|
||||
from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models
|
||||
from controllers.common.wraps import enforce_rbac_access
|
||||
from controllers.console import console_ns
|
||||
from controllers.console.flask_admission import console_account_admission
|
||||
from controllers.console.wraps import (
|
||||
RBACPermission,
|
||||
RBACResourceScope,
|
||||
account_initialization_required,
|
||||
edit_permission_required,
|
||||
model_validate,
|
||||
setup_required,
|
||||
with_current_tenant_id,
|
||||
with_current_user,
|
||||
)
|
||||
from extensions.ext_database import db
|
||||
from extensions.ext_application_services import application_services
|
||||
from fields.base import ResponseModel
|
||||
from libs.helper import dump_response
|
||||
from libs.login import current_account_with_tenant, login_required
|
||||
from models import Account
|
||||
from libs.login import current_account_with_tenant
|
||||
from machinery.context import RequestContext
|
||||
from models.enums import TagType
|
||||
from models.model import Tag
|
||||
from services.tag_service import (
|
||||
SaveTagPayload,
|
||||
TagBindingCreatePayload,
|
||||
TagBindingDeletePayload,
|
||||
TagService,
|
||||
UpdateTagPayload,
|
||||
from services.tag_application_service import (
|
||||
CreateTagInput,
|
||||
TagBindingInput,
|
||||
TagBindingTargetNotFoundError,
|
||||
TagNameConflictError,
|
||||
TagNotFoundError,
|
||||
UpdateTagInput,
|
||||
)
|
||||
|
||||
|
||||
@@ -59,7 +54,7 @@ class TagBindingRemovePayload(BaseModel):
|
||||
|
||||
|
||||
class TagListQueryParam(BaseModel):
|
||||
type: Literal["knowledge", "app", "snippet", ""] = Field("", description="Tag type filter")
|
||||
type: Literal["knowledge", "app", "snippet", "skill"] = Field(description="Tag type filter")
|
||||
keyword: str | None = Field(None, description="Search keyword")
|
||||
|
||||
|
||||
@@ -101,143 +96,158 @@ register_schema_models(
|
||||
register_response_schema_models(console_ns, SimpleResultResponse, TagResponse, TagListResponse)
|
||||
|
||||
|
||||
def _enforce_snippet_tag_rbac_if_needed(tag_type: TagType | str | None) -> None:
|
||||
def _enforce_snippet_tag_rbac_if_needed(tag_type: TagType | str | None, context: RequestContext) -> None:
|
||||
if tag_type != TagType.SNIPPET:
|
||||
return
|
||||
if not dify_config.RBAC_ENABLED:
|
||||
return
|
||||
|
||||
current_user, current_tenant_id = current_account_with_tenant()
|
||||
enforce_rbac_access(
|
||||
tenant_id=current_tenant_id,
|
||||
account_id=current_user.id,
|
||||
tenant_id=_workspace_id(context),
|
||||
account_id=context.account_id,
|
||||
resource_type=RBACResourceScope.WORKSPACE,
|
||||
scene=RBACPermission.SNIPPETS_CREATE_AND_MODIFY,
|
||||
resource_required=False,
|
||||
)
|
||||
|
||||
|
||||
def _enforce_snippet_tag_rbac_by_tag_id(tag_id: str) -> None:
|
||||
def _enforce_snippet_tag_rbac_by_tag_id(tag_id: str, context: RequestContext) -> None:
|
||||
if not dify_config.RBAC_ENABLED:
|
||||
return
|
||||
|
||||
_, current_tenant_id = current_account_with_tenant()
|
||||
tag_type = db.session.scalar(select(Tag.type).where(Tag.id == tag_id, Tag.tenant_id == current_tenant_id).limit(1))
|
||||
_enforce_snippet_tag_rbac_if_needed(tag_type)
|
||||
tag_type = application_services().tags.get_tag_type(context, tag_id)
|
||||
_enforce_snippet_tag_rbac_if_needed(tag_type, context)
|
||||
|
||||
|
||||
def _workspace_id(context: RequestContext) -> str:
|
||||
if context.active_workspace_id is None:
|
||||
raise RuntimeError("Console account admission did not resolve an active workspace")
|
||||
return context.active_workspace_id
|
||||
|
||||
|
||||
def _require_tag_edit_permission(*, allow_dataset_editor: bool) -> None:
|
||||
current_user, _ = current_account_with_tenant()
|
||||
if current_user.has_edit_permission:
|
||||
return
|
||||
if allow_dataset_editor and current_user.is_dataset_editor:
|
||||
return
|
||||
raise Forbidden()
|
||||
|
||||
|
||||
@console_ns.route("/tags")
|
||||
class TagListApi(Resource):
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@console_account_admission()
|
||||
@console_ns.doc(params=query_params_from_model(TagListQueryParam))
|
||||
@console_ns.response(200, "Success", console_ns.models[TagListResponse.__name__])
|
||||
@with_current_tenant_id
|
||||
@model_validate(TagListQueryParam)
|
||||
def get(self, req_data: TagListQueryParam, current_tenant_id: str):
|
||||
tags = TagService.get_tags(req_data.type, current_tenant_id, req_data.keyword, session=db.session())
|
||||
def get(self, req_data: TagListQueryParam, request_context: RequestContext):
|
||||
tags = application_services().tags.list_tags(request_context, req_data.type, req_data.keyword)
|
||||
|
||||
return dump_response(TagListResponse, 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
|
||||
@with_current_user
|
||||
@console_account_admission()
|
||||
@model_validate(TagBasePayload)
|
||||
def post(self, req_data: TagBasePayload, current_user: Account):
|
||||
def post(self, req_data: TagBasePayload, request_context: RequestContext):
|
||||
# Allow users with edit permission, or dataset editors (including dataset operators).
|
||||
if not (current_user.has_edit_permission or current_user.is_dataset_editor):
|
||||
raise Forbidden()
|
||||
_require_tag_edit_permission(allow_dataset_editor=True)
|
||||
|
||||
_enforce_snippet_tag_rbac_if_needed(req_data.type)
|
||||
tag = TagService.save_tags(SaveTagPayload(name=req_data.name, type=req_data.type), db.session())
|
||||
_enforce_snippet_tag_rbac_if_needed(req_data.type, request_context)
|
||||
try:
|
||||
tag = application_services().tags.create_tag(
|
||||
request_context,
|
||||
CreateTagInput(name=req_data.name, type=req_data.type.value),
|
||||
)
|
||||
except TagNameConflictError as error:
|
||||
raise ValueError(str(error)) from None
|
||||
|
||||
return dump_response(TagResponse, {"id": tag.id, "name": tag.name, "type": tag.type, "binding_count": 0}), 200
|
||||
return dump_response(TagResponse, tag), 200
|
||||
|
||||
|
||||
@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
|
||||
@with_current_user
|
||||
@console_account_admission()
|
||||
@model_validate(TagUpdateRequestPayload)
|
||||
def patch(self, req_data: TagUpdateRequestPayload, current_user: Account, tag_id: UUID):
|
||||
def patch(self, req_data: TagUpdateRequestPayload, request_context: RequestContext, tag_id: UUID):
|
||||
tag_id_str = str(tag_id)
|
||||
# The role of the current user in the ta table must be admin, owner, or editor
|
||||
if not (current_user.has_edit_permission or current_user.is_dataset_editor):
|
||||
raise Forbidden()
|
||||
_require_tag_edit_permission(allow_dataset_editor=True)
|
||||
|
||||
_enforce_snippet_tag_rbac_by_tag_id(tag_id_str)
|
||||
tag = TagService.update_tags(UpdateTagPayload(name=req_data.name), tag_id_str, db.session())
|
||||
_enforce_snippet_tag_rbac_by_tag_id(tag_id_str, request_context)
|
||||
try:
|
||||
tag = application_services().tags.update_tag(
|
||||
request_context,
|
||||
tag_id_str,
|
||||
UpdateTagInput(name=req_data.name),
|
||||
)
|
||||
except TagNameConflictError as error:
|
||||
raise ValueError(str(error)) from None
|
||||
except TagNotFoundError as error:
|
||||
raise NotFound(str(error)) from None
|
||||
|
||||
binding_count = TagService.get_tag_binding_count(tag_id_str, db.session())
|
||||
return dump_response(TagResponse, tag), 200
|
||||
|
||||
return (
|
||||
dump_response(
|
||||
TagResponse,
|
||||
{"id": tag.id, "name": tag.name, "type": tag.type, "binding_count": binding_count},
|
||||
),
|
||||
200,
|
||||
)
|
||||
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@edit_permission_required
|
||||
@console_account_admission()
|
||||
@console_ns.response(204, "Tag deleted successfully")
|
||||
def delete(self, tag_id: UUID):
|
||||
def delete(self, request_context: RequestContext, tag_id: UUID):
|
||||
tag_id_str = str(tag_id)
|
||||
|
||||
_enforce_snippet_tag_rbac_by_tag_id(tag_id_str)
|
||||
TagService.delete_tag(tag_id_str, db.session())
|
||||
_require_tag_edit_permission(allow_dataset_editor=False)
|
||||
_enforce_snippet_tag_rbac_by_tag_id(tag_id_str, request_context)
|
||||
try:
|
||||
application_services().tags.delete_tag(request_context, tag_id_str)
|
||||
except TagNotFoundError as error:
|
||||
raise NotFound(str(error)) from None
|
||||
|
||||
return "", 204
|
||||
|
||||
|
||||
def _require_tag_binding_edit_permission(current_user: Account) -> None:
|
||||
def _require_tag_binding_edit_permission() -> None:
|
||||
"""
|
||||
Ensure the current account can edit tag bindings.
|
||||
|
||||
Tag binding operations are allowed for users who can edit resources (app/dataset) within the current tenant.
|
||||
"""
|
||||
# The role of the current user in the ta table must be admin, owner, editor, or dataset_operator
|
||||
if not (current_user.has_edit_permission or current_user.is_dataset_editor):
|
||||
raise Forbidden()
|
||||
_require_tag_edit_permission(allow_dataset_editor=True)
|
||||
|
||||
|
||||
def _create_tag_bindings(current_user: Account, payload: TagBindingPayload) -> tuple[dict[str, str], int]:
|
||||
_require_tag_binding_edit_permission(current_user)
|
||||
def _create_tag_bindings(context: RequestContext, payload: TagBindingPayload) -> tuple[dict[str, str], int]:
|
||||
_require_tag_binding_edit_permission()
|
||||
|
||||
_enforce_snippet_tag_rbac_if_needed(payload.type)
|
||||
TagService.save_tag_binding(
|
||||
TagBindingCreatePayload(
|
||||
tag_ids=payload.tag_ids,
|
||||
target_id=payload.target_id,
|
||||
type=payload.type,
|
||||
),
|
||||
db.session(),
|
||||
)
|
||||
_enforce_snippet_tag_rbac_if_needed(payload.type, context)
|
||||
try:
|
||||
application_services().tags.create_bindings(
|
||||
context,
|
||||
TagBindingInput(
|
||||
tag_ids=tuple(payload.tag_ids),
|
||||
target_id=payload.target_id,
|
||||
type=payload.type.value,
|
||||
),
|
||||
)
|
||||
except TagBindingTargetNotFoundError as error:
|
||||
raise NotFound(str(error)) from None
|
||||
return {"result": "success"}, 200
|
||||
|
||||
|
||||
def _remove_tag_bindings(current_user: Account, payload: TagBindingRemovePayload) -> tuple[dict[str, str], int]:
|
||||
_require_tag_binding_edit_permission(current_user)
|
||||
def _remove_tag_bindings(context: RequestContext, payload: TagBindingRemovePayload) -> tuple[dict[str, str], int]:
|
||||
_require_tag_binding_edit_permission()
|
||||
|
||||
_enforce_snippet_tag_rbac_if_needed(payload.type)
|
||||
TagService.delete_tag_binding(
|
||||
TagBindingDeletePayload(
|
||||
tag_ids=payload.tag_ids,
|
||||
target_id=payload.target_id,
|
||||
type=payload.type,
|
||||
),
|
||||
db.session(),
|
||||
)
|
||||
_enforce_snippet_tag_rbac_if_needed(payload.type, context)
|
||||
try:
|
||||
application_services().tags.delete_bindings(
|
||||
context,
|
||||
TagBindingInput(
|
||||
tag_ids=tuple(payload.tag_ids),
|
||||
target_id=payload.target_id,
|
||||
type=payload.type.value,
|
||||
),
|
||||
)
|
||||
except TagBindingTargetNotFoundError as error:
|
||||
raise NotFound(str(error)) from None
|
||||
return {"result": "success"}, 200
|
||||
|
||||
|
||||
@@ -248,13 +258,10 @@ 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
|
||||
@with_current_user
|
||||
@console_account_admission()
|
||||
@model_validate(TagBindingPayload)
|
||||
def post(self, req_data: TagBindingPayload, current_user: Account):
|
||||
return _create_tag_bindings(current_user, req_data)
|
||||
def post(self, req_data: TagBindingPayload, request_context: RequestContext):
|
||||
return _create_tag_bindings(request_context, req_data)
|
||||
|
||||
|
||||
@console_ns.route("/tag-bindings/remove")
|
||||
@@ -265,10 +272,7 @@ class TagBindingRemoveApi(Resource):
|
||||
@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
|
||||
@with_current_user
|
||||
@console_account_admission()
|
||||
@model_validate(TagBindingRemovePayload)
|
||||
def post(self, req_data: TagBindingRemovePayload, current_user: Account):
|
||||
return _remove_tag_bindings(current_user, req_data)
|
||||
def post(self, req_data: TagBindingRemovePayload, request_context: RequestContext):
|
||||
return _remove_tag_bindings(request_context, req_data)
|
||||
|
||||
@@ -2,16 +2,14 @@ from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from http import HTTPStatus
|
||||
from typing import Literal
|
||||
from typing import Annotated, Literal
|
||||
|
||||
import pytz
|
||||
from flask import request
|
||||
from flask_restx import Resource
|
||||
from pydantic import BaseModel, Field, field_validator, model_validator
|
||||
from sqlalchemy import select
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
||||
from pydantic.json_schema import SkipJsonSchema
|
||||
from werkzeug.exceptions import NotFound
|
||||
|
||||
from configs import dify_config
|
||||
from constants.languages import supported_language
|
||||
from controllers.common.fields import (
|
||||
AvatarUrlResponse,
|
||||
@@ -24,48 +22,39 @@ from controllers.console import console_ns
|
||||
from controllers.console.auth.error import (
|
||||
EmailAlreadyInUseError,
|
||||
EmailChangeLimitError,
|
||||
EmailChangeRateLimitExceededError,
|
||||
EmailCodeAccountDeletionRateLimitExceededError,
|
||||
EmailCodeError,
|
||||
InvalidEmailError,
|
||||
InvalidTokenError,
|
||||
)
|
||||
from controllers.console.error import AccountInFreezeError, AccountNotFound, EmailSendIpLimitError
|
||||
from controllers.console.error import (
|
||||
AccountInFreezeError,
|
||||
AccountNotFound,
|
||||
EducationActivateLimitError,
|
||||
EducationVerifyLimitError,
|
||||
EmailDomainSuspendedError,
|
||||
EmailSendIpLimitError,
|
||||
)
|
||||
from controllers.console.flask_admission import console_account_admission
|
||||
from controllers.console.workspace.error import (
|
||||
AccountAlreadyInitedError,
|
||||
CurrentPasswordIncorrectError,
|
||||
InvalidAccountDeletionCodeError,
|
||||
InvalidAccountPasswordRequestError,
|
||||
InvalidInvitationCodeError,
|
||||
MissingInvitationCodeRequestError,
|
||||
RepeatPasswordNotMatchError,
|
||||
)
|
||||
from controllers.console.wraps import (
|
||||
account_initialization_required,
|
||||
enable_change_email,
|
||||
enterprise_license_required,
|
||||
model_validate,
|
||||
only_edition_cloud,
|
||||
setup_required,
|
||||
with_current_user,
|
||||
)
|
||||
from controllers.console.wraps import model_validate, setup_required
|
||||
from enums import DeploymentEdition
|
||||
from extensions.ext_database import db
|
||||
from extensions.ext_application_services import application_services
|
||||
from fields.base import ResponseModel
|
||||
from fields.member_fields import AccountResponse
|
||||
from graphon.file import helpers as file_helpers
|
||||
from libs.datetime_utils import naive_utc_now
|
||||
from libs.helper import EmailStr, dump_response, extract_remote_ip, timezone, to_timestamp
|
||||
from libs.login import login_required
|
||||
from models import Account, AccountIntegrate, InvitationCode
|
||||
from models.account import AccountStatus, InvitationCodeStatus
|
||||
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
|
||||
from machinery.context import RequestContext
|
||||
from services import account_errors
|
||||
from services.entities.account_entities import AccountProfileChanges
|
||||
|
||||
|
||||
class AccountInitPayload(BaseModel):
|
||||
@@ -118,6 +107,42 @@ class AccountTimezonePayload(BaseModel):
|
||||
return timezone(value)
|
||||
|
||||
|
||||
class AccountProfilePatchPayload(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
name: Annotated[str, Field(min_length=3, max_length=30)] | SkipJsonSchema[None] = None
|
||||
avatar: str | SkipJsonSchema[None] = None
|
||||
interface_language: str | SkipJsonSchema[None] = None
|
||||
interface_theme: Literal["light", "dark"] | SkipJsonSchema[None] = None
|
||||
timezone: str | SkipJsonSchema[None] = None
|
||||
|
||||
@field_validator("*", mode="before")
|
||||
@classmethod
|
||||
def reject_null(cls, value: object) -> object:
|
||||
if value is None:
|
||||
raise ValueError("Account profile fields cannot be null")
|
||||
return value
|
||||
|
||||
@field_validator("interface_language")
|
||||
@classmethod
|
||||
def validate_language(cls, value: str) -> str:
|
||||
return supported_language(value)
|
||||
|
||||
@field_validator("timezone")
|
||||
@classmethod
|
||||
def validate_timezone(cls, value: str) -> str:
|
||||
return timezone(value)
|
||||
|
||||
def to_changes(self) -> AccountProfileChanges:
|
||||
return AccountProfileChanges(
|
||||
name=self.name,
|
||||
avatar=self.avatar,
|
||||
interface_language=self.interface_language,
|
||||
interface_theme=self.interface_theme,
|
||||
timezone=self.timezone,
|
||||
)
|
||||
|
||||
|
||||
class AccountPasswordPayload(BaseModel):
|
||||
password: str | None = None
|
||||
new_password: str
|
||||
@@ -183,6 +208,7 @@ register_schema_models(
|
||||
AccountInterfaceLanguagePayload,
|
||||
AccountInterfaceThemePayload,
|
||||
AccountTimezonePayload,
|
||||
AccountProfilePatchPayload,
|
||||
AccountPasswordPayload,
|
||||
AccountDeletePayload,
|
||||
AccountDeletionFeedbackPayload,
|
||||
@@ -215,6 +241,10 @@ class EducationVerifyResponse(ResponseModel):
|
||||
token: str | None = None
|
||||
|
||||
|
||||
class EducationActivateResponse(ResponseModel):
|
||||
message: str
|
||||
|
||||
|
||||
class EducationStatusResponse(ResponseModel):
|
||||
result: bool | None = None
|
||||
is_student: bool | None = None
|
||||
@@ -239,6 +269,7 @@ register_response_schema_models(
|
||||
AccountIntegrateResponse,
|
||||
AccountIntegrateListResponse,
|
||||
AvatarUrlResponse,
|
||||
EducationActivateResponse,
|
||||
EducationVerifyResponse,
|
||||
EducationStatusResponse,
|
||||
EducationAutocompleteResponse,
|
||||
@@ -248,78 +279,75 @@ register_response_schema_models(
|
||||
)
|
||||
|
||||
|
||||
def _update_account_profile(request_context: RequestContext, changes: AccountProfileChanges) -> dict[str, object]:
|
||||
try:
|
||||
account = application_services().accounts.profile.update(request_context, changes)
|
||||
except account_errors.AccountNotFoundError as error:
|
||||
raise AccountNotFound() from error
|
||||
return dump_response(AccountResponse, account)
|
||||
|
||||
|
||||
@console_ns.route("/account/init")
|
||||
class AccountInitApi(Resource):
|
||||
@console_ns.expect(console_ns.models[AccountInitPayload.__name__])
|
||||
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[SimpleResultResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@with_current_user
|
||||
def post(self, account: Account):
|
||||
if account.status == "active":
|
||||
raise AccountAlreadyInitedError()
|
||||
|
||||
@console_account_admission(require_initialized=False)
|
||||
def post(self, request_context: RequestContext):
|
||||
payload = console_ns.payload or {}
|
||||
args = AccountInitPayload.model_validate(payload)
|
||||
|
||||
if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD:
|
||||
if not args.invitation_code:
|
||||
raise ValueError("invitation_code is required")
|
||||
|
||||
# check invitation code
|
||||
invitation_code = db.session.scalar(
|
||||
select(InvitationCode)
|
||||
.where(
|
||||
InvitationCode.code == args.invitation_code,
|
||||
InvitationCode.status == InvitationCodeStatus.UNUSED,
|
||||
)
|
||||
.limit(1)
|
||||
try:
|
||||
application_services().accounts.initialization.initialize(
|
||||
request_context,
|
||||
interface_language=args.interface_language,
|
||||
timezone=args.timezone,
|
||||
invitation_code=args.invitation_code,
|
||||
)
|
||||
|
||||
if not invitation_code:
|
||||
raise InvalidInvitationCodeError()
|
||||
|
||||
invitation_code.status = InvitationCodeStatus.USED
|
||||
invitation_code.used_at = naive_utc_now()
|
||||
invitation_code.used_by_tenant_id = account.current_tenant_id
|
||||
invitation_code.used_by_account_id = account.id
|
||||
|
||||
account.interface_language = args.interface_language
|
||||
account.timezone = args.timezone
|
||||
account.interface_theme = "light"
|
||||
account.status = AccountStatus.ACTIVE
|
||||
account.initialized_at = naive_utc_now()
|
||||
db.session.commit()
|
||||
except account_errors.AccountAlreadyInitializedError as error:
|
||||
raise AccountAlreadyInitedError() from error
|
||||
except account_errors.MissingInvitationCodeError as error:
|
||||
raise MissingInvitationCodeRequestError() from error
|
||||
except account_errors.InvalidInvitationCodeError as error:
|
||||
raise InvalidInvitationCodeError() from error
|
||||
except account_errors.AccountNotFoundError as error:
|
||||
raise AccountNotFound() from error
|
||||
|
||||
return SimpleResultResponse(result="success").model_dump(mode="json")
|
||||
|
||||
|
||||
@console_ns.route("/account/profile")
|
||||
class AccountProfileApi(Resource):
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[AccountResponse.__name__])
|
||||
@enterprise_license_required
|
||||
@with_current_user
|
||||
def get(self, current_user: Account):
|
||||
return dump_response(AccountResponse, current_user)
|
||||
@console_account_admission(require_valid_enterprise_license=True)
|
||||
def get(self, request_context: RequestContext):
|
||||
try:
|
||||
account = application_services().accounts.profile.get(request_context)
|
||||
except account_errors.AccountNotFoundError as error:
|
||||
raise AccountNotFound() from error
|
||||
return dump_response(AccountResponse, account)
|
||||
|
||||
@console_ns.expect(console_ns.models[AccountProfilePatchPayload.__name__])
|
||||
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[AccountResponse.__name__])
|
||||
@console_account_admission()
|
||||
@model_validate(AccountProfilePatchPayload)
|
||||
def patch(self, args: AccountProfilePatchPayload, request_context: RequestContext):
|
||||
return _update_account_profile(request_context, args.to_changes())
|
||||
|
||||
|
||||
@console_ns.route("/account/name")
|
||||
class AccountNameApi(Resource):
|
||||
"""Deprecated compatibility route; use PATCH /account/profile."""
|
||||
|
||||
@console_ns.doc("update_account_name_deprecated")
|
||||
@console_ns.doc(deprecated=True)
|
||||
@console_ns.doc(description="Deprecated. Use PATCH /account/profile instead.")
|
||||
@console_ns.expect(console_ns.models[AccountNamePayload.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[AccountResponse.__name__])
|
||||
@with_current_user
|
||||
def post(self, current_user: Account):
|
||||
@console_account_admission()
|
||||
def post(self, request_context: RequestContext):
|
||||
payload = console_ns.payload or {}
|
||||
args = AccountNamePayload.model_validate(payload)
|
||||
updated_account = AccountService.update_account(current_user, session=db.session(), name=args.name)
|
||||
|
||||
return dump_response(AccountResponse, updated_account)
|
||||
return _update_account_profile(request_context, AccountProfileChanges(name=args.name))
|
||||
|
||||
|
||||
@console_ns.route("/account/avatar")
|
||||
@@ -328,169 +356,139 @@ class AccountAvatarApi(Resource):
|
||||
@console_ns.doc(description="Get account avatar url")
|
||||
@console_ns.doc(params=query_params_from_model(AccountAvatarQuery))
|
||||
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[AvatarUrlResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_current_user
|
||||
@console_account_admission()
|
||||
@model_validate(AccountAvatarQuery)
|
||||
def get(self, req_data: AccountAvatarQuery, current_user: Account):
|
||||
avatar = req_data.avatar
|
||||
|
||||
if avatar.startswith(("http://", "https://")):
|
||||
return AvatarUrlResponse(avatar_url=avatar).model_dump(mode="json")
|
||||
|
||||
upload_file = db.session.scalar(select(UploadFile).where(UploadFile.id == avatar).limit(1))
|
||||
if upload_file is None:
|
||||
raise NotFound("Avatar file not found")
|
||||
|
||||
if upload_file.created_by_role != CreatorUserRole.ACCOUNT or upload_file.created_by != current_user.id:
|
||||
raise NotFound("Avatar file not found")
|
||||
|
||||
avatar_url = file_helpers.get_signed_file_url(upload_file_id=upload_file.id)
|
||||
def get(self, args: AccountAvatarQuery, request_context: RequestContext):
|
||||
try:
|
||||
avatar_url = application_services().accounts.avatar.resolve(request_context, args.avatar)
|
||||
except account_errors.AvatarFileNotFoundError as error:
|
||||
raise NotFound("Avatar file not found") from error
|
||||
return AvatarUrlResponse(avatar_url=avatar_url).model_dump(mode="json")
|
||||
|
||||
@console_ns.expect(console_ns.models[AccountAvatarPayload.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@console_ns.doc("update_account_avatar_deprecated")
|
||||
@console_ns.doc(deprecated=True)
|
||||
@console_ns.doc(description="Deprecated. Use PATCH /account/profile instead.")
|
||||
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[AccountResponse.__name__])
|
||||
@with_current_user
|
||||
def post(self, current_user: Account):
|
||||
@console_account_admission()
|
||||
def post(self, request_context: RequestContext):
|
||||
payload = console_ns.payload or {}
|
||||
args = AccountAvatarPayload.model_validate(payload)
|
||||
|
||||
updated_account = AccountService.update_account(current_user, session=db.session(), avatar=args.avatar)
|
||||
|
||||
return dump_response(AccountResponse, updated_account)
|
||||
return _update_account_profile(request_context, AccountProfileChanges(avatar=args.avatar))
|
||||
|
||||
|
||||
@console_ns.route("/account/interface-language")
|
||||
class AccountInterfaceLanguageApi(Resource):
|
||||
"""Deprecated compatibility route; use PATCH /account/profile."""
|
||||
|
||||
@console_ns.doc("update_account_interface_language_deprecated")
|
||||
@console_ns.doc(deprecated=True)
|
||||
@console_ns.doc(description="Deprecated. Use PATCH /account/profile instead.")
|
||||
@console_ns.expect(console_ns.models[AccountInterfaceLanguagePayload.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[AccountResponse.__name__])
|
||||
@with_current_user
|
||||
def post(self, current_user: Account):
|
||||
@console_account_admission()
|
||||
def post(self, request_context: RequestContext):
|
||||
payload = console_ns.payload or {}
|
||||
args = AccountInterfaceLanguagePayload.model_validate(payload)
|
||||
|
||||
updated_account = AccountService.update_account(
|
||||
current_user, session=db.session(), interface_language=args.interface_language
|
||||
return _update_account_profile(
|
||||
request_context,
|
||||
AccountProfileChanges(interface_language=args.interface_language),
|
||||
)
|
||||
|
||||
return dump_response(AccountResponse, updated_account)
|
||||
|
||||
|
||||
@console_ns.route("/account/interface-theme")
|
||||
class AccountInterfaceThemeApi(Resource):
|
||||
"""Deprecated compatibility route; use PATCH /account/profile."""
|
||||
|
||||
@console_ns.doc("update_account_interface_theme_deprecated")
|
||||
@console_ns.doc(deprecated=True)
|
||||
@console_ns.doc(description="Deprecated. Use PATCH /account/profile instead.")
|
||||
@console_ns.expect(console_ns.models[AccountInterfaceThemePayload.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[AccountResponse.__name__])
|
||||
@with_current_user
|
||||
def post(self, current_user: Account):
|
||||
@console_account_admission()
|
||||
def post(self, request_context: RequestContext):
|
||||
payload = console_ns.payload or {}
|
||||
args = AccountInterfaceThemePayload.model_validate(payload)
|
||||
|
||||
updated_account = AccountService.update_account(
|
||||
current_user, session=db.session(), interface_theme=args.interface_theme
|
||||
return _update_account_profile(
|
||||
request_context,
|
||||
AccountProfileChanges(interface_theme=args.interface_theme),
|
||||
)
|
||||
|
||||
return dump_response(AccountResponse, updated_account)
|
||||
|
||||
|
||||
@console_ns.route("/account/timezone")
|
||||
class AccountTimezoneApi(Resource):
|
||||
"""Deprecated compatibility route; use PATCH /account/profile."""
|
||||
|
||||
@console_ns.doc("update_account_timezone_deprecated")
|
||||
@console_ns.doc(deprecated=True)
|
||||
@console_ns.doc(description="Deprecated. Use PATCH /account/profile instead.")
|
||||
@console_ns.expect(console_ns.models[AccountTimezonePayload.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[AccountResponse.__name__])
|
||||
@with_current_user
|
||||
def post(self, current_user: Account):
|
||||
@console_account_admission()
|
||||
def post(self, request_context: RequestContext):
|
||||
payload = console_ns.payload or {}
|
||||
args = AccountTimezonePayload.model_validate(payload)
|
||||
|
||||
updated_account = AccountService.update_account(current_user, session=db.session(), timezone=args.timezone)
|
||||
|
||||
return dump_response(AccountResponse, updated_account)
|
||||
return _update_account_profile(request_context, AccountProfileChanges(timezone=args.timezone))
|
||||
|
||||
|
||||
@console_ns.route("/account/password")
|
||||
class AccountPasswordApi(Resource):
|
||||
@console_ns.expect(console_ns.models[AccountPasswordPayload.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[AccountResponse.__name__])
|
||||
@with_current_user
|
||||
def post(self, current_user: Account):
|
||||
@console_account_admission()
|
||||
def post(self, request_context: RequestContext):
|
||||
payload = console_ns.payload or {}
|
||||
args = AccountPasswordPayload.model_validate(payload)
|
||||
|
||||
try:
|
||||
assert args.password is not None
|
||||
AccountService.update_account_password(current_user, args.password, args.new_password, session=db.session())
|
||||
except ServiceCurrentPasswordIncorrectError:
|
||||
raise CurrentPasswordIncorrectError()
|
||||
account = application_services().accounts.password.change(
|
||||
request_context,
|
||||
current_password=args.password,
|
||||
new_password=args.new_password,
|
||||
)
|
||||
except account_errors.CurrentAccountPasswordIncorrectError as error:
|
||||
raise CurrentPasswordIncorrectError() from error
|
||||
except account_errors.InvalidAccountPasswordError as error:
|
||||
raise InvalidAccountPasswordRequestError(description=str(error)) from error
|
||||
except account_errors.AccountNotFoundError as error:
|
||||
raise AccountNotFound() from error
|
||||
|
||||
return dump_response(AccountResponse, current_user)
|
||||
return dump_response(AccountResponse, account)
|
||||
|
||||
|
||||
@console_ns.route("/account/integrates")
|
||||
class AccountIntegrateApi(Resource):
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[AccountIntegrateListResponse.__name__])
|
||||
@with_current_user
|
||||
def get(self, account: Account):
|
||||
account_integrates = db.session.scalars(
|
||||
select(AccountIntegrate).where(AccountIntegrate.account_id == account.id)
|
||||
).all()
|
||||
|
||||
@console_account_admission()
|
||||
def get(self, request_context: RequestContext):
|
||||
base_url = request.url_root.rstrip("/")
|
||||
oauth_base_path = "/console/api/oauth/login"
|
||||
providers = ["github", "google"]
|
||||
|
||||
integrate_data: list[AccountIntegrateResponse] = []
|
||||
for provider in providers:
|
||||
existing_integrate = next((ai for ai in account_integrates if ai.provider == provider), None)
|
||||
if existing_integrate:
|
||||
integrate_data.append(
|
||||
AccountIntegrateResponse(
|
||||
provider=provider,
|
||||
created_at=to_timestamp(existing_integrate.created_at),
|
||||
is_bound=True,
|
||||
link=None,
|
||||
)
|
||||
)
|
||||
else:
|
||||
integrate_data.append(
|
||||
AccountIntegrateResponse(
|
||||
provider=provider,
|
||||
created_at=None,
|
||||
is_bound=False,
|
||||
link=f"{base_url}{oauth_base_path}/{provider}",
|
||||
)
|
||||
)
|
||||
integrations = application_services().accounts.integrations.list(request_context)
|
||||
integrate_data = [
|
||||
AccountIntegrateResponse(
|
||||
provider=integration.provider,
|
||||
created_at=to_timestamp(integration.created_at),
|
||||
is_bound=integration.is_bound,
|
||||
link=(None if integration.is_bound else f"{base_url}{oauth_base_path}/{integration.provider}"),
|
||||
)
|
||||
for integration in integrations
|
||||
]
|
||||
|
||||
return AccountIntegrateListResponse(data=integrate_data).model_dump(mode="json")
|
||||
|
||||
|
||||
@console_ns.route("/account/delete/verify")
|
||||
class AccountDeleteVerifyApi(Resource):
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[SimpleResultDataResponse.__name__])
|
||||
@with_current_user
|
||||
def get(self, account: Account):
|
||||
token, code = AccountService.generate_account_deletion_verification_code(account)
|
||||
AccountService.send_account_deletion_verification_email(account, code)
|
||||
@console_account_admission()
|
||||
def get(self, request_context: RequestContext):
|
||||
try:
|
||||
token = application_services().accounts.deletion.issue_verification(request_context)
|
||||
except account_errors.AccountDeletionRateLimitError as error:
|
||||
raise EmailCodeAccountDeletionRateLimitExceededError(error.retry_after_minutes) from None
|
||||
except account_errors.AccountNotFoundError:
|
||||
raise AccountNotFound() from None
|
||||
|
||||
return SimpleResultDataResponse(result="success", data=token).model_dump(mode="json")
|
||||
|
||||
@@ -499,18 +497,19 @@ class AccountDeleteVerifyApi(Resource):
|
||||
class AccountDeleteApi(Resource):
|
||||
@console_ns.expect(console_ns.models[AccountDeletePayload.__name__])
|
||||
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[SimpleResultResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_current_user
|
||||
def post(self, account: Account):
|
||||
@console_account_admission()
|
||||
def post(self, request_context: RequestContext):
|
||||
payload = console_ns.payload or {}
|
||||
args = AccountDeletePayload.model_validate(payload)
|
||||
|
||||
if not AccountService.verify_account_deletion_code(args.token, args.code):
|
||||
raise InvalidAccountDeletionCodeError()
|
||||
|
||||
AccountService.delete_account(account, session=db.session())
|
||||
try:
|
||||
application_services().accounts.deletion.request_deletion(
|
||||
request_context,
|
||||
token=args.token,
|
||||
code=args.code,
|
||||
)
|
||||
except account_errors.InvalidAccountDeletionVerificationError:
|
||||
raise InvalidAccountDeletionCodeError() from None
|
||||
|
||||
return SimpleResultResponse(result="success").model_dump(mode="json")
|
||||
|
||||
@@ -524,71 +523,69 @@ class AccountDeleteUpdateFeedbackApi(Resource):
|
||||
payload = console_ns.payload or {}
|
||||
args = AccountDeletionFeedbackPayload.model_validate(payload)
|
||||
|
||||
BillingService.update_account_deletion_feedback(args.email, args.feedback)
|
||||
application_services().accounts.deletion_feedback.submit(email=args.email, feedback=args.feedback)
|
||||
|
||||
return SimpleResultResponse(result="success").model_dump(mode="json")
|
||||
|
||||
|
||||
@console_ns.route("/account/education/verify")
|
||||
class EducationVerifyApi(Resource):
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@only_edition_cloud
|
||||
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[EducationVerifyResponse.__name__])
|
||||
@with_current_user
|
||||
def get(self, account: Account):
|
||||
return dump_response(
|
||||
EducationVerifyResponse, BillingService.EducationIdentity.verify(account.id, account.email) or {}
|
||||
)
|
||||
@console_account_admission(editions=frozenset({DeploymentEdition.CLOUD}))
|
||||
def get(self, request_context: RequestContext):
|
||||
try:
|
||||
verification = application_services().accounts.education.verify(request_context)
|
||||
except account_errors.AccountNotFoundError as error:
|
||||
raise AccountNotFound() from error
|
||||
except account_errors.EducationRateLimitExceededError as error:
|
||||
raise EducationVerifyLimitError() from error
|
||||
return dump_response(EducationVerifyResponse, verification)
|
||||
|
||||
|
||||
@console_ns.route("/account/education")
|
||||
class EducationApi(Resource):
|
||||
@console_ns.expect(console_ns.models[EducationActivatePayload.__name__])
|
||||
# response-contract:ignore billing-service activation payload; TODO: model education activation result.
|
||||
@console_ns.response(HTTPStatus.OK, "Success")
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@only_edition_cloud
|
||||
@with_current_user
|
||||
def post(self, account: Account):
|
||||
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[EducationActivateResponse.__name__])
|
||||
@console_account_admission(editions=frozenset({DeploymentEdition.CLOUD}))
|
||||
def post(self, request_context: RequestContext):
|
||||
payload = console_ns.payload or {}
|
||||
args = EducationActivatePayload.model_validate(payload)
|
||||
try:
|
||||
activation = application_services().accounts.education.activate(
|
||||
request_context,
|
||||
token=args.token,
|
||||
institution=args.institution,
|
||||
role=args.role,
|
||||
)
|
||||
except account_errors.AccountNotFoundError as error:
|
||||
raise AccountNotFound() from error
|
||||
except account_errors.EducationRateLimitExceededError as error:
|
||||
raise EducationActivateLimitError() from error
|
||||
return dump_response(EducationActivateResponse, activation)
|
||||
|
||||
result = BillingService.EducationIdentity.activate(account, args.token, args.institution, args.role)
|
||||
return result
|
||||
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@only_edition_cloud
|
||||
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[EducationStatusResponse.__name__])
|
||||
@with_current_user
|
||||
def get(self, account: Account):
|
||||
res = BillingService.EducationIdentity.status(account.id) or {}
|
||||
# convert expire_at to UTC timestamp from isoformat
|
||||
if res and "expire_at" in res:
|
||||
res["expire_at"] = datetime.fromisoformat(res["expire_at"]).astimezone(pytz.utc)
|
||||
return dump_response(EducationStatusResponse, res)
|
||||
@console_account_admission(editions=frozenset({DeploymentEdition.CLOUD}))
|
||||
def get(self, request_context: RequestContext):
|
||||
return dump_response(EducationStatusResponse, application_services().accounts.education.status(request_context))
|
||||
|
||||
|
||||
@console_ns.route("/account/education/autocomplete")
|
||||
class EducationAutoCompleteApi(Resource):
|
||||
@console_ns.doc(params=query_params_from_model(EducationAutocompleteQuery))
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@only_edition_cloud
|
||||
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[EducationAutocompleteResponse.__name__])
|
||||
def get(self):
|
||||
@console_account_admission(editions=frozenset({DeploymentEdition.CLOUD}))
|
||||
def get(self, request_context: RequestContext):
|
||||
payload = request.args.to_dict(flat=True)
|
||||
args = EducationAutocompleteQuery.model_validate(payload)
|
||||
|
||||
return dump_response(
|
||||
EducationAutocompleteResponse,
|
||||
BillingService.EducationIdentity.autocomplete(args.keywords, args.page, args.limit) or {},
|
||||
application_services().accounts.education.autocomplete(
|
||||
request_context,
|
||||
keywords=args.keywords,
|
||||
page=args.page,
|
||||
limit=args.limit,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -596,59 +593,32 @@ class EducationAutoCompleteApi(Resource):
|
||||
class ChangeEmailSendEmailApi(Resource):
|
||||
@console_ns.expect(console_ns.models[ChangeEmailSendPayload.__name__])
|
||||
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[SimpleResultDataResponse.__name__])
|
||||
@enable_change_email
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_current_user
|
||||
def post(self, current_user: Account):
|
||||
@console_account_admission(require_change_email_enabled=True)
|
||||
def post(self, request_context: RequestContext):
|
||||
payload = console_ns.payload or {}
|
||||
args = ChangeEmailSendPayload.model_validate(payload)
|
||||
|
||||
ip_address = extract_remote_ip(request)
|
||||
if AccountService.is_email_send_ip_limit(ip_address):
|
||||
raise EmailSendIpLimitError()
|
||||
|
||||
if args.language is not None and args.language == "zh-Hans":
|
||||
language = "zh-Hans"
|
||||
else:
|
||||
language = "en-US"
|
||||
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
|
||||
# later phases without a verified predecessor token.
|
||||
send_phase = AccountService.CHANGE_EMAIL_PHASE_OLD
|
||||
if args.phase is not None and args.phase == AccountService.CHANGE_EMAIL_PHASE_NEW:
|
||||
send_phase = AccountService.CHANGE_EMAIL_PHASE_NEW
|
||||
if args.token is None:
|
||||
raise InvalidTokenError()
|
||||
|
||||
reset_data = AccountService.get_change_email_data(args.token)
|
||||
if reset_data is None:
|
||||
raise InvalidTokenError()
|
||||
|
||||
if not isinstance(reset_data, ChangeEmailOldEmailVerifiedToken):
|
||||
raise InvalidTokenError()
|
||||
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()
|
||||
else:
|
||||
if email_for_sending != current_user.email.lower():
|
||||
raise InvalidEmailError()
|
||||
email_for_sending = current_user.email
|
||||
|
||||
token = AccountService.send_change_email_email(
|
||||
account=account,
|
||||
email=email_for_sending,
|
||||
old_email=user_email,
|
||||
language=language,
|
||||
phase=send_phase,
|
||||
)
|
||||
language = "zh-Hans" if args.language == "zh-Hans" else "en-US"
|
||||
try:
|
||||
token = application_services().accounts.change_email.send_code(
|
||||
request_context,
|
||||
requested_email=args.email,
|
||||
language=language,
|
||||
phase=args.phase,
|
||||
predecessor_token=args.token,
|
||||
ip_address=ip_address,
|
||||
)
|
||||
except account_errors.ChangeEmailSendIPLimitedError:
|
||||
raise EmailSendIpLimitError() from None
|
||||
except account_errors.ChangeEmailSendRateLimitError as error:
|
||||
raise EmailChangeRateLimitExceededError(error.retry_after_minutes) from None
|
||||
except account_errors.InvalidChangeEmailTokenError:
|
||||
raise InvalidTokenError() from None
|
||||
except account_errors.InvalidChangeEmailAddressError:
|
||||
raise InvalidEmailError() from None
|
||||
except account_errors.AccountNotFoundError:
|
||||
raise AccountNotFound() from None
|
||||
return SimpleResultDataResponse(result="success", data=token).model_dump(mode="json")
|
||||
|
||||
|
||||
@@ -656,47 +626,27 @@ class ChangeEmailSendEmailApi(Resource):
|
||||
class ChangeEmailCheckApi(Resource):
|
||||
@console_ns.expect(console_ns.models[ChangeEmailValidityPayload.__name__])
|
||||
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[VerificationTokenResponse.__name__])
|
||||
@enable_change_email
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_current_user
|
||||
def post(self, current_user: Account):
|
||||
@console_account_admission(require_change_email_enabled=True)
|
||||
def post(self, request_context: RequestContext):
|
||||
payload = console_ns.payload or {}
|
||||
args = ChangeEmailValidityPayload.model_validate(payload)
|
||||
|
||||
user_email = args.email.lower()
|
||||
|
||||
is_change_email_error_rate_limit = AccountService.is_change_email_error_rate_limit(user_email)
|
||||
if is_change_email_error_rate_limit:
|
||||
raise EmailChangeLimitError()
|
||||
|
||||
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()
|
||||
|
||||
normalized_token_email = token_data.email.lower()
|
||||
if user_email != normalized_token_email:
|
||||
raise InvalidEmailError()
|
||||
|
||||
if args.code != token_data.code:
|
||||
AccountService.add_change_email_error_rate_limit(user_email)
|
||||
raise EmailCodeError()
|
||||
|
||||
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)
|
||||
|
||||
new_token = AccountService.generate_change_email_token(refreshed_token_data, current_user)
|
||||
|
||||
AccountService.reset_change_email_error_rate_limit(user_email)
|
||||
return VerificationTokenResponse(is_valid=True, email=normalized_token_email, token=new_token).model_dump(
|
||||
try:
|
||||
verification = application_services().accounts.change_email.verify_code(
|
||||
request_context,
|
||||
email=args.email,
|
||||
code=args.code,
|
||||
token=args.token,
|
||||
)
|
||||
except account_errors.ChangeEmailVerificationLimitError:
|
||||
raise EmailChangeLimitError() from None
|
||||
except account_errors.InvalidChangeEmailTokenError:
|
||||
raise InvalidTokenError() from None
|
||||
except account_errors.InvalidChangeEmailAddressError:
|
||||
raise InvalidEmailError() from None
|
||||
except account_errors.InvalidChangeEmailCodeError:
|
||||
raise EmailCodeError() from None
|
||||
return VerificationTokenResponse(is_valid=True, email=verification.email, token=verification.token).model_dump(
|
||||
mode="json"
|
||||
)
|
||||
|
||||
@@ -704,51 +654,27 @@ class ChangeEmailCheckApi(Resource):
|
||||
@console_ns.route("/account/change-email/reset")
|
||||
class ChangeEmailResetApi(Resource):
|
||||
@console_ns.expect(console_ns.models[ChangeEmailResetPayload.__name__])
|
||||
@enable_change_email
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[AccountResponse.__name__])
|
||||
@with_current_user
|
||||
def post(self, current_user: Account):
|
||||
@console_account_admission(require_change_email_enabled=True)
|
||||
def post(self, request_context: RequestContext):
|
||||
payload = console_ns.payload or {}
|
||||
args = ChangeEmailResetPayload.model_validate(payload)
|
||||
normalized_new_email = args.new_email.lower()
|
||||
|
||||
if AccountService.is_account_in_freeze(normalized_new_email):
|
||||
raise AccountInFreezeError()
|
||||
|
||||
if not AccountService.check_email_unique(normalized_new_email, session=db.session()):
|
||||
raise EmailAlreadyInUseError()
|
||||
|
||||
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()
|
||||
|
||||
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.
|
||||
if reset_data.email.lower() != normalized_new_email:
|
||||
raise InvalidTokenError()
|
||||
|
||||
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
|
||||
# legitimately verified token.
|
||||
AccountService.revoke_change_email_token(args.token)
|
||||
|
||||
updated_account = AccountService.update_account_email(
|
||||
current_user, email=normalized_new_email, session=db.session()
|
||||
)
|
||||
|
||||
AccountService.send_change_email_completed_notify_email(
|
||||
email=normalized_new_email,
|
||||
)
|
||||
try:
|
||||
updated_account = application_services().accounts.change_email.reset(
|
||||
request_context,
|
||||
new_email=args.new_email,
|
||||
token=args.token,
|
||||
)
|
||||
except account_errors.AccountEmailDomainSuspendedError:
|
||||
raise EmailDomainSuspendedError() from None
|
||||
except account_errors.AccountEmailFrozenError:
|
||||
raise AccountInFreezeError() from None
|
||||
except account_errors.AccountEmailAlreadyInUseError:
|
||||
raise EmailAlreadyInUseError() from None
|
||||
except account_errors.InvalidChangeEmailTokenError:
|
||||
raise InvalidTokenError() from None
|
||||
except account_errors.AccountNotFoundError:
|
||||
raise AccountNotFound() from None
|
||||
|
||||
return dump_response(AccountResponse, updated_account)
|
||||
|
||||
@@ -761,9 +687,12 @@ class CheckEmailUnique(Resource):
|
||||
def post(self):
|
||||
payload = console_ns.payload or {}
|
||||
args = CheckEmailUniquePayload.model_validate(payload)
|
||||
normalized_email = args.email.lower()
|
||||
if AccountService.is_account_in_freeze(normalized_email):
|
||||
raise AccountInFreezeError()
|
||||
if not AccountService.check_email_unique(normalized_email, session=db.session()):
|
||||
raise EmailAlreadyInUseError()
|
||||
try:
|
||||
application_services().accounts.change_email.ensure_available(args.email)
|
||||
except account_errors.AccountEmailDomainSuspendedError:
|
||||
raise EmailDomainSuspendedError() from None
|
||||
except account_errors.AccountEmailFrozenError:
|
||||
raise AccountInFreezeError() from None
|
||||
except account_errors.AccountEmailAlreadyInUseError:
|
||||
raise EmailAlreadyInUseError() from None
|
||||
return SimpleResultResponse(result="success").model_dump(mode="json")
|
||||
|
||||
@@ -290,6 +290,8 @@ class EndpointListApi(Resource):
|
||||
)
|
||||
@setup_required
|
||||
@login_required
|
||||
@is_admin_or_owner_required
|
||||
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_MODEL_CONFIG, resource_required=False)
|
||||
@account_initialization_required
|
||||
@with_current_user_id
|
||||
@with_current_tenant_id
|
||||
@@ -318,6 +320,8 @@ class EndpointListForSinglePluginApi(Resource):
|
||||
)
|
||||
@setup_required
|
||||
@login_required
|
||||
@is_admin_or_owner_required
|
||||
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_MODEL_CONFIG, resource_required=False)
|
||||
@account_initialization_required
|
||||
@with_current_user_id
|
||||
@with_current_tenant_id
|
||||
|
||||
@@ -19,12 +19,24 @@ class CurrentPasswordIncorrectError(BaseHTTPException):
|
||||
code = 400
|
||||
|
||||
|
||||
class InvalidAccountPasswordRequestError(BaseHTTPException):
|
||||
error_code = "invalid_account_password"
|
||||
description = "The password does not satisfy the account password policy."
|
||||
code = 400
|
||||
|
||||
|
||||
class InvalidInvitationCodeError(BaseHTTPException):
|
||||
error_code = "invalid_invitation_code"
|
||||
description = "Invalid invitation code."
|
||||
code = 400
|
||||
|
||||
|
||||
class MissingInvitationCodeRequestError(BaseHTTPException):
|
||||
error_code = "missing_invitation_code"
|
||||
description = "Invitation code is required."
|
||||
code = 400
|
||||
|
||||
|
||||
class AccountAlreadyInitedError(BaseHTTPException):
|
||||
error_code = "account_already_inited"
|
||||
description = "The account has been initialized. Please refresh the page."
|
||||
|
||||
@@ -20,7 +20,6 @@ from controllers.console.wraps import (
|
||||
with_current_tenant_id,
|
||||
with_current_user,
|
||||
)
|
||||
from extensions.ext_database import db
|
||||
from fields.base import ResponseModel
|
||||
from graphon.model_runtime.entities.model_entities import ModelType
|
||||
from graphon.model_runtime.errors.validate import CredentialsValidateFailedError
|
||||
@@ -209,6 +208,8 @@ class ModelProviderCredentialApi(Resource):
|
||||
)
|
||||
@setup_required
|
||||
@login_required
|
||||
@is_admin_or_owner_required
|
||||
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.CREDENTIAL_MANAGE, resource_required=False)
|
||||
@account_initialization_required
|
||||
@with_current_tenant_id
|
||||
def get(self, tenant_id: str, provider: str):
|
||||
@@ -401,6 +402,7 @@ class PreferredProviderTypeUpdateApi(Resource):
|
||||
|
||||
@console_ns.route("/workspaces/current/model-providers/<path:provider>/checkout-url")
|
||||
class ModelProviderPaymentCheckoutUrlApi(Resource):
|
||||
@console_ns.doc(deprecated=True)
|
||||
@console_ns.response(
|
||||
200,
|
||||
"Model provider checkout URL retrieved successfully",
|
||||
@@ -408,13 +410,14 @@ class ModelProviderPaymentCheckoutUrlApi(Resource):
|
||||
)
|
||||
@setup_required
|
||||
@login_required
|
||||
@is_admin_or_owner_required
|
||||
@account_initialization_required
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
def get(self, current_tenant_id: str, current_user: Account, provider: str):
|
||||
if provider != "anthropic":
|
||||
raise ValueError(f"provider name {provider} is invalid")
|
||||
BillingService.is_tenant_owner_or_admin(current_user, session=db.session())
|
||||
# pyrefly: ignore [deprecated]
|
||||
data = BillingService.get_model_provider_payment_link(
|
||||
provider_name=provider,
|
||||
tenant_id=current_tenant_id,
|
||||
|
||||
@@ -210,6 +210,8 @@ class DefaultModelApi(Resource):
|
||||
)
|
||||
@setup_required
|
||||
@login_required
|
||||
@is_admin_or_owner_required
|
||||
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_PREFERENCES, resource_required=False)
|
||||
@account_initialization_required
|
||||
@with_current_tenant_id
|
||||
@model_validate(ParserGetDefault)
|
||||
@@ -263,6 +265,8 @@ class ModelProviderModelApi(Resource):
|
||||
)
|
||||
@setup_required
|
||||
@login_required
|
||||
@is_admin_or_owner_required
|
||||
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_PREFERENCES, resource_required=False)
|
||||
@account_initialization_required
|
||||
@with_current_tenant_id
|
||||
def get(self, tenant_id: str, provider: str):
|
||||
@@ -349,6 +353,8 @@ class ModelProviderModelCredentialApi(Resource):
|
||||
)
|
||||
@setup_required
|
||||
@login_required
|
||||
@is_admin_or_owner_required
|
||||
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.CREDENTIAL_MANAGE, resource_required=False)
|
||||
@account_initialization_required
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
|
||||
883
api/controllers/console/workspace/skills.py
Normal file
883
api/controllers/console/workspace/skills.py
Normal file
@@ -0,0 +1,883 @@
|
||||
"""Console API for workspace-level Skill Management."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
|
||||
from flask import request, send_file
|
||||
from flask_restx import Resource
|
||||
from pydantic import BaseModel, ConfigDict, Field, ValidationError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from controllers.common.fields import BinaryFileResponse
|
||||
from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models
|
||||
from controllers.common.session import with_session
|
||||
from controllers.console import console_ns
|
||||
from controllers.console.flask_admission import console_account_admission
|
||||
from controllers.console.wraps import (
|
||||
RBACPermission,
|
||||
RBACResourceScope,
|
||||
edit_permission_required,
|
||||
)
|
||||
from fields.base import ResponseModel
|
||||
from libs import helper
|
||||
from libs.helper import dump_response
|
||||
from machinery.context import RequestContext
|
||||
from services.skill_management_service import (
|
||||
SkillAssistMessagePayload,
|
||||
SkillCreatePayload,
|
||||
SkillDraftFileCheckPayload,
|
||||
SkillDraftFileOperationPayload,
|
||||
SkillDraftTreePayload,
|
||||
SkillImportPayload,
|
||||
SkillManagementService,
|
||||
SkillManagementServiceError,
|
||||
SkillMetadataPayload,
|
||||
SkillPublishPayload,
|
||||
SkillRestorePayload,
|
||||
SkillVersionUpdatePayload,
|
||||
)
|
||||
|
||||
_FILE_UPLOAD_PARAMS = {
|
||||
"file": {
|
||||
"description": "Skill draft file payload",
|
||||
"in": "formData",
|
||||
"type": "file",
|
||||
"required": True,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class WorkspaceSkillsQuery(BaseModel):
|
||||
keyword: str | None = Field(default=None, description="Search keyword matching skill name or description.")
|
||||
page: int = Field(default=1, ge=1, le=99999, description="Page number.")
|
||||
limit: int = Field(default=20, ge=1, le=100, description="Number of items per page.")
|
||||
tag: list[str] = Field(
|
||||
default_factory=list,
|
||||
description="Skill tag filters. Repeat the parameter for multiple tags.",
|
||||
)
|
||||
|
||||
|
||||
class SkillDeletePayload(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
confirmation_name: str | None = Field(
|
||||
default=None,
|
||||
description="Required when deleting a referenced Skill. Must match the Skill display name.",
|
||||
)
|
||||
|
||||
|
||||
class AgentSkillBindingsPayload(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
skill_ids: list[str] = Field(default_factory=list, description="Ordered Skill IDs bound to the Agent.")
|
||||
|
||||
|
||||
class SkillFileQuery(BaseModel):
|
||||
path: str = Field(description="Skill file path relative to the Skill root.")
|
||||
version_id: str | None = Field(default=None, description="Optional published version ID. Omit for current draft.")
|
||||
|
||||
|
||||
class SkillResponse(ResponseModel):
|
||||
id: str
|
||||
name: str
|
||||
display_name: str
|
||||
icon: str
|
||||
description: str
|
||||
tags: list[str] = Field(default_factory=list)
|
||||
name_manually_edited: bool = False
|
||||
visibility: str
|
||||
latest_published_version_id: str | None = None
|
||||
latest_published_version_number: int | None = None
|
||||
latest_published_at: int | None = None
|
||||
reference_count: int = 0
|
||||
created_by: str | None = None
|
||||
created_by_name: str | None = None
|
||||
updated_by: str | None = None
|
||||
updated_by_name: str | None = None
|
||||
created_at: int
|
||||
updated_at: int
|
||||
|
||||
|
||||
class SkillFileResponse(ResponseModel):
|
||||
id: str | None = None
|
||||
path: str
|
||||
kind: str
|
||||
storage: str | None = None
|
||||
mime_type: str | None = None
|
||||
content: str | None = None
|
||||
tool_file_id: str | None = None
|
||||
size: int | None = None
|
||||
hash: str | None = None
|
||||
|
||||
|
||||
class SkillFilePreviewResponse(ResponseModel):
|
||||
path: str
|
||||
mime_type: str
|
||||
content: str
|
||||
size: int
|
||||
hash: str
|
||||
|
||||
|
||||
class SkillFileUploadResponse(ResponseModel):
|
||||
id: str
|
||||
name: str
|
||||
mime_type: str
|
||||
size: int
|
||||
hash: str
|
||||
|
||||
|
||||
class SkillFileCheckErrorResponse(ResponseModel):
|
||||
code: str
|
||||
message: str
|
||||
|
||||
|
||||
class SkillFileCheckItemResponse(ResponseModel):
|
||||
path: str
|
||||
filename: str
|
||||
extension: str
|
||||
mime_type: str
|
||||
size: int
|
||||
errors: list[SkillFileCheckErrorResponse] = Field(default_factory=list)
|
||||
|
||||
|
||||
class SkillFileCheckResponse(ResponseModel):
|
||||
data: dict[str, SkillFileCheckItemResponse] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class SkillDetailResponse(SkillResponse):
|
||||
files: list[SkillFileResponse] = Field(default_factory=list)
|
||||
|
||||
|
||||
class SkillListResponse(ResponseModel):
|
||||
data: list[SkillResponse] = Field(default_factory=list)
|
||||
has_more: bool = False
|
||||
limit: int = 20
|
||||
page: int = 1
|
||||
total: int = 0
|
||||
|
||||
|
||||
class SkillTagResponse(ResponseModel):
|
||||
tag: str
|
||||
count: int
|
||||
|
||||
|
||||
class SkillTagListResponse(ResponseModel):
|
||||
data: list[SkillTagResponse] = Field(default_factory=list)
|
||||
|
||||
|
||||
class SkillVersionResponse(ResponseModel):
|
||||
id: str
|
||||
skill_id: str
|
||||
version_number: int
|
||||
version_name: str
|
||||
publish_note: str
|
||||
hash_code: str
|
||||
archive_size: int
|
||||
published_by: str | None = None
|
||||
published_by_name: str | None = None
|
||||
is_latest: bool = False
|
||||
created_at: int
|
||||
|
||||
|
||||
class SkillVersionListResponse(ResponseModel):
|
||||
data: list[SkillVersionResponse] = Field(default_factory=list)
|
||||
|
||||
|
||||
class SkillVersionDetailResponse(SkillVersionResponse):
|
||||
files: list[SkillFileResponse] = Field(default_factory=list)
|
||||
|
||||
|
||||
class SkillVersionDeleteResponse(ResponseModel):
|
||||
id: str
|
||||
deleted: bool
|
||||
latest_published_version_id: str | None = None
|
||||
|
||||
|
||||
class SkillReferenceResponse(ResponseModel):
|
||||
type: str
|
||||
agent_id: str
|
||||
agent_icon: str | None = None
|
||||
agent_icon_background: str | None = None
|
||||
agent_icon_type: str | None = None
|
||||
app_id: str | None = None
|
||||
name: str
|
||||
display_name: str
|
||||
workflow_id: str | None = None
|
||||
workflow_name: str | None = None
|
||||
workflow_icon: str | None = None
|
||||
workflow_icon_background: str | None = None
|
||||
workflow_icon_type: str | None = None
|
||||
workflow_version: str | None = None
|
||||
node_id: str | None = None
|
||||
node_name: str | None = None
|
||||
|
||||
|
||||
class SkillReferenceListResponse(ResponseModel):
|
||||
data: list[SkillReferenceResponse] = Field(default_factory=list)
|
||||
|
||||
|
||||
class SkillDeleteResponse(ResponseModel):
|
||||
id: str
|
||||
deleted: bool
|
||||
|
||||
|
||||
class AgentSkillBindingItemResponse(ResponseModel):
|
||||
id: str
|
||||
priority: int
|
||||
name: str
|
||||
display_name: str
|
||||
icon: str
|
||||
description: str
|
||||
tags: list[str] = Field(default_factory=list)
|
||||
status: str
|
||||
file_count: int
|
||||
latest_published_version_id: str | None = None
|
||||
latest_published_at: int | None = None
|
||||
updated_at: int
|
||||
|
||||
|
||||
class AgentSkillBindingsResponse(ResponseModel):
|
||||
agent_id: str
|
||||
skill_ids: list[str] = Field(default_factory=list)
|
||||
data: list[AgentSkillBindingItemResponse] = Field(default_factory=list)
|
||||
|
||||
|
||||
register_schema_models(
|
||||
console_ns,
|
||||
WorkspaceSkillsQuery,
|
||||
SkillCreatePayload,
|
||||
SkillAssistMessagePayload,
|
||||
SkillMetadataPayload,
|
||||
SkillDraftFileCheckPayload,
|
||||
SkillDraftFileOperationPayload,
|
||||
SkillDraftTreePayload,
|
||||
SkillPublishPayload,
|
||||
SkillRestorePayload,
|
||||
SkillVersionUpdatePayload,
|
||||
SkillDeletePayload,
|
||||
SkillFileQuery,
|
||||
AgentSkillBindingsPayload,
|
||||
)
|
||||
|
||||
register_response_schema_models(
|
||||
console_ns,
|
||||
SkillResponse,
|
||||
SkillFileResponse,
|
||||
SkillFilePreviewResponse,
|
||||
SkillFileUploadResponse,
|
||||
SkillFileCheckErrorResponse,
|
||||
SkillFileCheckItemResponse,
|
||||
SkillFileCheckResponse,
|
||||
SkillDetailResponse,
|
||||
SkillListResponse,
|
||||
SkillTagResponse,
|
||||
SkillTagListResponse,
|
||||
SkillVersionResponse,
|
||||
SkillVersionListResponse,
|
||||
SkillVersionDetailResponse,
|
||||
SkillVersionDeleteResponse,
|
||||
SkillReferenceResponse,
|
||||
SkillReferenceListResponse,
|
||||
SkillDeleteResponse,
|
||||
AgentSkillBindingItemResponse,
|
||||
AgentSkillBindingsResponse,
|
||||
BinaryFileResponse,
|
||||
)
|
||||
|
||||
|
||||
def _error_response(exc: SkillManagementServiceError) -> tuple[dict[str, object], int]:
|
||||
body: dict[str, object] = {"code": exc.code, "message": exc.message}
|
||||
if exc.details:
|
||||
body["details"] = exc.details
|
||||
return body, exc.status_code
|
||||
|
||||
|
||||
def _workspace_id(context: RequestContext) -> str:
|
||||
if context.active_workspace_id is None:
|
||||
raise RuntimeError("Console account admission did not resolve an active workspace")
|
||||
return context.active_workspace_id
|
||||
|
||||
|
||||
@console_ns.route("/workspaces/current/skills")
|
||||
class WorkspaceSkillsApi(Resource):
|
||||
@console_ns.doc(params=query_params_from_model(WorkspaceSkillsQuery))
|
||||
@console_ns.response(200, "Workspace skills", console_ns.models[SkillListResponse.__name__])
|
||||
@console_account_admission(
|
||||
rbac_resource_scope=RBACResourceScope.WORKSPACE,
|
||||
rbac_permission=RBACPermission.SKILL_VIEW,
|
||||
rbac_resource_required=False,
|
||||
)
|
||||
@with_session(write=False)
|
||||
def get(self, session: Session, request_context: RequestContext):
|
||||
query_input: dict[str, object] = {
|
||||
"keyword": request.args.get("keyword"),
|
||||
"tag": request.args.getlist("tag"),
|
||||
}
|
||||
if "limit" in request.args:
|
||||
query_input["limit"] = request.args.get("limit")
|
||||
if "page" in request.args:
|
||||
query_input["page"] = request.args.get("page")
|
||||
query = WorkspaceSkillsQuery.model_validate(query_input)
|
||||
result = SkillManagementService(session=session).list_skills(
|
||||
tenant_id=_workspace_id(request_context),
|
||||
keyword=query.keyword,
|
||||
page=query.page,
|
||||
limit=query.limit,
|
||||
tags=[tag for tag in query.tag if tag],
|
||||
)
|
||||
return dump_response(SkillListResponse, result)
|
||||
|
||||
@console_ns.expect(console_ns.models[SkillCreatePayload.__name__])
|
||||
@console_ns.response(201, "Skill created", console_ns.models[SkillDetailResponse.__name__])
|
||||
@console_account_admission(
|
||||
rbac_resource_scope=RBACResourceScope.WORKSPACE,
|
||||
rbac_permission=RBACPermission.SKILL_EDIT,
|
||||
rbac_resource_required=False,
|
||||
)
|
||||
@edit_permission_required
|
||||
@with_session
|
||||
def post(self, session: Session, request_context: RequestContext):
|
||||
try:
|
||||
payload = SkillCreatePayload.model_validate(console_ns.payload or {})
|
||||
result = SkillManagementService(session=session).create_skill(
|
||||
tenant_id=_workspace_id(request_context),
|
||||
user_id=request_context.account_id,
|
||||
payload=payload,
|
||||
)
|
||||
return dump_response(SkillDetailResponse, result), 201
|
||||
except ValidationError as exc:
|
||||
return {"code": "invalid_request", "message": str(exc)}, 400
|
||||
except ValueError as exc:
|
||||
return {"code": "invalid_request", "message": str(exc)}, 400
|
||||
except SkillManagementServiceError as exc:
|
||||
return _error_response(exc)
|
||||
|
||||
|
||||
@console_ns.route("/workspaces/current/skills/files/upload")
|
||||
class WorkspaceSkillFileUploadApi(Resource):
|
||||
@console_ns.doc(consumes=["multipart/form-data"], params=_FILE_UPLOAD_PARAMS)
|
||||
@console_ns.response(201, "Skill draft file uploaded", console_ns.models[SkillFileUploadResponse.__name__])
|
||||
@console_account_admission()
|
||||
@edit_permission_required
|
||||
@with_session
|
||||
def post(self, session: Session, request_context: RequestContext):
|
||||
if "file" not in request.files:
|
||||
return {"code": "no_file_uploaded", "message": "no file uploaded"}, 400
|
||||
|
||||
file = request.files["file"]
|
||||
if not file.filename:
|
||||
return {"code": "filename_missing", "message": "filename is required"}, 400
|
||||
|
||||
try:
|
||||
result = SkillManagementService(session=session).upload_file(
|
||||
tenant_id=_workspace_id(request_context),
|
||||
user_id=request_context.account_id,
|
||||
filename=file.filename,
|
||||
content=file.stream.read(),
|
||||
mime_type=file.mimetype,
|
||||
)
|
||||
return dump_response(SkillFileUploadResponse, result), 201
|
||||
except SkillManagementServiceError as exc:
|
||||
return _error_response(exc)
|
||||
|
||||
|
||||
@console_ns.route("/workspaces/current/skills/tags")
|
||||
class WorkspaceSkillTagsApi(Resource):
|
||||
@console_ns.response(200, "Workspace Skill tags", console_ns.models[SkillTagListResponse.__name__])
|
||||
@console_account_admission()
|
||||
@with_session(write=False)
|
||||
def get(self, session: Session, request_context: RequestContext):
|
||||
result = SkillManagementService(session=session).list_tags(tenant_id=_workspace_id(request_context))
|
||||
return dump_response(SkillTagListResponse, result)
|
||||
|
||||
|
||||
@console_ns.route("/workspaces/current/skills/import")
|
||||
class WorkspaceSkillImportApi(Resource):
|
||||
@console_ns.doc(description="Import a Skill zip package from multipart form field `file`.")
|
||||
@console_ns.response(201, "Skill imported", console_ns.models[SkillDetailResponse.__name__])
|
||||
@console_account_admission(
|
||||
rbac_resource_scope=RBACResourceScope.WORKSPACE,
|
||||
rbac_permission=RBACPermission.SKILL_EDIT,
|
||||
rbac_resource_required=False,
|
||||
)
|
||||
@edit_permission_required
|
||||
@with_session
|
||||
def post(self, session: Session, request_context: RequestContext):
|
||||
upload = request.files.get("file")
|
||||
if upload is None:
|
||||
return {"code": "invalid_request", "message": "file is required"}, 400
|
||||
try:
|
||||
payload = SkillImportPayload(content=upload.read(), filename=upload.filename or "skill.zip")
|
||||
result = SkillManagementService(session=session).import_skill(
|
||||
tenant_id=_workspace_id(request_context),
|
||||
user_id=request_context.account_id,
|
||||
payload=payload,
|
||||
)
|
||||
return dump_response(SkillDetailResponse, result), 201
|
||||
except (ValidationError, ValueError) as exc:
|
||||
return {"code": "invalid_request", "message": str(exc)}, 400
|
||||
except SkillManagementServiceError as exc:
|
||||
return _error_response(exc)
|
||||
|
||||
|
||||
@console_ns.route("/workspaces/current/skills/<string:skill_id>")
|
||||
class WorkspaceSkillApi(Resource):
|
||||
@console_ns.response(200, "Skill detail", console_ns.models[SkillDetailResponse.__name__])
|
||||
@console_account_admission(
|
||||
rbac_resource_scope=RBACResourceScope.WORKSPACE,
|
||||
rbac_permission=RBACPermission.SKILL_VIEW,
|
||||
rbac_resource_required=False,
|
||||
)
|
||||
@with_session(write=False)
|
||||
def get(self, session: Session, request_context: RequestContext, skill_id: str):
|
||||
try:
|
||||
result = SkillManagementService(session=session).get_skill(
|
||||
tenant_id=_workspace_id(request_context), skill_id=skill_id
|
||||
)
|
||||
return dump_response(SkillDetailResponse, result)
|
||||
except SkillManagementServiceError as exc:
|
||||
return _error_response(exc)
|
||||
|
||||
@console_ns.expect(console_ns.models[SkillMetadataPayload.__name__])
|
||||
@console_ns.response(200, "Skill updated", console_ns.models[SkillResponse.__name__])
|
||||
@console_account_admission(
|
||||
rbac_resource_scope=RBACResourceScope.WORKSPACE,
|
||||
rbac_permission=RBACPermission.SKILL_EDIT,
|
||||
rbac_resource_required=False,
|
||||
)
|
||||
@edit_permission_required
|
||||
@with_session
|
||||
def patch(self, session: Session, request_context: RequestContext, skill_id: str):
|
||||
try:
|
||||
payload = SkillMetadataPayload.model_validate(console_ns.payload or {})
|
||||
result = SkillManagementService(session=session).update_metadata(
|
||||
tenant_id=_workspace_id(request_context),
|
||||
user_id=request_context.account_id,
|
||||
skill_id=skill_id,
|
||||
payload=payload,
|
||||
)
|
||||
return dump_response(SkillResponse, result)
|
||||
except ValidationError as exc:
|
||||
return {"code": "invalid_request", "message": str(exc)}, 400
|
||||
except ValueError as exc:
|
||||
return {"code": "invalid_request", "message": str(exc)}, 400
|
||||
except SkillManagementServiceError as exc:
|
||||
return _error_response(exc)
|
||||
|
||||
@console_ns.expect(console_ns.models[SkillDeletePayload.__name__])
|
||||
@console_ns.response(200, "Skill deleted", console_ns.models[SkillDeleteResponse.__name__])
|
||||
@console_account_admission(
|
||||
rbac_resource_scope=RBACResourceScope.WORKSPACE,
|
||||
rbac_permission=RBACPermission.SKILL_DELETE,
|
||||
rbac_resource_required=False,
|
||||
)
|
||||
@edit_permission_required
|
||||
@with_session
|
||||
def delete(self, session: Session, request_context: RequestContext, skill_id: str):
|
||||
try:
|
||||
payload = SkillDeletePayload.model_validate(console_ns.payload or {})
|
||||
result = SkillManagementService(session=session).delete_skill(
|
||||
tenant_id=_workspace_id(request_context),
|
||||
skill_id=skill_id,
|
||||
confirmation_name=payload.confirmation_name,
|
||||
)
|
||||
return dump_response(SkillDeleteResponse, result)
|
||||
except ValidationError as exc:
|
||||
return {"code": "invalid_request", "message": str(exc)}, 400
|
||||
except SkillManagementServiceError as exc:
|
||||
return _error_response(exc)
|
||||
|
||||
|
||||
@console_ns.route("/workspaces/current/skills/<string:skill_id>/duplicate")
|
||||
class WorkspaceSkillDuplicateApi(Resource):
|
||||
@console_ns.response(201, "Skill duplicated", console_ns.models[SkillDetailResponse.__name__])
|
||||
@console_account_admission(
|
||||
rbac_resource_scope=RBACResourceScope.WORKSPACE,
|
||||
rbac_permission=RBACPermission.SKILL_EDIT,
|
||||
rbac_resource_required=False,
|
||||
)
|
||||
@edit_permission_required
|
||||
@with_session
|
||||
def post(self, session: Session, request_context: RequestContext, skill_id: str):
|
||||
try:
|
||||
result = SkillManagementService(session=session).duplicate_skill(
|
||||
tenant_id=_workspace_id(request_context),
|
||||
user_id=request_context.account_id,
|
||||
skill_id=skill_id,
|
||||
)
|
||||
return dump_response(SkillDetailResponse, result), 201
|
||||
except SkillManagementServiceError as exc:
|
||||
return _error_response(exc)
|
||||
|
||||
|
||||
@console_ns.route("/workspaces/current/skills/<string:skill_id>/export")
|
||||
class WorkspaceSkillExportApi(Resource):
|
||||
@console_ns.response(200, "Published Skill zip archive")
|
||||
@console_account_admission(
|
||||
rbac_resource_scope=RBACResourceScope.WORKSPACE,
|
||||
rbac_permission=RBACPermission.SKILL_VIEW,
|
||||
rbac_resource_required=False,
|
||||
)
|
||||
@with_session(write=False)
|
||||
def get(self, session: Session, request_context: RequestContext, skill_id: str):
|
||||
try:
|
||||
result = SkillManagementService(session=session).pull_published_archive(
|
||||
tenant_id=_workspace_id(request_context), skill_id=skill_id
|
||||
)
|
||||
return send_file(
|
||||
io.BytesIO(result.payload),
|
||||
mimetype=result.mime_type,
|
||||
as_attachment=True,
|
||||
download_name=result.filename,
|
||||
)
|
||||
except SkillManagementServiceError as exc:
|
||||
return _error_response(exc)
|
||||
|
||||
|
||||
@console_ns.route("/workspaces/current/skills/<string:skill_id>/assist/messages")
|
||||
class WorkspaceSkillAssistMessageApi(Resource):
|
||||
"""Stream read-only Skill Authoring suggestions from the default workspace model."""
|
||||
|
||||
@console_ns.expect(console_ns.models[SkillAssistMessagePayload.__name__])
|
||||
@console_ns.response(200, "Skill Authoring assistant event stream")
|
||||
@console_account_admission()
|
||||
@with_session
|
||||
def post(self, session: Session, request_context: RequestContext, skill_id: str):
|
||||
try:
|
||||
payload = SkillAssistMessagePayload.model_validate(console_ns.payload or {})
|
||||
response = SkillManagementService(session=session).create_assistant_action_stream(
|
||||
tenant_id=_workspace_id(request_context),
|
||||
skill_id=skill_id,
|
||||
user_id=request_context.account_id,
|
||||
message=payload.message,
|
||||
attachments=payload.attachments,
|
||||
history=payload.history,
|
||||
model_payload=payload.model,
|
||||
target_path=payload.target_path,
|
||||
)
|
||||
except ValidationError as exc:
|
||||
return {"code": "invalid_request", "message": str(exc)}, 400
|
||||
except SkillManagementServiceError as exc:
|
||||
return _error_response(exc)
|
||||
return helper.compact_generate_response(response)
|
||||
|
||||
|
||||
@console_ns.route("/workspaces/current/skills/<string:skill_id>/files/check")
|
||||
class WorkspaceSkillFilesCheckApi(Resource):
|
||||
@console_ns.expect(console_ns.models[SkillDraftFileCheckPayload.__name__])
|
||||
@console_ns.response(200, "Draft files checked", console_ns.models[SkillFileCheckResponse.__name__])
|
||||
@console_account_admission()
|
||||
@edit_permission_required
|
||||
@with_session
|
||||
def post(self, session: Session, request_context: RequestContext, skill_id: str):
|
||||
try:
|
||||
payload = SkillDraftFileCheckPayload.model_validate(console_ns.payload or {})
|
||||
result = SkillManagementService(session=session).check_draft_files(
|
||||
tenant_id=_workspace_id(request_context),
|
||||
skill_id=skill_id,
|
||||
payload=payload,
|
||||
)
|
||||
return dump_response(SkillFileCheckResponse, result)
|
||||
except ValidationError as exc:
|
||||
return {"code": "invalid_request", "message": str(exc)}, 400
|
||||
except ValueError as exc:
|
||||
return {"code": "invalid_request", "message": str(exc)}, 400
|
||||
except SkillManagementServiceError as exc:
|
||||
return _error_response(exc)
|
||||
|
||||
|
||||
@console_ns.route("/workspaces/current/skills/<string:skill_id>/files")
|
||||
class WorkspaceSkillFilesApi(Resource):
|
||||
@console_ns.expect(console_ns.models[SkillDraftFileOperationPayload.__name__])
|
||||
@console_ns.response(200, "Draft file operation applied", console_ns.models[SkillDetailResponse.__name__])
|
||||
@console_account_admission()
|
||||
@edit_permission_required
|
||||
@with_session
|
||||
def patch(self, session: Session, request_context: RequestContext, skill_id: str):
|
||||
try:
|
||||
payload = SkillDraftFileOperationPayload.model_validate(console_ns.payload or {})
|
||||
result = SkillManagementService(session=session).apply_draft_file_operation(
|
||||
tenant_id=_workspace_id(request_context),
|
||||
user_id=request_context.account_id,
|
||||
skill_id=skill_id,
|
||||
payload=payload,
|
||||
)
|
||||
return dump_response(SkillDetailResponse, result)
|
||||
except ValidationError as exc:
|
||||
return {"code": "invalid_request", "message": str(exc)}, 400
|
||||
except ValueError as exc:
|
||||
return {"code": "invalid_request", "message": str(exc)}, 400
|
||||
except SkillManagementServiceError as exc:
|
||||
return _error_response(exc)
|
||||
|
||||
@console_ns.expect(console_ns.models[SkillDraftTreePayload.__name__])
|
||||
@console_ns.response(200, "Draft files replaced", console_ns.models[SkillDetailResponse.__name__])
|
||||
@console_account_admission()
|
||||
@edit_permission_required
|
||||
@with_session
|
||||
def put(self, session: Session, request_context: RequestContext, skill_id: str):
|
||||
try:
|
||||
payload = SkillDraftTreePayload.model_validate(console_ns.payload or {})
|
||||
result = SkillManagementService(session=session).replace_draft_tree(
|
||||
tenant_id=_workspace_id(request_context),
|
||||
user_id=request_context.account_id,
|
||||
skill_id=skill_id,
|
||||
payload=payload,
|
||||
)
|
||||
return dump_response(SkillDetailResponse, result)
|
||||
except ValidationError as exc:
|
||||
return {"code": "invalid_request", "message": str(exc)}, 400
|
||||
except ValueError as exc:
|
||||
return {"code": "invalid_request", "message": str(exc)}, 400
|
||||
except SkillManagementServiceError as exc:
|
||||
return _error_response(exc)
|
||||
|
||||
|
||||
@console_ns.route("/workspaces/current/skills/<string:skill_id>/files/preview")
|
||||
class WorkspaceSkillFilePreviewApi(Resource):
|
||||
@console_ns.doc(params=query_params_from_model(SkillFileQuery))
|
||||
@console_ns.response(200, "Skill file text preview", console_ns.models[SkillFilePreviewResponse.__name__])
|
||||
@console_account_admission()
|
||||
@with_session
|
||||
def get(self, session: Session, request_context: RequestContext, skill_id: str):
|
||||
try:
|
||||
query = SkillFileQuery.model_validate(
|
||||
{
|
||||
"path": request.args.get("path"),
|
||||
"version_id": request.args.get("version_id"),
|
||||
}
|
||||
)
|
||||
result = SkillManagementService(session=session).preview_file(
|
||||
tenant_id=_workspace_id(request_context),
|
||||
skill_id=skill_id,
|
||||
path=query.path,
|
||||
version_id=query.version_id,
|
||||
)
|
||||
return dump_response(SkillFilePreviewResponse, result)
|
||||
except ValidationError as exc:
|
||||
return {"code": "invalid_request", "message": str(exc)}, 400
|
||||
except ValueError as exc:
|
||||
return {"code": "invalid_request", "message": str(exc)}, 400
|
||||
except SkillManagementServiceError as exc:
|
||||
return _error_response(exc)
|
||||
|
||||
|
||||
@console_ns.route("/workspaces/current/skills/<string:skill_id>/files/content")
|
||||
class WorkspaceSkillFileContentApi(Resource):
|
||||
@console_ns.doc(params={**query_params_from_model(SkillFileQuery), "download": "Return as an attachment when 1."})
|
||||
@console_ns.response(200, "Skill file content", console_ns.models[BinaryFileResponse.__name__])
|
||||
@console_account_admission()
|
||||
@with_session
|
||||
def get(self, session: Session, request_context: RequestContext, skill_id: str):
|
||||
try:
|
||||
query = SkillFileQuery.model_validate(
|
||||
{
|
||||
"path": request.args.get("path"),
|
||||
"version_id": request.args.get("version_id"),
|
||||
}
|
||||
)
|
||||
result = SkillManagementService(session=session).pull_file(
|
||||
tenant_id=_workspace_id(request_context),
|
||||
skill_id=skill_id,
|
||||
path=query.path,
|
||||
version_id=query.version_id,
|
||||
)
|
||||
return send_file(
|
||||
io.BytesIO(result.payload),
|
||||
mimetype=result.mime_type,
|
||||
as_attachment=request.args.get("download") == "1",
|
||||
download_name=result.filename,
|
||||
)
|
||||
except ValidationError as exc:
|
||||
return {"code": "invalid_request", "message": str(exc)}, 400
|
||||
except ValueError as exc:
|
||||
return {"code": "invalid_request", "message": str(exc)}, 400
|
||||
except SkillManagementServiceError as exc:
|
||||
return _error_response(exc)
|
||||
|
||||
|
||||
@console_ns.route("/workspaces/current/skills/<string:skill_id>/publish")
|
||||
class WorkspaceSkillPublishApi(Resource):
|
||||
@console_ns.expect(console_ns.models[SkillPublishPayload.__name__])
|
||||
@console_ns.response(200, "Skill published", console_ns.models[SkillVersionResponse.__name__])
|
||||
@console_account_admission(
|
||||
rbac_resource_scope=RBACResourceScope.WORKSPACE,
|
||||
rbac_permission=RBACPermission.SKILL_PUBLISH,
|
||||
rbac_resource_required=False,
|
||||
)
|
||||
@edit_permission_required
|
||||
@with_session
|
||||
def post(self, session: Session, request_context: RequestContext, skill_id: str):
|
||||
try:
|
||||
payload = SkillPublishPayload.model_validate(console_ns.payload or {})
|
||||
result = SkillManagementService(session=session).publish_skill(
|
||||
tenant_id=_workspace_id(request_context),
|
||||
user_id=request_context.account_id,
|
||||
skill_id=skill_id,
|
||||
payload=payload,
|
||||
)
|
||||
return dump_response(SkillVersionResponse, result)
|
||||
except ValidationError as exc:
|
||||
return {"code": "invalid_request", "message": str(exc)}, 400
|
||||
except SkillManagementServiceError as exc:
|
||||
return _error_response(exc)
|
||||
|
||||
|
||||
@console_ns.route("/workspaces/current/skills/<string:skill_id>/restore")
|
||||
class WorkspaceSkillRestoreApi(Resource):
|
||||
@console_ns.expect(console_ns.models[SkillRestorePayload.__name__])
|
||||
@console_ns.response(200, "Skill version restored to draft", console_ns.models[SkillDetailResponse.__name__])
|
||||
@console_account_admission(
|
||||
rbac_resource_scope=RBACResourceScope.WORKSPACE,
|
||||
rbac_permission=RBACPermission.SKILL_PUBLISH,
|
||||
rbac_resource_required=False,
|
||||
)
|
||||
@edit_permission_required
|
||||
@with_session
|
||||
def post(self, session: Session, request_context: RequestContext, skill_id: str):
|
||||
try:
|
||||
payload = SkillRestorePayload.model_validate(console_ns.payload or {})
|
||||
result = SkillManagementService(session=session).restore_version(
|
||||
tenant_id=_workspace_id(request_context),
|
||||
user_id=request_context.account_id,
|
||||
skill_id=skill_id,
|
||||
payload=payload,
|
||||
)
|
||||
return dump_response(SkillDetailResponse, result)
|
||||
except ValidationError as exc:
|
||||
return {"code": "invalid_request", "message": str(exc)}, 400
|
||||
except SkillManagementServiceError as exc:
|
||||
return _error_response(exc)
|
||||
|
||||
|
||||
@console_ns.route("/workspaces/current/skills/<string:skill_id>/references")
|
||||
class WorkspaceSkillReferencesApi(Resource):
|
||||
@console_ns.response(200, "Skill references", console_ns.models[SkillReferenceListResponse.__name__])
|
||||
@console_account_admission()
|
||||
@with_session
|
||||
def get(self, session: Session, request_context: RequestContext, skill_id: str):
|
||||
try:
|
||||
result = SkillManagementService(session=session).list_skill_references(
|
||||
tenant_id=_workspace_id(request_context), skill_id=skill_id
|
||||
)
|
||||
return dump_response(SkillReferenceListResponse, result)
|
||||
except SkillManagementServiceError as exc:
|
||||
return _error_response(exc)
|
||||
|
||||
|
||||
@console_ns.route("/workspaces/current/skills/<string:skill_id>/versions")
|
||||
class WorkspaceSkillVersionsApi(Resource):
|
||||
@console_ns.response(200, "Skill versions", console_ns.models[SkillVersionListResponse.__name__])
|
||||
@console_account_admission()
|
||||
@with_session
|
||||
def get(self, session: Session, request_context: RequestContext, skill_id: str):
|
||||
try:
|
||||
result = SkillManagementService(session=session).list_versions(
|
||||
tenant_id=_workspace_id(request_context), skill_id=skill_id
|
||||
)
|
||||
return dump_response(SkillVersionListResponse, result)
|
||||
except SkillManagementServiceError as exc:
|
||||
return _error_response(exc)
|
||||
|
||||
|
||||
@console_ns.route("/workspaces/current/skills/<string:skill_id>/versions/<string:version_id>")
|
||||
class WorkspaceSkillVersionApi(Resource):
|
||||
@console_ns.response(200, "Skill version detail", console_ns.models[SkillVersionDetailResponse.__name__])
|
||||
@console_account_admission()
|
||||
@with_session
|
||||
def get(self, session: Session, request_context: RequestContext, skill_id: str, version_id: str):
|
||||
try:
|
||||
result = SkillManagementService(session=session).get_version(
|
||||
tenant_id=_workspace_id(request_context),
|
||||
skill_id=skill_id,
|
||||
version_id=version_id,
|
||||
)
|
||||
return dump_response(SkillVersionDetailResponse, result)
|
||||
except SkillManagementServiceError as exc:
|
||||
return _error_response(exc)
|
||||
|
||||
@console_ns.expect(console_ns.models[SkillVersionUpdatePayload.__name__])
|
||||
@console_ns.response(200, "Skill version updated", console_ns.models[SkillVersionResponse.__name__])
|
||||
@console_account_admission()
|
||||
@edit_permission_required
|
||||
@with_session
|
||||
def patch(self, session: Session, request_context: RequestContext, skill_id: str, version_id: str):
|
||||
try:
|
||||
payload = SkillVersionUpdatePayload.model_validate(console_ns.payload or {})
|
||||
result = SkillManagementService(session=session).update_version(
|
||||
tenant_id=_workspace_id(request_context),
|
||||
skill_id=skill_id,
|
||||
version_id=version_id,
|
||||
payload=payload,
|
||||
)
|
||||
return dump_response(SkillVersionResponse, result)
|
||||
except ValidationError as exc:
|
||||
return {"code": "invalid_request", "message": str(exc)}, 400
|
||||
except SkillManagementServiceError as exc:
|
||||
return _error_response(exc)
|
||||
|
||||
@console_ns.response(200, "Skill version deleted", console_ns.models[SkillVersionDeleteResponse.__name__])
|
||||
@console_account_admission()
|
||||
@edit_permission_required
|
||||
@with_session
|
||||
def delete(self, session: Session, request_context: RequestContext, skill_id: str, version_id: str):
|
||||
try:
|
||||
result = SkillManagementService(session=session).delete_version(
|
||||
tenant_id=_workspace_id(request_context),
|
||||
user_id=request_context.account_id,
|
||||
skill_id=skill_id,
|
||||
version_id=version_id,
|
||||
)
|
||||
return dump_response(SkillVersionDeleteResponse, result)
|
||||
except SkillManagementServiceError as exc:
|
||||
return _error_response(exc)
|
||||
|
||||
|
||||
@console_ns.route("/workspaces/current/agents/<string:agent_id>/skills")
|
||||
class WorkspaceAgentSkillBindingsApi(Resource):
|
||||
@console_ns.response(200, "Agent Skill bindings", console_ns.models[AgentSkillBindingsResponse.__name__])
|
||||
@console_account_admission()
|
||||
@with_session
|
||||
def get(self, session: Session, request_context: RequestContext, agent_id: str):
|
||||
result = SkillManagementService(session=session).list_agent_bindings(
|
||||
tenant_id=_workspace_id(request_context), agent_id=agent_id
|
||||
)
|
||||
return dump_response(AgentSkillBindingsResponse, result)
|
||||
|
||||
@console_ns.expect(console_ns.models[AgentSkillBindingsPayload.__name__])
|
||||
@console_ns.response(200, "Agent Skill bindings replaced", console_ns.models[AgentSkillBindingsResponse.__name__])
|
||||
@console_account_admission()
|
||||
@edit_permission_required
|
||||
@with_session
|
||||
def put(self, session: Session, request_context: RequestContext, agent_id: str):
|
||||
try:
|
||||
payload = AgentSkillBindingsPayload.model_validate(console_ns.payload or {})
|
||||
result = SkillManagementService(session=session).replace_agent_bindings(
|
||||
tenant_id=_workspace_id(request_context),
|
||||
user_id=request_context.account_id,
|
||||
agent_id=agent_id,
|
||||
skill_ids=payload.skill_ids,
|
||||
)
|
||||
return dump_response(AgentSkillBindingsResponse, result)
|
||||
except ValidationError as exc:
|
||||
return {"code": "invalid_request", "message": str(exc)}, 400
|
||||
except SkillManagementServiceError as exc:
|
||||
return _error_response(exc)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"WorkspaceAgentSkillBindingsApi",
|
||||
"WorkspaceSkillApi",
|
||||
"WorkspaceSkillDuplicateApi",
|
||||
"WorkspaceSkillExportApi",
|
||||
"WorkspaceSkillFilesApi",
|
||||
"WorkspaceSkillImportApi",
|
||||
"WorkspaceSkillPublishApi",
|
||||
"WorkspaceSkillReferencesApi",
|
||||
"WorkspaceSkillRestoreApi",
|
||||
"WorkspaceSkillTagsApi",
|
||||
"WorkspaceSkillVersionApi",
|
||||
"WorkspaceSkillVersionsApi",
|
||||
"WorkspaceSkillsApi",
|
||||
]
|
||||
@@ -17,7 +17,7 @@ from controllers.console import console_ns
|
||||
from controllers.console.app.wraps import with_session
|
||||
from controllers.console.snippets.payloads import (
|
||||
CreateSnippetPayload,
|
||||
IncludeSecretQuery,
|
||||
SnippetExportQuery,
|
||||
SnippetImportPayload,
|
||||
SnippetListQuery,
|
||||
UpdateSnippetPayload,
|
||||
@@ -89,7 +89,7 @@ register_schema_models(
|
||||
CreateSnippetPayload,
|
||||
UpdateSnippetPayload,
|
||||
SnippetImportPayload,
|
||||
IncludeSecretQuery,
|
||||
SnippetExportQuery,
|
||||
)
|
||||
register_response_schema_models(
|
||||
console_ns,
|
||||
@@ -289,7 +289,7 @@ class CustomizedSnippetExportApi(Resource):
|
||||
@console_ns.doc("export_customized_snippet")
|
||||
@console_ns.doc(description="Export snippet configuration as DSL")
|
||||
@console_ns.doc(params={"snippet_id": "Snippet ID to export"})
|
||||
@console_ns.doc(params=query_params_from_model(IncludeSecretQuery))
|
||||
@console_ns.doc(params=query_params_from_model(SnippetExportQuery))
|
||||
@console_ns.response(200, "Snippet exported successfully", console_ns.models[TextFileResponse.__name__])
|
||||
@console_ns.response(404, "Snippet not found")
|
||||
@setup_required
|
||||
@@ -312,11 +312,18 @@ class CustomizedSnippetExportApi(Resource):
|
||||
raise NotFound("Snippet not found")
|
||||
|
||||
# Get include_secret parameter
|
||||
query = IncludeSecretQuery.model_validate(request.args.to_dict())
|
||||
query = SnippetExportQuery.model_validate(request.args.to_dict())
|
||||
|
||||
with Session(db.engine) as session:
|
||||
export_service = SnippetDslService(session)
|
||||
result = export_service.export_snippet_dsl(snippet=snippet, include_secret=query.include_secret == "true")
|
||||
try:
|
||||
result = export_service.export_snippet_dsl(
|
||||
snippet=snippet,
|
||||
include_secret=query.include_secret == "true",
|
||||
workflow_id=query.workflow_id,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise NotFound(str(exc)) from exc
|
||||
|
||||
# Set filename with .snippet extension
|
||||
filename = f"{snippet.name}.snippet"
|
||||
|
||||
@@ -1296,6 +1296,8 @@ class ToolOAuthCustomClient(Resource):
|
||||
)
|
||||
@setup_required
|
||||
@login_required
|
||||
@is_admin_or_owner_required
|
||||
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.CREDENTIAL_MANAGE, resource_required=False)
|
||||
@account_initialization_required
|
||||
@with_current_tenant_id
|
||||
def get(self, current_tenant_id: str, provider: str):
|
||||
|
||||
@@ -54,6 +54,10 @@ class TriggerSubscriptionBuilderCreatePayload(BaseModel):
|
||||
|
||||
|
||||
class TriggerSubscriptionBuilderVerifyPayload(BaseModel):
|
||||
credentials: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class TriggerSubscriptionVerifyPayload(BaseModel):
|
||||
credentials: dict[str, Any]
|
||||
|
||||
|
||||
@@ -120,6 +124,7 @@ register_schema_models(
|
||||
TriggerSubscriptionBuilderCreatePayload,
|
||||
TriggerSubscriptionBuilderVerifyPayload,
|
||||
TriggerSubscriptionBuilderUpdatePayload,
|
||||
TriggerSubscriptionVerifyPayload,
|
||||
TriggerOAuthClientPayload,
|
||||
)
|
||||
register_response_schema_models(
|
||||
@@ -812,7 +817,7 @@ class TriggerOAuthClientManageApi(Resource):
|
||||
"/workspaces/current/trigger-provider/<path:provider>/subscriptions/verify/<path:subscription_id>",
|
||||
)
|
||||
class TriggerSubscriptionVerifyApi(Resource):
|
||||
@console_ns.expect(console_ns.models[TriggerSubscriptionBuilderVerifyPayload.__name__])
|
||||
@console_ns.expect(console_ns.models[TriggerSubscriptionVerifyPayload.__name__])
|
||||
@console_ns.response(
|
||||
200,
|
||||
"Trigger subscription verified successfully",
|
||||
@@ -825,10 +830,10 @@ class TriggerSubscriptionVerifyApi(Resource):
|
||||
@account_initialization_required
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
@model_validate(TriggerSubscriptionBuilderVerifyPayload)
|
||||
@model_validate(TriggerSubscriptionVerifyPayload)
|
||||
def post(
|
||||
self,
|
||||
req_data: TriggerSubscriptionBuilderVerifyPayload,
|
||||
req_data: TriggerSubscriptionVerifyPayload,
|
||||
tenant_id: str,
|
||||
user: Account,
|
||||
provider: str,
|
||||
|
||||
@@ -14,12 +14,11 @@ api = ExternalApi(
|
||||
|
||||
files_ns = Namespace("files", description="File operations", path="/")
|
||||
|
||||
from . import agent_drive_archive, image_preview, tool_files, upload
|
||||
from . import image_preview, tool_files, upload
|
||||
|
||||
api.add_namespace(files_ns)
|
||||
|
||||
__all__ = [
|
||||
"agent_drive_archive",
|
||||
"api",
|
||||
"bp",
|
||||
"files_ns",
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
from urllib.parse import quote
|
||||
|
||||
from flask import Response, request
|
||||
from flask_restx import Resource
|
||||
from pydantic import BaseModel, Field
|
||||
from werkzeug.exceptions import Forbidden, NotFound
|
||||
|
||||
from controllers.common.file_response import enforce_download_for_html
|
||||
from controllers.common.schema import register_schema_models
|
||||
from controllers.files import files_ns
|
||||
from extensions.ext_database import db
|
||||
from models.agent import AgentDriveFileKind
|
||||
from services.agent_drive_service import AgentDriveError, AgentDriveService
|
||||
|
||||
|
||||
class AgentDriveArchiveMemberQuery(BaseModel):
|
||||
tenant_id: str = Field(..., description="Tenant ID")
|
||||
agent_id: str = Field(..., description="Agent ID")
|
||||
key: str = Field(..., description="Virtual drive key")
|
||||
archive_file_kind: AgentDriveFileKind = Field(..., description="Archive file kind")
|
||||
archive_file_id: str = Field(..., description="Archive file id")
|
||||
member_path: str = Field(..., description="Zip member path")
|
||||
timestamp: str = Field(..., description="Unix timestamp")
|
||||
nonce: str = Field(..., description="Random nonce")
|
||||
sign: str = Field(..., description="HMAC signature")
|
||||
as_attachment: bool = Field(default=False, description="Download as attachment")
|
||||
|
||||
|
||||
register_schema_models(files_ns, AgentDriveArchiveMemberQuery)
|
||||
|
||||
|
||||
@files_ns.route("/agent-drive/archive-member")
|
||||
class AgentDriveArchiveMemberApi(Resource):
|
||||
@files_ns.doc("get_agent_drive_archive_member")
|
||||
@files_ns.doc(description="Download a lazily resolved Agent Skill archive member by signed parameters")
|
||||
def get(self):
|
||||
args = AgentDriveArchiveMemberQuery.model_validate(request.args.to_dict(flat=True))
|
||||
if not AgentDriveService.verify_archive_member_signature(
|
||||
tenant_id=args.tenant_id,
|
||||
agent_id=args.agent_id,
|
||||
key=args.key,
|
||||
archive_file_kind=args.archive_file_kind,
|
||||
archive_file_id=args.archive_file_id,
|
||||
member_path=args.member_path,
|
||||
timestamp=args.timestamp,
|
||||
nonce=args.nonce,
|
||||
sign=args.sign,
|
||||
):
|
||||
raise Forbidden("Invalid request.")
|
||||
try:
|
||||
payload, mime_type, filename = AgentDriveService().load_archive_member_for_signed_request(
|
||||
tenant_id=args.tenant_id,
|
||||
agent_id=args.agent_id,
|
||||
key=args.key,
|
||||
archive_file_kind=args.archive_file_kind,
|
||||
archive_file_id=args.archive_file_id,
|
||||
member_path=args.member_path,
|
||||
session=db.session(),
|
||||
)
|
||||
except AgentDriveError as exc:
|
||||
raise NotFound(exc.message) from exc
|
||||
|
||||
response = Response(payload, mimetype=mime_type, direct_passthrough=True, headers={})
|
||||
response.headers["Content-Length"] = str(len(payload))
|
||||
if args.as_attachment and filename:
|
||||
encoded_filename = quote(filename)
|
||||
response.headers["Content-Disposition"] = f"attachment; filename*=UTF-8''{encoded_filename}"
|
||||
enforce_download_for_html(response, mime_type=mime_type, filename=filename, extension="")
|
||||
return response
|
||||
@@ -8,7 +8,7 @@ from werkzeug.exceptions import Forbidden
|
||||
|
||||
import services
|
||||
from core.db.session_factory import session_factory
|
||||
from core.tools.signature import verify_plugin_file_signature
|
||||
from core.tools.signature import sign_tool_file, verify_plugin_file_signature
|
||||
from core.tools.tool_file_manager import ToolFileManager, resolve_extension
|
||||
from core.workflow.file_reference import build_file_reference
|
||||
from fields.file_fields import FileResponse
|
||||
@@ -136,7 +136,7 @@ class PluginUploadFileApi(Resource):
|
||||
)
|
||||
|
||||
extension = resolve_extension(filename=tool_file.name, mimetype=tool_file.mimetype)
|
||||
preview_url = ToolFileManager.sign_file(tool_file_id=tool_file.id, extension=extension)
|
||||
preview_url = sign_tool_file(tool_file_id=tool_file.id, extension=extension, for_external=True)
|
||||
|
||||
# Create a dictionary with all the necessary attributes
|
||||
result = FileResponse(
|
||||
|
||||
@@ -23,15 +23,14 @@ from .agent import tools as _agent_tools
|
||||
from .app import dsl as _app_dsl
|
||||
from .knowledge import retrieval as _knowledge_retrieval
|
||||
from .plugin import agent_config as _agent_config
|
||||
from .plugin import agent_drive as _agent_drive
|
||||
from .plugin import plugin as _plugin
|
||||
from .plugin import skills as _skills
|
||||
from .workspace import workspace as _workspace
|
||||
|
||||
api.add_namespace(inner_api_ns)
|
||||
|
||||
__all__ = [
|
||||
"_agent_config",
|
||||
"_agent_drive",
|
||||
"_agent_files",
|
||||
"_agent_llm",
|
||||
"_agent_tools",
|
||||
@@ -40,6 +39,7 @@ __all__ = [
|
||||
"_mail",
|
||||
"_plugin",
|
||||
"_runtime_credentials",
|
||||
"_skills",
|
||||
"_workspace",
|
||||
"api",
|
||||
"bp",
|
||||
|
||||
@@ -1,105 +0,0 @@
|
||||
"""Inner API for the agent drive (agent 网盘) control plane.
|
||||
|
||||
These endpoints are called by the dify-agent server (not the sandbox) with the
|
||||
inner API key. The drive ref is the URL segment ``agent-<agent_id>``; the
|
||||
path-like file key travels in the query/body, never as a URL path segment (so
|
||||
its ``/`` characters do not collide with routing). Drive-owned semantics:
|
||||
tenant scoped, no user-level FileAccessScope. Commit still canonicalizes the
|
||||
trusted execution-context user through the same EndUser lookup as plugin file
|
||||
upload before validating ToolFile ownership.
|
||||
"""
|
||||
|
||||
from flask import request
|
||||
from flask_restx import Resource
|
||||
from pydantic import BaseModel, ValidationError
|
||||
|
||||
from controllers.console.wraps import setup_required
|
||||
from controllers.inner_api import inner_api_ns
|
||||
from controllers.inner_api.plugin.wraps import get_user
|
||||
from controllers.inner_api.wraps import plugin_inner_api_only
|
||||
from extensions.ext_database import db
|
||||
from services.agent_drive_service import (
|
||||
AgentDriveError,
|
||||
AgentDriveService,
|
||||
DriveCommitItem,
|
||||
parse_agent_drive_ref,
|
||||
)
|
||||
|
||||
|
||||
class _CommitRequest(BaseModel):
|
||||
tenant_id: str
|
||||
user_id: str
|
||||
items: list[DriveCommitItem]
|
||||
|
||||
|
||||
def _error_response(exc: AgentDriveError) -> tuple[dict[str, str], int]:
|
||||
return {"code": exc.code, "message": exc.message}, exc.status_code
|
||||
|
||||
|
||||
@inner_api_ns.route("/drive/<string:drive_ref>/manifest")
|
||||
class AgentDriveManifestApi(Resource):
|
||||
@setup_required
|
||||
@plugin_inner_api_only
|
||||
@inner_api_ns.doc("agent_drive_manifest")
|
||||
@inner_api_ns.doc(description="List an agent drive (optionally with download URLs)")
|
||||
def get(self, drive_ref: str):
|
||||
try:
|
||||
agent_id = parse_agent_drive_ref(drive_ref)
|
||||
tenant_id = (request.args.get("tenant_id") or "").strip()
|
||||
if not tenant_id:
|
||||
raise AgentDriveError("missing_tenant_id", "tenant_id is required", status_code=400)
|
||||
include_download_url = (request.args.get("include_download_url") or "").lower() in ("1", "true", "yes")
|
||||
items = AgentDriveService().manifest(
|
||||
tenant_id=tenant_id,
|
||||
agent_id=agent_id,
|
||||
prefix=request.args.get("prefix", ""),
|
||||
include_download_url=include_download_url,
|
||||
session=db.session(),
|
||||
)
|
||||
except AgentDriveError as exc:
|
||||
return _error_response(exc)
|
||||
return {"items": items}
|
||||
|
||||
|
||||
@inner_api_ns.route("/drive/<string:drive_ref>/skills")
|
||||
class AgentDriveSkillsApi(Resource):
|
||||
@setup_required
|
||||
@plugin_inner_api_only
|
||||
@inner_api_ns.doc("agent_drive_skills")
|
||||
@inner_api_ns.doc(description="List the skill catalog of an agent drive")
|
||||
def get(self, drive_ref: str):
|
||||
try:
|
||||
agent_id = parse_agent_drive_ref(drive_ref)
|
||||
tenant_id = (request.args.get("tenant_id") or "").strip()
|
||||
if not tenant_id:
|
||||
raise AgentDriveError("missing_tenant_id", "tenant_id is required", status_code=400)
|
||||
items = AgentDriveService().list_skills(tenant_id=tenant_id, agent_id=agent_id, session=db.session())
|
||||
except AgentDriveError as exc:
|
||||
return _error_response(exc)
|
||||
return {"items": items}
|
||||
|
||||
|
||||
@inner_api_ns.route("/drive/<string:drive_ref>/commit")
|
||||
class AgentDriveCommitApi(Resource):
|
||||
@setup_required
|
||||
@plugin_inner_api_only
|
||||
@inner_api_ns.doc("agent_drive_commit")
|
||||
@inner_api_ns.doc(description="Commit a batch of file refs into an agent drive")
|
||||
def post(self, drive_ref: str):
|
||||
try:
|
||||
agent_id = parse_agent_drive_ref(drive_ref)
|
||||
try:
|
||||
body = _CommitRequest.model_validate(request.get_json(silent=True) or {})
|
||||
except ValidationError as exc:
|
||||
raise AgentDriveError("invalid_request", str(exc), status_code=400) from exc
|
||||
user = get_user(body.tenant_id, body.user_id)
|
||||
items = AgentDriveService().commit(
|
||||
tenant_id=body.tenant_id,
|
||||
user_id=user.id,
|
||||
agent_id=agent_id,
|
||||
items=body.items,
|
||||
session=db.session(),
|
||||
)
|
||||
except AgentDriveError as exc:
|
||||
return _error_response(exc)
|
||||
return {"items": items}
|
||||
59
api/controllers/inner_api/plugin/skills.py
Normal file
59
api/controllers/inner_api/plugin/skills.py
Normal file
@@ -0,0 +1,59 @@
|
||||
"""Inner API for published workspace Skills.
|
||||
|
||||
These endpoints are called by trusted runtime services. They expose only
|
||||
published Skill artifacts, never draft files or editable metadata.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
|
||||
from flask import request, send_file
|
||||
from flask_restx import Resource
|
||||
from pydantic import BaseModel, ValidationError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from controllers.common.session import with_session
|
||||
from controllers.console.wraps import setup_required
|
||||
from controllers.inner_api import inner_api_ns
|
||||
from controllers.inner_api.wraps import plugin_inner_api_only
|
||||
from services.skill_management_service import SkillManagementService, SkillManagementServiceError
|
||||
|
||||
|
||||
class _SkillTargetQuery(BaseModel):
|
||||
tenant_id: str
|
||||
|
||||
|
||||
def _target_query_from_request() -> _SkillTargetQuery:
|
||||
return _SkillTargetQuery.model_validate({"tenant_id": request.args.get("tenant_id")})
|
||||
|
||||
|
||||
def _error_response(exc: SkillManagementServiceError) -> tuple[dict[str, str], int]:
|
||||
return {"code": exc.code, "message": exc.message}, exc.status_code
|
||||
|
||||
|
||||
@inner_api_ns.route("/skills/<string:skill_id>/pull")
|
||||
class PublishedSkillPullApi(Resource):
|
||||
@setup_required
|
||||
@plugin_inner_api_only
|
||||
@inner_api_ns.doc("published_skill_pull")
|
||||
@with_session(write=False)
|
||||
def get(self, session: Session, skill_id: str):
|
||||
try:
|
||||
query = _target_query_from_request()
|
||||
result = SkillManagementService(session=session).pull_published_archive(
|
||||
tenant_id=query.tenant_id, skill_id=skill_id
|
||||
)
|
||||
return send_file(
|
||||
io.BytesIO(result.payload),
|
||||
mimetype=result.mime_type,
|
||||
as_attachment=True,
|
||||
download_name=result.filename,
|
||||
)
|
||||
except ValidationError as exc:
|
||||
return {"code": "invalid_request", "message": str(exc)}, 400
|
||||
except SkillManagementServiceError as exc:
|
||||
return _error_response(exc)
|
||||
|
||||
|
||||
__all__ = ["PublishedSkillPullApi"]
|
||||
@@ -4,6 +4,7 @@ from typing import cast
|
||||
|
||||
from flask_restx import Resource
|
||||
from sqlalchemy.orm import Session
|
||||
from werkzeug.exceptions import Forbidden
|
||||
|
||||
from controllers.common.wraps import RBACPermission, RBACResourceScope
|
||||
from controllers.openapi import openapi_ns
|
||||
@@ -17,6 +18,7 @@ from models import Account, App
|
||||
from models.account import TenantAccountRole
|
||||
from services.app_dsl_service import AppDslService, Import
|
||||
from services.entities.dsl_entities import CheckDependenciesResult, ImportStatus
|
||||
from services.errors.account import NoPermissionError
|
||||
from services.errors.app import WorkflowNotFoundError
|
||||
|
||||
|
||||
@@ -53,18 +55,21 @@ class AppDslImportApi(Resource):
|
||||
|
||||
with Session(db.engine, expire_on_commit=False) as session:
|
||||
service = AppDslService(session)
|
||||
result = service.import_app(
|
||||
account=account,
|
||||
import_mode=body.mode,
|
||||
yaml_content=body.yaml_content,
|
||||
yaml_url=body.yaml_url,
|
||||
name=body.name,
|
||||
description=body.description,
|
||||
icon_type=body.icon_type,
|
||||
icon=body.icon,
|
||||
icon_background=body.icon_background,
|
||||
app_id=body.app_id,
|
||||
)
|
||||
try:
|
||||
result = service.import_app(
|
||||
account=account,
|
||||
import_mode=body.mode,
|
||||
yaml_content=body.yaml_content,
|
||||
yaml_url=body.yaml_url,
|
||||
name=body.name,
|
||||
description=body.description,
|
||||
icon_type=body.icon_type,
|
||||
icon=body.icon,
|
||||
icon_background=body.icon_background,
|
||||
app_id=body.app_id,
|
||||
)
|
||||
except NoPermissionError as exc:
|
||||
raise Forbidden(str(exc)) from exc
|
||||
if result.status == ImportStatus.FAILED:
|
||||
session.rollback()
|
||||
else:
|
||||
@@ -108,7 +113,10 @@ class AppDslImportConfirmApi(Resource):
|
||||
|
||||
with Session(db.engine, expire_on_commit=False) as session:
|
||||
service = AppDslService(session)
|
||||
result = service.confirm_import(import_id=import_id, account=account)
|
||||
try:
|
||||
result = service.confirm_import(import_id=import_id, account=account)
|
||||
except NoPermissionError as exc:
|
||||
raise Forbidden(str(exc)) from exc
|
||||
if result.status == ImportStatus.FAILED:
|
||||
session.rollback()
|
||||
else:
|
||||
|
||||
@@ -151,8 +151,9 @@ class TextApi(Resource):
|
||||
tags=["TTS"],
|
||||
responses={
|
||||
200: (
|
||||
"Returns the generated audio. Generator responses are streamed by the service as `audio/mpeg`; "
|
||||
"otherwise the provider output is returned directly."
|
||||
"Returns the generated audio. The `Content-Type` header reflects the provider audio container, "
|
||||
"verified from the response bytes when recognizable. The binary response can be AAC, FLAC, MP4, "
|
||||
"MP3, Ogg, WAV, or WebM."
|
||||
),
|
||||
400: (
|
||||
"- `app_unavailable` : App unavailable or misconfigured.\n"
|
||||
@@ -165,7 +166,8 @@ class TextApi(Resource):
|
||||
},
|
||||
)
|
||||
@expect_with_user(service_api_ns, TextToAudioPayload)
|
||||
@binary_response(service_api_ns, "audio/mpeg")
|
||||
# OpenAPI 2 cannot express a runtime-selected response MIME type, so document the body as generic binary.
|
||||
@binary_response(service_api_ns, "application/octet-stream")
|
||||
@service_api_ns.doc("text_to_audio")
|
||||
@service_api_ns.doc(description="Convert text to audio using text-to-speech")
|
||||
@service_api_ns.doc(
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
from flask_restx import Resource
|
||||
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
|
||||
from extensions.ext_application_services import application_services
|
||||
from libs.helper import dump_response
|
||||
from models.model import App
|
||||
from services.app_definition_query_service import AppDefinitionUnavailableError
|
||||
|
||||
register_response_schema_models(service_api_ns, SiteResponse)
|
||||
|
||||
@@ -49,13 +49,9 @@ class AppSiteApi(Resource):
|
||||
|
||||
Returns the site configuration for the application including theme, icons, and text.
|
||||
"""
|
||||
site = db.session.scalar(select(Site).where(Site.app_id == app_model.id).limit(1))
|
||||
try:
|
||||
configuration = application_services().app_definitions.get_site_configuration(app_model.id)
|
||||
except AppDefinitionUnavailableError:
|
||||
raise Forbidden() from None
|
||||
|
||||
if not site:
|
||||
raise Forbidden()
|
||||
|
||||
assert app_model.tenant
|
||||
if app_model.tenant.status == TenantStatus.ARCHIVE:
|
||||
raise Forbidden()
|
||||
|
||||
return SiteResponse.model_validate(site).model_dump(mode="json")
|
||||
return dump_response(SiteResponse, configuration)
|
||||
|
||||
@@ -7,24 +7,29 @@ from pydantic import BaseModel, ConfigDict, Field
|
||||
from werkzeug.exceptions import Unauthorized
|
||||
|
||||
from constants import HEADER_NAME_APP_CODE
|
||||
from controllers.common import fields
|
||||
from controllers.common.fields import Parameters
|
||||
from controllers.common.errors import InvalidArgumentError
|
||||
from controllers.common.fields import AccessModeResponse, BooleanResultResponse, Parameters
|
||||
from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models
|
||||
from controllers.web import web_ns
|
||||
from controllers.web.error import (
|
||||
AgentNotPublishedError,
|
||||
AppUnavailableError,
|
||||
WebAppAccessServiceUnavailableError,
|
||||
WebAppAuthRequiredError,
|
||||
WebAppNotFoundError,
|
||||
)
|
||||
from controllers.web.wraps import WebApiResource
|
||||
from extensions.ext_application_services import application_services
|
||||
from extensions.ext_database import db
|
||||
from libs.helper import dump_response
|
||||
from libs.passport import PassportService
|
||||
from libs.token import extract_webapp_passport
|
||||
from models.model import App, EndUser
|
||||
from services.app_definition_query_service import AppDefinitionNotPublishedError, AppDefinitionUnavailableError
|
||||
from services.app_service import AppService
|
||||
from services.enterprise.enterprise_service import EnterpriseService
|
||||
from services.feature_service import FeatureService
|
||||
from services.webapp_auth_service import WebAppAuthService
|
||||
|
||||
from . import web_ns
|
||||
from .error import AgentNotPublishedError, AppUnavailableError
|
||||
from .wraps import WebApiResource
|
||||
from services.webapp_access_query_service import (
|
||||
WebAppAccessAppNotFoundError,
|
||||
WebAppAccessReferenceRequiredError,
|
||||
WebAppAccessUnavailableError,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -54,8 +59,8 @@ register_response_schema_models(
|
||||
web_ns,
|
||||
Parameters,
|
||||
AppMetaResponse,
|
||||
fields.AccessModeResponse,
|
||||
fields.BooleanResultResponse,
|
||||
AccessModeResponse,
|
||||
BooleanResultResponse,
|
||||
)
|
||||
|
||||
|
||||
@@ -122,28 +127,27 @@ class AppAccessMode(Resource):
|
||||
responses={
|
||||
200: "Success",
|
||||
400: "Bad Request",
|
||||
404: "App Not Found",
|
||||
500: "Internal Server Error",
|
||||
503: "Web App Access Service Unavailable",
|
||||
}
|
||||
)
|
||||
@web_ns.response(200, "Success", web_ns.models[fields.AccessModeResponse.__name__])
|
||||
@web_ns.response(200, "Success", web_ns.models[AccessModeResponse.__name__])
|
||||
def get(self):
|
||||
raw_args = request.args.to_dict()
|
||||
args = AppAccessModeQuery.model_validate(raw_args)
|
||||
|
||||
features = FeatureService.get_system_features()
|
||||
if not features.webapp_auth.enabled:
|
||||
return {"accessMode": "public"}
|
||||
|
||||
app_id = args.app_id
|
||||
if args.app_code:
|
||||
app_id = AppService.get_app_id_by_code(args.app_code, session=db.session())
|
||||
|
||||
if not app_id:
|
||||
raise ValueError("appId or appCode must be provided")
|
||||
|
||||
res = EnterpriseService.WebAppAuth.get_app_access_mode_by_id(app_id)
|
||||
|
||||
return {"accessMode": res.access_mode}
|
||||
try:
|
||||
access_mode = application_services().webapp_access.get_access_mode(
|
||||
app_id=args.app_id,
|
||||
app_code=args.app_code,
|
||||
)
|
||||
except WebAppAccessReferenceRequiredError as e:
|
||||
raise InvalidArgumentError(description=str(e)) from None
|
||||
except WebAppAccessAppNotFoundError:
|
||||
raise WebAppNotFoundError() from None
|
||||
except WebAppAccessUnavailableError:
|
||||
raise WebAppAccessServiceUnavailableError() from None
|
||||
return dump_response(AccessModeResponse, {"access_mode": access_mode})
|
||||
|
||||
|
||||
@web_ns.route("/webapp/permission")
|
||||
@@ -157,21 +161,23 @@ class AppWebAuthPermission(Resource):
|
||||
400: "Bad Request",
|
||||
401: "Unauthorized",
|
||||
500: "Internal Server Error",
|
||||
503: "Web App Access Service Unavailable",
|
||||
}
|
||||
)
|
||||
@web_ns.response(200, "Success", web_ns.models[fields.BooleanResultResponse.__name__])
|
||||
@web_ns.response(200, "Success", web_ns.models[BooleanResultResponse.__name__])
|
||||
def get(self):
|
||||
user_id = "visitor"
|
||||
app_code = request.headers.get(HEADER_NAME_APP_CODE)
|
||||
app_id = request.args.get("appId")
|
||||
if not app_id or not app_code:
|
||||
raise ValueError("appId must be provided")
|
||||
|
||||
require_permission_check = WebAppAuthService.is_app_require_permission_check(
|
||||
app_id=app_id, session=db.session()
|
||||
)
|
||||
if not require_permission_check:
|
||||
return {"result": True}
|
||||
webapp_access = application_services().webapp_access
|
||||
try:
|
||||
requires_permission_check = webapp_access.requires_permission_check(app_id)
|
||||
except WebAppAccessUnavailableError:
|
||||
raise WebAppAccessServiceUnavailableError() from None
|
||||
if not requires_permission_check:
|
||||
return dump_response(BooleanResultResponse, {"result": True})
|
||||
|
||||
try:
|
||||
tk = extract_webapp_passport(app_code, request)
|
||||
@@ -180,16 +186,13 @@ class AppWebAuthPermission(Resource):
|
||||
decoded = PassportService().verify(tk)
|
||||
user_id = decoded.get("user_id", "visitor")
|
||||
except Unauthorized:
|
||||
raise
|
||||
raise WebAppAuthRequiredError() from None
|
||||
except Exception:
|
||||
logger.exception("Unexpected error during auth verification")
|
||||
raise
|
||||
|
||||
features = FeatureService.get_system_features()
|
||||
if not features.webapp_auth.enabled:
|
||||
return {"result": True}
|
||||
|
||||
res = True
|
||||
if WebAppAuthService.is_app_require_permission_check(app_id=app_id, session=db.session()):
|
||||
res = EnterpriseService.WebAppAuth.is_user_allowed_to_access_webapp(str(user_id), app_id)
|
||||
return {"result": res}
|
||||
try:
|
||||
is_allowed = webapp_access.is_user_allowed(user_id=str(user_id), app_id=app_id)
|
||||
except WebAppAccessUnavailableError:
|
||||
raise WebAppAccessServiceUnavailableError() from None
|
||||
return dump_response(BooleanResultResponse, {"result": is_allowed})
|
||||
|
||||
@@ -121,6 +121,18 @@ class WebAppAuthAccessDeniedError(BaseHTTPException):
|
||||
code = 401
|
||||
|
||||
|
||||
class WebAppNotFoundError(BaseHTTPException):
|
||||
error_code = "app_not_found"
|
||||
description = "App not found."
|
||||
code = 404
|
||||
|
||||
|
||||
class WebAppAccessServiceUnavailableError(BaseHTTPException):
|
||||
error_code = "web_app_access_unavailable"
|
||||
description = "Web app access service is unavailable."
|
||||
code = 503
|
||||
|
||||
|
||||
class InvokeRateLimitError(BaseHTTPException):
|
||||
"""Raised when the Invoke returns rate limit error."""
|
||||
|
||||
|
||||
@@ -1,23 +1,19 @@
|
||||
from typing import Any, Self
|
||||
|
||||
from pydantic import AliasChoices, Field
|
||||
from sqlalchemy import select
|
||||
from werkzeug.exceptions import Forbidden
|
||||
|
||||
from configs import dify_config
|
||||
from controllers.common.schema import register_response_schema_models
|
||||
from controllers.web import web_ns
|
||||
from controllers.web.wraps import WebApiResource
|
||||
from enums import DeploymentEdition
|
||||
from extensions.ext_database import db
|
||||
from extensions.storage.storage_type import StorageType
|
||||
from extensions.ext_application_services import application_services
|
||||
from fields.base import ResponseModel
|
||||
from libs.helper import build_icon_url
|
||||
from models.account import Tenant, TenantStatus
|
||||
from models.model import App, AppMode, EndUser, IconType, Site
|
||||
from libs.helper import build_icon_url, dump_response
|
||||
from models.account import Tenant
|
||||
from models.model import App, AppMode, EndUser, Site
|
||||
from services.entities.feature_entities import FeatureModel
|
||||
from services.feature_service import FeatureService
|
||||
from services.file_service import FileService
|
||||
from services.web_app_runtime_query_service import WebAppRuntimeUnavailableError
|
||||
|
||||
|
||||
class WebSiteResponse(ResponseModel):
|
||||
@@ -128,17 +124,6 @@ register_response_schema_models(
|
||||
)
|
||||
|
||||
|
||||
def _build_site_icon_url(*, site: Site, tenant_id: str) -> str | None:
|
||||
"""Use direct S3 URLs only in Cloud Mode and preserve preview URLs elsewhere."""
|
||||
if site.icon_type != IconType.IMAGE or not site.icon:
|
||||
return None
|
||||
if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD and (
|
||||
StorageType(dify_config.STORAGE_TYPE) == StorageType.S3
|
||||
):
|
||||
return FileService(db.engine).get_file_presigned_url(file_id=site.icon, tenant_id=tenant_id)
|
||||
return build_icon_url(site.icon_type, site.icon)
|
||||
|
||||
|
||||
@web_ns.route("/site")
|
||||
class AppSiteApi(WebApiResource):
|
||||
@web_ns.doc("Get App Site Info")
|
||||
@@ -156,25 +141,21 @@ class AppSiteApi(WebApiResource):
|
||||
@web_ns.response(200, "Success", web_ns.models[WebAppSiteResponse.__name__])
|
||||
def get(self, app_model: App, end_user: EndUser):
|
||||
"""Retrieve app site info."""
|
||||
# get site
|
||||
site = db.session.scalar(select(Site).where(Site.app_id == app_model.id).limit(1))
|
||||
try:
|
||||
bootstrap = application_services().web_app_runtime.get_bootstrap(app_model.id)
|
||||
except WebAppRuntimeUnavailableError:
|
||||
raise Forbidden() from None
|
||||
|
||||
if site is None:
|
||||
raise Forbidden()
|
||||
|
||||
tenant = app_model.tenant
|
||||
if tenant is None or tenant.status == TenantStatus.ARCHIVE:
|
||||
raise Forbidden()
|
||||
|
||||
features = FeatureService.get_features(app_model.tenant_id, exclude_vector_space=True)
|
||||
|
||||
return WebAppSiteResponse.from_app_site(
|
||||
tenant=tenant,
|
||||
app_model=app_model,
|
||||
mode=AppMode.value_of(app_model.mode_compatible_with_agent_with_session(session=db.session())),
|
||||
site=site,
|
||||
end_user_id=end_user.id,
|
||||
features=features,
|
||||
can_replace_logo=features.can_replace_logo,
|
||||
icon_url=_build_site_icon_url(site=site, tenant_id=tenant.id),
|
||||
).model_dump(mode="json")
|
||||
return dump_response(
|
||||
WebAppSiteResponse,
|
||||
{
|
||||
"app_id": bootstrap.app_id,
|
||||
"mode": bootstrap.mode,
|
||||
"end_user_id": end_user.id,
|
||||
"enable_site": bootstrap.enable_site,
|
||||
"site": bootstrap.site,
|
||||
"plan": bootstrap.plan,
|
||||
"can_replace_logo": bootstrap.can_replace_logo,
|
||||
"custom_config": bootstrap.custom_config,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -6,11 +6,11 @@ from typing import Concatenate
|
||||
from flask import request
|
||||
from flask_restx import Resource
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from werkzeug.exceptions import BadRequest, NotFound, Unauthorized
|
||||
|
||||
from constants import HEADER_NAME_APP_CODE
|
||||
from controllers.web.error import WebAppAuthAccessDeniedError, WebAppAuthRequiredError
|
||||
from core.db.session_factory import session_factory
|
||||
from core.logging.context import set_identity_context
|
||||
from extensions.ext_database import db
|
||||
from libs.passport import PassportService
|
||||
@@ -54,7 +54,7 @@ def decode_jwt_token(app_code: str | None = None, user_id: str | None = None) ->
|
||||
decoded = PassportService().verify(tk)
|
||||
app_code = decoded.get("app_code")
|
||||
app_id = decoded.get("app_id")
|
||||
with sessionmaker(db.engine, expire_on_commit=False).begin() as session:
|
||||
with session_factory.create_session() as session:
|
||||
app_model = session.scalar(select(App).where(App.id == app_id))
|
||||
site = session.scalar(select(Site).where(Site.code == app_code))
|
||||
if not app_model:
|
||||
|
||||
@@ -12,6 +12,8 @@ from core.agent.errors import AgentMaxIterationError
|
||||
from core.agent.output_parser.cot_output_parser import CotAgentOutputParser
|
||||
from core.app.apps.base_app_queue_manager import PublishFrom
|
||||
from core.app.entities.queue_entities import QueueAgentThoughtEvent, QueueMessageEndEvent, QueueMessageFileEvent
|
||||
from core.credit_usage import CreditUsageAppType, CreditUsageCreatedBy
|
||||
from core.model_context import use_credit_usage_metadata
|
||||
from core.ops.ops_trace_manager import TraceQueueManager
|
||||
from core.prompt.agent_history_prompt_transform import AgentHistoryPromptTransform
|
||||
from core.tools.__base.tool import Tool
|
||||
@@ -135,6 +137,12 @@ class CotAgentRunner(BaseAgentRunner, ABC):
|
||||
session.close()
|
||||
|
||||
# invoke model
|
||||
request_metadata: dict[str, object] = {
|
||||
"app_id": self.app_config.app_id,
|
||||
"app_type": CreditUsageAppType.AGENT,
|
||||
"created_by": CreditUsageCreatedBy.APP,
|
||||
}
|
||||
|
||||
chunks = model_instance.invoke_llm(
|
||||
prompt_messages=prompt_messages,
|
||||
model_parameters=app_generate_entity.model_conf.parameters,
|
||||
@@ -142,7 +150,7 @@ class CotAgentRunner(BaseAgentRunner, ABC):
|
||||
stop=app_generate_entity.model_conf.stop,
|
||||
stream=True,
|
||||
callbacks=[],
|
||||
request_metadata={"app_id": self.app_config.app_id},
|
||||
request_metadata=request_metadata,
|
||||
)
|
||||
|
||||
usage_dict: dict[str, LLMUsage | None] = {}
|
||||
@@ -331,17 +339,18 @@ class CotAgentRunner(BaseAgentRunner, ABC):
|
||||
pass
|
||||
|
||||
# invoke tool
|
||||
tool_invoke_response, message_files, tool_invoke_meta = ToolEngine.agent_invoke(
|
||||
session=session,
|
||||
tool=tool_instance,
|
||||
tool_parameters=tool_call_args,
|
||||
user_id=self.user_id,
|
||||
tenant_id=self.tenant_id,
|
||||
message=self.message,
|
||||
invoke_from=self.application_generate_entity.invoke_from,
|
||||
agent_tool_callback=self.agent_callback,
|
||||
trace_manager=trace_manager,
|
||||
)
|
||||
with use_credit_usage_metadata({"app_type": CreditUsageAppType.AGENT}):
|
||||
tool_invoke_response, message_files, tool_invoke_meta = ToolEngine.agent_invoke(
|
||||
session=session,
|
||||
tool=tool_instance,
|
||||
tool_parameters=tool_call_args,
|
||||
user_id=self.user_id,
|
||||
tenant_id=self.tenant_id,
|
||||
message=self.message,
|
||||
invoke_from=self.application_generate_entity.invoke_from,
|
||||
agent_tool_callback=self.agent_callback,
|
||||
trace_manager=trace_manager,
|
||||
)
|
||||
session.commit()
|
||||
session.close()
|
||||
|
||||
|
||||
@@ -13,6 +13,8 @@ from core.agent.errors import AgentMaxIterationError
|
||||
from core.app.apps.base_app_queue_manager import PublishFrom
|
||||
from core.app.entities.queue_entities import QueueAgentThoughtEvent, QueueMessageEndEvent, QueueMessageFileEvent
|
||||
from core.app.file_access import grant_upload_file_access
|
||||
from core.credit_usage import CreditUsageAppType, CreditUsageCreatedBy
|
||||
from core.model_context import use_credit_usage_metadata
|
||||
from core.prompt.agent_history_prompt_transform import AgentHistoryPromptTransform
|
||||
from core.tools.entities.tool_entities import ToolInvokeMeta
|
||||
from core.tools.signature import sign_upload_file_preview_url
|
||||
@@ -167,6 +169,12 @@ class FunctionCallAgentRunner(BaseAgentRunner):
|
||||
session.close()
|
||||
|
||||
# invoke model
|
||||
request_metadata: dict[str, object] = {
|
||||
"app_id": self.app_config.app_id,
|
||||
"app_type": CreditUsageAppType.AGENT,
|
||||
"created_by": CreditUsageCreatedBy.APP,
|
||||
}
|
||||
|
||||
chunks: Union[Generator[LLMResultChunk, None, None], LLMResult] = model_instance.invoke_llm(
|
||||
prompt_messages=prompt_messages,
|
||||
model_parameters=app_generate_entity.model_conf.parameters,
|
||||
@@ -174,7 +182,7 @@ class FunctionCallAgentRunner(BaseAgentRunner):
|
||||
stop=app_generate_entity.model_conf.stop,
|
||||
stream=self.stream_tool_call,
|
||||
callbacks=[],
|
||||
request_metadata={"app_id": self.app_config.app_id},
|
||||
request_metadata=request_metadata,
|
||||
)
|
||||
|
||||
tool_calls: list[tuple[str, str, dict[str, Any]]] = []
|
||||
@@ -315,20 +323,21 @@ class FunctionCallAgentRunner(BaseAgentRunner):
|
||||
}
|
||||
else:
|
||||
# invoke tool
|
||||
tool_invoke_response, message_files, tool_invoke_meta = ToolEngine.agent_invoke(
|
||||
session=session,
|
||||
tool=tool_instance,
|
||||
tool_parameters=tool_call_args,
|
||||
user_id=self.user_id,
|
||||
tenant_id=self.tenant_id,
|
||||
message=self.message,
|
||||
invoke_from=self.application_generate_entity.invoke_from,
|
||||
agent_tool_callback=self.agent_callback,
|
||||
trace_manager=trace_manager,
|
||||
app_id=self.application_generate_entity.app_config.app_id,
|
||||
message_id=self.message.id,
|
||||
conversation_id=self.conversation.id,
|
||||
)
|
||||
with use_credit_usage_metadata({"app_type": CreditUsageAppType.AGENT}):
|
||||
tool_invoke_response, message_files, tool_invoke_meta = ToolEngine.agent_invoke(
|
||||
session=session,
|
||||
tool=tool_instance,
|
||||
tool_parameters=tool_call_args,
|
||||
user_id=self.user_id,
|
||||
tenant_id=self.tenant_id,
|
||||
message=self.message,
|
||||
invoke_from=self.application_generate_entity.invoke_from,
|
||||
agent_tool_callback=self.agent_callback,
|
||||
trace_manager=trace_manager,
|
||||
app_id=self.application_generate_entity.app_config.app_id,
|
||||
message_id=self.message.id,
|
||||
conversation_id=self.conversation.id,
|
||||
)
|
||||
session.commit()
|
||||
session.close()
|
||||
# publish files
|
||||
|
||||
@@ -11,13 +11,16 @@ from core.app.apps.base_app_queue_manager import AppQueueManager
|
||||
from core.app.apps.workflow.command_channels import (
|
||||
CelerySignalCommandChannel,
|
||||
CombinedCommandChannel,
|
||||
StopFlagCommandChannel,
|
||||
)
|
||||
from core.app.apps.workflow.stop_aware_ready_queue import attach_stop_aware_ready_queue
|
||||
from core.app.apps.workflow_app_runner import WorkflowBasedAppRunner
|
||||
from core.app.entities.app_invoke_entities import (
|
||||
AdvancedChatAppGenerateEntity,
|
||||
AppGenerateEntity,
|
||||
DifyRunContext,
|
||||
InvokeFrom,
|
||||
get_credit_usage_app_type,
|
||||
)
|
||||
from core.app.entities.queue_entities import (
|
||||
QueueAnnotationReplyEvent,
|
||||
@@ -139,6 +142,7 @@ class AdvancedChatAppRunner(WorkflowBasedAppRunner):
|
||||
user_id=self.application_generate_entity.user_id,
|
||||
invoke_from=invoke_from,
|
||||
user_from=user_from,
|
||||
app_type=get_credit_usage_app_type(app_config.app_mode),
|
||||
trace_session_id=self.application_generate_entity.extras.get("trace_session_id"),
|
||||
)
|
||||
elif self.application_generate_entity.single_iteration_run or self.application_generate_entity.single_loop_run:
|
||||
@@ -148,6 +152,7 @@ class AdvancedChatAppRunner(WorkflowBasedAppRunner):
|
||||
single_iteration_run=self.application_generate_entity.single_iteration_run,
|
||||
single_loop_run=self.application_generate_entity.single_loop_run,
|
||||
user_id=self.application_generate_entity.user_id,
|
||||
app_type=get_credit_usage_app_type(app_config.app_mode),
|
||||
trace_session_id=self.application_generate_entity.extras.get("trace_session_id"),
|
||||
)
|
||||
else:
|
||||
@@ -218,6 +223,7 @@ class AdvancedChatAppRunner(WorkflowBasedAppRunner):
|
||||
user_from=user_from,
|
||||
invoke_from=invoke_from,
|
||||
root_node_id=root_node_id,
|
||||
app_type=get_credit_usage_app_type(app_config.app_mode),
|
||||
trace_session_id=self.application_generate_entity.extras.get("trace_session_id"),
|
||||
)
|
||||
|
||||
@@ -229,9 +235,11 @@ class AdvancedChatAppRunner(WorkflowBasedAppRunner):
|
||||
shutdown_state_getter=celery_warm_shutdown_started,
|
||||
abort_reason=WORKFLOW_WARM_SHUTDOWN_ABORT_REASON,
|
||||
)
|
||||
attach_stop_aware_ready_queue(graph_runtime_state, task_id=task_id)
|
||||
command_channel = CombinedCommandChannel(
|
||||
(
|
||||
RedisChannel(redis_client, channel_key),
|
||||
StopFlagCommandChannel(task_id=task_id),
|
||||
celery_signal_channel,
|
||||
)
|
||||
)
|
||||
@@ -276,6 +284,7 @@ class AdvancedChatAppRunner(WorkflowBasedAppRunner):
|
||||
user_id=self.application_generate_entity.user_id,
|
||||
user_from=user_from,
|
||||
invoke_from=invoke_from,
|
||||
app_type=get_credit_usage_app_type(app_config.app_mode),
|
||||
trace_session_id=self.application_generate_entity.extras.get("trace_session_id"),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -11,7 +11,6 @@ from typing import Any, Union
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from constants.tts_auto_play_timeout import TTS_AUTO_PLAY_TIMEOUT, TTS_AUTO_PLAY_YIELD_CPU_TIME
|
||||
from core.app.apps.base_app_queue_manager import AppQueueManager, PublishFrom
|
||||
from core.app.apps.common.graph_runtime_state_support import GraphRuntimeStateSupport
|
||||
from core.app.apps.common.workflow_response_converter import WorkflowResponseConverter
|
||||
@@ -19,6 +18,7 @@ from core.app.apps.draft_variable_saver import DraftVariableSaverFactory
|
||||
from core.app.entities.app_invoke_entities import (
|
||||
AdvancedChatAppGenerateEntity,
|
||||
InvokeFrom,
|
||||
get_credit_usage_app_type,
|
||||
)
|
||||
from core.app.entities.queue_entities import (
|
||||
MessageQueueMessage,
|
||||
@@ -70,9 +70,10 @@ from core.app.entities.task_entities import (
|
||||
)
|
||||
from core.app.task_pipeline.based_generate_task_pipeline import BasedGenerateTaskPipeline
|
||||
from core.app.task_pipeline.message_cycle_manager import MessageCycleManager
|
||||
from core.base.tts import AppGeneratorTTSPublisher, AudioTrunk
|
||||
from core.base.tts import AppGeneratorTTSPublisher
|
||||
from core.db.session_factory import session_factory
|
||||
from core.ops.ops_trace_manager import TraceQueueManager
|
||||
from core.ops.entities.trace_entity import TraceTaskName
|
||||
from core.ops.ops_trace_manager import TraceQueueManager, TraceTask
|
||||
from core.repositories.human_input_repository import HumanInputFormRepositoryImpl
|
||||
from core.workflow.file_reference import resolve_file_record_id
|
||||
from core.workflow.nodes.human_input.pause_reason import HumanInputRequired
|
||||
@@ -228,7 +229,9 @@ class AdvancedChatAppGenerateTaskPipeline(GraphRuntimeStateSupport):
|
||||
:return:
|
||||
"""
|
||||
self._conversation_name_generate_thread = self._message_cycle_manager.generate_conversation_name(
|
||||
conversation_id=self._conversation_id, query=self._application_generate_entity.query
|
||||
conversation_id=self._conversation_id,
|
||||
query=self._application_generate_entity.query,
|
||||
message_id=self._message_id,
|
||||
)
|
||||
|
||||
generator = self._wrapper_process_stream_response(trace_manager=self._application_generate_entity.trace_manager)
|
||||
@@ -346,9 +349,13 @@ class AdvancedChatAppGenerateTaskPipeline(GraphRuntimeStateSupport):
|
||||
if not publisher:
|
||||
return None
|
||||
audio_msg = publisher.check_and_get_audio()
|
||||
if audio_msg and isinstance(audio_msg, AudioTrunk) and audio_msg.status != "finish":
|
||||
return MessageAudioStreamResponse(audio=audio_msg.audio, task_id=task_id)
|
||||
return None
|
||||
if audio_msg is None:
|
||||
return None
|
||||
if audio_msg.status == "responding":
|
||||
return MessageAudioStreamResponse(audio=audio_msg.audio, audio_type=audio_msg.audio_type, task_id=task_id)
|
||||
if audio_msg.status in {"finish", "error"}:
|
||||
return None
|
||||
raise RuntimeError(f"TTS publisher returned an unknown status: {audio_msg.status}")
|
||||
|
||||
def _wrapper_process_stream_response(
|
||||
self, trace_manager: TraceQueueManager | None = None
|
||||
@@ -359,42 +366,53 @@ class AdvancedChatAppGenerateTaskPipeline(GraphRuntimeStateSupport):
|
||||
features_dict = self._workflow_features_dict
|
||||
|
||||
if (
|
||||
features_dict.get("text_to_speech")
|
||||
self._base_task_pipeline.stream
|
||||
and features_dict.get("text_to_speech")
|
||||
and features_dict["text_to_speech"].get("enabled")
|
||||
and features_dict["text_to_speech"].get("autoPlay") == "enabled"
|
||||
):
|
||||
tts_publisher = AppGeneratorTTSPublisher(
|
||||
tenant_id, features_dict["text_to_speech"].get("voice"), features_dict["text_to_speech"].get("language")
|
||||
tenant_id,
|
||||
features_dict["text_to_speech"].get("voice"),
|
||||
features_dict["text_to_speech"].get("language"),
|
||||
get_credit_usage_app_type(self._application_generate_entity.app_config.app_mode),
|
||||
)
|
||||
|
||||
for response in self._process_stream_response(tts_publisher=tts_publisher, trace_manager=trace_manager):
|
||||
while True:
|
||||
audio_response = self._listen_audio_msg(publisher=tts_publisher, task_id=task_id)
|
||||
if audio_response:
|
||||
try:
|
||||
for response in self._process_stream_response(tts_publisher=tts_publisher, trace_manager=trace_manager):
|
||||
while audio_response := self._listen_audio_msg(publisher=tts_publisher, task_id=task_id):
|
||||
yield audio_response
|
||||
else:
|
||||
break
|
||||
yield response
|
||||
if tts_publisher and isinstance(response, ErrorStreamResponse):
|
||||
tts_publisher.cancel()
|
||||
yield MessageAudioEndStreamResponse(audio="", task_id=task_id)
|
||||
yield response
|
||||
return
|
||||
yield response
|
||||
|
||||
start_listener_time = time.time()
|
||||
while (time.time() - start_listener_time) < TTS_AUTO_PLAY_TIMEOUT:
|
||||
try:
|
||||
if not tts_publisher:
|
||||
break
|
||||
audio_trunk = tts_publisher.check_and_get_audio()
|
||||
if audio_trunk is None:
|
||||
time.sleep(TTS_AUTO_PLAY_YIELD_CPU_TIME)
|
||||
if tts_publisher is None:
|
||||
return
|
||||
|
||||
tts_publisher.publish(None)
|
||||
while True:
|
||||
audio_trunk = tts_publisher.check_and_get_audio(block=True)
|
||||
assert audio_trunk is not None
|
||||
if audio_trunk.status == "responding":
|
||||
yield MessageAudioStreamResponse(
|
||||
audio=audio_trunk.audio, audio_type=audio_trunk.audio_type, task_id=task_id
|
||||
)
|
||||
continue
|
||||
if audio_trunk.status == "finish":
|
||||
break
|
||||
else:
|
||||
start_listener_time = time.time()
|
||||
yield MessageAudioStreamResponse(audio=audio_trunk.audio, task_id=task_id)
|
||||
except Exception:
|
||||
logger.exception("Failed to listen audio message, task_id: %s", task_id)
|
||||
break
|
||||
if tts_publisher:
|
||||
yield MessageAudioEndStreamResponse(audio="", task_id=task_id)
|
||||
if audio_trunk.status not in {"finish", "error"}:
|
||||
raise RuntimeError(f"TTS publisher returned an unknown status: {audio_trunk.status}")
|
||||
|
||||
yield MessageAudioEndStreamResponse(audio="", task_id=task_id)
|
||||
if audio_trunk.status == "error":
|
||||
if audio_trunk.error is None:
|
||||
raise RuntimeError("TTS publisher returned an error terminal without an exception")
|
||||
yield ErrorStreamResponse(err=audio_trunk.error, task_id=task_id)
|
||||
return
|
||||
finally:
|
||||
if tts_publisher:
|
||||
tts_publisher.cancel()
|
||||
|
||||
@contextmanager
|
||||
def _database_session(self):
|
||||
@@ -779,6 +797,7 @@ class AdvancedChatAppGenerateTaskPipeline(GraphRuntimeStateSupport):
|
||||
with self._database_session() as session:
|
||||
# Save message
|
||||
self._save_message(session=session, graph_runtime_state=resolved_state)
|
||||
self._emit_message_trace()
|
||||
|
||||
yield workflow_finish_resp
|
||||
elif event.stopped_by in (
|
||||
@@ -789,6 +808,7 @@ class AdvancedChatAppGenerateTaskPipeline(GraphRuntimeStateSupport):
|
||||
with self._database_session() as session:
|
||||
# Save message
|
||||
self._save_message(session=session)
|
||||
self._emit_message_trace()
|
||||
|
||||
yield self._message_end_to_stream_response()
|
||||
|
||||
@@ -816,6 +836,7 @@ class AdvancedChatAppGenerateTaskPipeline(GraphRuntimeStateSupport):
|
||||
if not self._message_saved_on_pause:
|
||||
with self._database_session() as session:
|
||||
self._save_message(session=session, graph_runtime_state=resolved_state)
|
||||
self._emit_message_trace()
|
||||
|
||||
yield self._message_end_to_stream_response()
|
||||
|
||||
@@ -1036,9 +1057,6 @@ class AdvancedChatAppGenerateTaskPipeline(GraphRuntimeStateSupport):
|
||||
):
|
||||
yield from responses
|
||||
|
||||
if tts_publisher:
|
||||
tts_publisher.publish(None)
|
||||
|
||||
if self._conversation_name_generate_thread:
|
||||
logger.debug("Conversation name generation running as daemon thread")
|
||||
|
||||
@@ -1101,6 +1119,18 @@ class AdvancedChatAppGenerateTaskPipeline(GraphRuntimeStateSupport):
|
||||
)
|
||||
session.add_all(message_files)
|
||||
|
||||
def _emit_message_trace(self) -> None:
|
||||
trace_manager = self._application_generate_entity.trace_manager
|
||||
if trace_manager:
|
||||
trace_manager.add_trace_task(
|
||||
TraceTask(
|
||||
TraceTaskName.MESSAGE_TRACE,
|
||||
conversation_id=self._conversation_id,
|
||||
message_id=self._message_id,
|
||||
trace_session_id=self._application_generate_entity.extras.get("trace_session_id"),
|
||||
)
|
||||
)
|
||||
|
||||
def _seed_graph_runtime_state_from_queue_manager(self) -> None:
|
||||
"""Bootstrap the cached runtime state from the queue manager when present."""
|
||||
candidate = self._base_task_pipeline.queue_manager.graph_runtime_state
|
||||
|
||||
@@ -45,6 +45,7 @@ from core.app.entities.app_invoke_entities import (
|
||||
InvokeFrom,
|
||||
UserFrom,
|
||||
)
|
||||
from core.credit_usage import CreditUsageAppType
|
||||
from core.db.session_factory import session_factory
|
||||
from core.ops.ops_trace_manager import TraceQueueManager
|
||||
from core.workflow.file_reference import build_file_reference, is_canonical_file_reference
|
||||
@@ -498,6 +499,7 @@ class AgentAppGenerator(MessageBasedAppGenerator):
|
||||
user_id=application_generate_entity.user_id,
|
||||
user_from=user_from,
|
||||
invoke_from=application_generate_entity.invoke_from,
|
||||
app_type=CreditUsageAppType.AGENT_V2,
|
||||
)
|
||||
with session_factory.create_session() as session:
|
||||
agent, config_version, agent_soul = self._resolve_agent_by_id(
|
||||
|
||||
@@ -44,6 +44,7 @@ from core.workflow.nodes.agent_v2.runtime_request_builder import (
|
||||
build_config_layer_config,
|
||||
build_knowledge_layer_config,
|
||||
build_shell_layer_config,
|
||||
load_runtime_agent_skill_configs,
|
||||
)
|
||||
from models.agent_config_entities import AgentSoulConfig, AgentSoulToolsConfig
|
||||
from models.provider_ids import ModelProviderID
|
||||
@@ -121,14 +122,22 @@ class AgentAppRuntimeRequestBuilder:
|
||||
"cli_tool_count": len(agent_soul.tools.cli_tools),
|
||||
}
|
||||
|
||||
runtime_config_skills = load_runtime_agent_skill_configs(
|
||||
tenant_id=context.dify_context.tenant_id,
|
||||
agent_id=context.agent_id,
|
||||
)
|
||||
config_layer_config, config_warnings = build_config_layer_config(
|
||||
agent_soul,
|
||||
agent_id=context.agent_id,
|
||||
config_version_id=context.agent_config_snapshot_id,
|
||||
config_version_kind=context.agent_config_version_kind,
|
||||
runtime_config_skills=runtime_config_skills,
|
||||
)
|
||||
append_runtime_warnings(metadata, config_warnings)
|
||||
soul_prompt_resolver = build_config_aware_soul_mention_resolver(agent_soul)
|
||||
soul_prompt_resolver = build_config_aware_soul_mention_resolver(
|
||||
agent_soul,
|
||||
runtime_config_skills=runtime_config_skills,
|
||||
)
|
||||
knowledge_config = build_knowledge_layer_config(agent_soul)
|
||||
context_window_tokens = resolve_model_context_window(
|
||||
run_context=context.dify_context,
|
||||
|
||||
@@ -9,6 +9,8 @@ from core.app.apps.base_app_runner import AppRunner
|
||||
from core.app.apps.chat.app_config_manager import ChatAppConfig
|
||||
from core.app.entities.app_invoke_entities import (
|
||||
ChatAppGenerateEntity,
|
||||
get_credit_usage_app_type,
|
||||
get_credit_usage_created_by,
|
||||
)
|
||||
from core.app.entities.queue_entities import QueueAnnotationReplyEvent
|
||||
from core.callback_handler.index_tool_callback_handler import DatasetIndexToolCallbackHandler
|
||||
@@ -231,12 +233,16 @@ class ChatAppRunner(AppRunner):
|
||||
model=application_generate_entity.model_conf.model,
|
||||
)
|
||||
|
||||
request_metadata: dict[str, object] = {"app_id": app_config.app_id}
|
||||
request_metadata["app_type"] = get_credit_usage_app_type(app_config.app_mode)
|
||||
request_metadata["created_by"] = get_credit_usage_created_by(app_config.app_mode)
|
||||
|
||||
invoke_result = model_instance.invoke_llm(
|
||||
prompt_messages=prompt_messages,
|
||||
model_parameters=application_generate_entity.model_conf.parameters,
|
||||
stop=stop,
|
||||
stream=application_generate_entity.stream,
|
||||
request_metadata={"app_id": app_config.app_id},
|
||||
request_metadata=request_metadata,
|
||||
)
|
||||
|
||||
# handle invoke result
|
||||
|
||||
@@ -9,6 +9,8 @@ from core.app.apps.base_app_runner import AppRunner
|
||||
from core.app.apps.completion.app_config_manager import CompletionAppConfig
|
||||
from core.app.entities.app_invoke_entities import (
|
||||
CompletionAppGenerateEntity,
|
||||
get_credit_usage_app_type,
|
||||
get_credit_usage_created_by,
|
||||
)
|
||||
from core.callback_handler.index_tool_callback_handler import DatasetIndexToolCallbackHandler
|
||||
from core.db.session_factory import create_session
|
||||
@@ -192,12 +194,16 @@ class CompletionAppRunner(AppRunner):
|
||||
model=application_generate_entity.model_conf.model,
|
||||
)
|
||||
|
||||
request_metadata: dict[str, object] = {"app_id": app_config.app_id}
|
||||
request_metadata["app_type"] = get_credit_usage_app_type(app_config.app_mode)
|
||||
request_metadata["created_by"] = get_credit_usage_created_by(app_config.app_mode)
|
||||
|
||||
invoke_result = model_instance.invoke_llm(
|
||||
prompt_messages=prompt_messages,
|
||||
model_parameters=application_generate_entity.model_conf.parameters,
|
||||
stop=stop,
|
||||
stream=application_generate_entity.stream,
|
||||
request_metadata={"app_id": app_config.app_id},
|
||||
request_metadata=request_metadata,
|
||||
)
|
||||
|
||||
# handle invoke result
|
||||
|
||||
@@ -26,11 +26,24 @@ def app_task_command_channel_key(task_id: str) -> str:
|
||||
return f"workflow:{task_id}:commands"
|
||||
|
||||
|
||||
def app_task_stop_flag_key(task_id: str) -> str:
|
||||
"""Redis key of the legacy generate-task stop flag."""
|
||||
return f"generate_task_stopped:{task_id}"
|
||||
|
||||
|
||||
def set_app_task_stop_flag(task_id: str) -> None:
|
||||
if not task_id:
|
||||
return
|
||||
|
||||
redis_client.setex(f"generate_task_stopped:{task_id}", 600, 1)
|
||||
redis_client.setex(app_task_stop_flag_key(task_id), 600, 1)
|
||||
|
||||
|
||||
def is_app_task_stop_flag_set(task_id: str) -> bool:
|
||||
"""Return whether the legacy Redis stop flag is currently armed."""
|
||||
if not task_id:
|
||||
return False
|
||||
|
||||
return redis_client.get(app_task_stop_flag_key(task_id)) is not None
|
||||
|
||||
|
||||
def clear_app_task_cancellation_signals(task_id: str) -> None:
|
||||
@@ -47,7 +60,7 @@ def clear_app_task_cancellation_signals(task_id: str) -> None:
|
||||
return
|
||||
|
||||
try:
|
||||
redis_client.delete(f"generate_task_stopped:{task_id}")
|
||||
redis_client.delete(app_task_stop_flag_key(task_id))
|
||||
except Exception:
|
||||
logger.exception("Failed to clear stop flag for app task %s", task_id)
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ from core.prompt.utils.prompt_template_parser import PromptTemplateParser
|
||||
from core.workflow.file_reference import resolve_file_record_id
|
||||
from extensions.ext_database import db
|
||||
from extensions.ext_redis import get_pubsub_broadcast_channel
|
||||
from libs.broadcast_channel.channel import Topic
|
||||
from libs.broadcast_channel.channel import SupportsPreparedSubscription, Topic
|
||||
from libs.datetime_utils import naive_utc_now
|
||||
from models import Account
|
||||
from models.enums import ConversationFromSource, CreatorUserRole, MessageFileBelongsTo
|
||||
@@ -323,8 +323,14 @@ class MessageBasedAppGenerator(BaseAppGenerator):
|
||||
on_subscribe: Callable[[], None] | None = None,
|
||||
) -> Generator[Mapping | str, None, None]:
|
||||
topic = cls.get_response_topic(app_mode, workflow_run_id)
|
||||
subscriber = topic.as_subscriber()
|
||||
subscription = (
|
||||
subscriber.prepare_subscription()
|
||||
if isinstance(subscriber, SupportsPreparedSubscription)
|
||||
else subscriber.subscribe()
|
||||
)
|
||||
return stream_topic_events(
|
||||
topic=topic,
|
||||
subscription=subscription,
|
||||
idle_timeout=idle_timeout,
|
||||
on_subscribe=on_subscribe,
|
||||
)
|
||||
|
||||
@@ -3,7 +3,7 @@ from collections.abc import Callable, Generator, Iterable, Mapping
|
||||
from core.app.apps.streaming_utils import stream_topic_events
|
||||
from core.app.entities.task_entities import StreamEvent
|
||||
from extensions.ext_redis import get_pubsub_broadcast_channel
|
||||
from libs.broadcast_channel.channel import Topic
|
||||
from libs.broadcast_channel.channel import SupportsPreparedSubscription, Topic
|
||||
from models.model import AppMode
|
||||
|
||||
|
||||
@@ -30,8 +30,14 @@ class MessageGenerator:
|
||||
terminal_events: Iterable[str | StreamEvent] | None = None,
|
||||
) -> Generator[Mapping | str, None, None]:
|
||||
topic = cls.get_response_topic(app_mode, workflow_run_id)
|
||||
subscriber = topic.as_subscriber()
|
||||
subscription = (
|
||||
subscriber.prepare_subscription()
|
||||
if isinstance(subscriber, SupportsPreparedSubscription)
|
||||
else subscriber.subscribe()
|
||||
)
|
||||
return stream_topic_events(
|
||||
topic=topic,
|
||||
subscription=subscription,
|
||||
idle_timeout=idle_timeout,
|
||||
ping_interval=ping_interval,
|
||||
on_subscribe=on_subscribe,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user