Compare commits
132
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
01419f4b4e | ||
|
|
e4c97139c0 | ||
|
|
444d164aea | ||
|
|
46163c68bd | ||
|
|
54a8ff2c1d | ||
|
|
9521c7fe7d | ||
|
|
995ba6b00e | ||
|
|
b5a5fb7247 | ||
|
|
0f29a3b425 | ||
|
|
a66b3559aa | ||
|
|
89cef33b45 | ||
|
|
40f977d8bc | ||
|
|
d859922943 | ||
|
|
49a92f096f | ||
|
|
fa1ac75922 | ||
|
|
cb35c6fa98 | ||
|
|
34f62e7df6 | ||
|
|
07b5dcbb19 | ||
|
|
23917c7b3e | ||
|
|
8a6ce28855 | ||
|
|
0d5f43520c | ||
|
|
d8f3be4bcd | ||
|
|
fc16fcba36 | ||
|
|
ca2755e0c1 | ||
|
|
d2216fe181 | ||
|
|
0d2084494e | ||
|
|
358cf8be33 | ||
|
|
93d6506443 | ||
|
|
77d624e7a4 | ||
|
|
9d3ac1b7b3 | ||
|
|
7a111c2226 | ||
|
|
7bb94cb6fe | ||
|
|
484633d261 | ||
|
|
a14310fc62 | ||
|
|
8218694691 | ||
|
|
17bee5fb32 | ||
|
|
446b3962c1 | ||
|
|
00b4cdc68e | ||
|
|
449b46b863 | ||
|
|
8d09b32cd5 | ||
|
|
52c106b532 | ||
|
|
5b8679468d | ||
|
|
4fbfedd287 | ||
|
|
051f4b32e3 | ||
|
|
e22fd9efd6 | ||
|
|
35eeb743d1 | ||
|
|
1dbda1463e | ||
|
|
3f2ef24755 | ||
|
|
eb4ec93cea | ||
|
|
fa6f4b0ea5 | ||
|
|
267b34caaf | ||
|
|
6ab1dd06ac | ||
|
|
113d6d7e00 | ||
|
|
a66b1de477 | ||
|
|
16b698b54d | ||
|
|
4cd8b8c733 | ||
|
|
677ab01806 | ||
|
|
f4e832f35c | ||
|
|
1c5e1280cb | ||
|
|
0035d90e36 | ||
|
|
2047e0dc12 | ||
|
|
2e9c0a3c7a | ||
|
|
a246dc8b17 | ||
|
|
bb921bcc45 | ||
|
|
4f4ac27de2 | ||
|
|
3aa26fb637 | ||
|
|
1d2cc1e475 | ||
|
|
aa37c1d833 | ||
|
|
48dfbd60d6 | ||
|
|
e90c7ab8a7 | ||
|
|
40119fef44 | ||
|
|
72a03c2d6a | ||
|
|
affdc89f84 | ||
|
|
b33e8f0ddb | ||
|
|
8f74e176ca | ||
|
|
b9bcf31c72 | ||
|
|
abf2986299 | ||
|
|
599d92ef6b | ||
|
|
93dd955deb | ||
|
|
75909ce10e | ||
|
|
d93989bfc0 | ||
|
|
31a50a3b20 | ||
|
|
3d8316333f | ||
|
|
9fc2925b00 | ||
|
|
d349e892f4 | ||
|
|
2483c091aa | ||
|
|
a421362847 | ||
|
|
4964359961 | ||
|
|
1b81ac033f | ||
|
|
2eb564696e | ||
|
|
d87764b0f8 | ||
|
|
d135dab241 | ||
|
|
ea1aa2fecd | ||
|
|
86b73ba205 | ||
|
|
fe62177ba5 | ||
|
|
ce6297bed2 | ||
|
|
94d365ea5e | ||
|
|
32dc9ff2d9 | ||
|
|
2112115962 | ||
|
|
f665bcac95 | ||
|
|
347b02d318 | ||
|
|
eb19140986 | ||
|
|
f8a9605417 | ||
|
|
4573aaa717 | ||
|
|
67a5eacf2d | ||
|
|
2cde7e4a94 | ||
|
|
4086f5f2d9 | ||
|
|
31c08faded | ||
|
|
1c1b20aa46 | ||
|
|
24dd7ea3a8 | ||
|
|
1eafbf9763 | ||
|
|
d0b2239c60 | ||
|
|
50b3228bc7 | ||
|
|
5b453069d1 | ||
|
|
acf6d0ddc9 | ||
|
|
d8ed874dc7 | ||
|
|
ed500761c8 | ||
|
|
26b0ff2a01 | ||
|
|
b56e5f74e7 | ||
|
|
4ac8c5a30e | ||
|
|
be3677f145 | ||
|
|
d1a3f31f15 | ||
|
|
ac06deba20 | ||
|
|
725e4da29d | ||
|
|
c2a554da93 | ||
|
|
db9b899321 | ||
|
|
b98ba99b23 | ||
|
|
04d226384f | ||
|
|
0fcaf92d67 | ||
|
|
a554891bbd | ||
|
|
d764b16d06 | ||
|
|
f4fdbeba76 |
@@ -102,11 +102,11 @@ describe('ComponentName', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// Props tests (REQUIRED)
|
||||
// Props tests (REQUIRED when props change observable behavior)
|
||||
describe('Props', () => {
|
||||
it('should apply custom className', () => {
|
||||
render(<Component className="custom" />)
|
||||
expect(screen.getByRole('button')).toHaveClass('custom')
|
||||
it('should disable the action when disabled', () => {
|
||||
render(<Component disabled />)
|
||||
expect(screen.getByRole('button')).toBeDisabled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -220,6 +220,7 @@ Every test should clearly separate:
|
||||
### 2. Black-Box Testing
|
||||
|
||||
- Test observable behavior, not implementation details
|
||||
- Test product contracts, not cosmetic implementation. Do not add or expand unit tests only to lock pure style classes, spacing, colors, backgrounds, or layout micro-adjustments. Cover visual-only fixes with browser/manual verification, screenshots, or E2E/visual checks when risk justifies it. Add unit tests only when the change affects user-observable behavior, accessibility semantics, state, data flow, routing, or a stable component API contract.
|
||||
- Use semantic queries (`getByRole` with accessible `name`, `getByLabelText`, `getByPlaceholderText`, `getByText`, and scoped `within(...)`)
|
||||
- Treat `getByTestId` as a last resort. If a control cannot be found by role/name, label, landmark, or dialog scope, fix the component accessibility first instead of adding or relying on `data-testid`.
|
||||
- Remove production `data-testid` attributes when semantic selectors can cover the behavior. Keep them only for non-visual mocked boundaries, editor/browser shims such as Monaco, canvas/chart output, or third-party widgets with no accessible DOM in the test environment.
|
||||
@@ -273,7 +274,7 @@ it('should disable input when isReadOnly is true')
|
||||
### Always Required (All Components)
|
||||
|
||||
1. **Rendering**: Component renders without crashing
|
||||
1. **Props**: Required props, optional props, default values
|
||||
1. **Props**: Required props, optional props, default values that change observable behavior. Do not test pass-through styling props such as `className` unless they are an explicit, stable component API whose absence would break a real integration contract.
|
||||
1. **Edge Cases**: null, undefined, empty values, boundary conditions
|
||||
|
||||
### Conditional (When Present)
|
||||
|
||||
@@ -1,147 +1,127 @@
|
||||
---
|
||||
name: how-to-write-component
|
||||
description: React/TypeScript component style guide. Use when writing, refactoring, or reviewing React components, especially around abstraction choices, props typing, state boundaries, shared local state with Jotai atoms, API types, query/mutation contracts, navigation, memoization, wrappers, and empty-state handling.
|
||||
description: Use when writing, refactoring, or reviewing React/TypeScript components in Dify web, especially decisions about component ownership, props/types, URL/query state, Jotai state, async state, generated API contracts, queries/mutations, overlays, effects, navigation, performance, and empty states.
|
||||
---
|
||||
|
||||
# How To Write A Component
|
||||
|
||||
Use this as the decision guide for React/TypeScript component structure. Existing code is reference material, not automatic precedent; when it conflicts with these rules, adapt the approach instead of reproducing the violation.
|
||||
Use this as the component decision guide for Dify web. Existing code is reference material, not automatic precedent; if touched code violates these rules, adapt it and fix equivalent patterns in the same feature branch.
|
||||
|
||||
## First Decisions
|
||||
|
||||
| Question | Default | Promote or extract only when |
|
||||
| --- | --- | --- |
|
||||
| Where should code live? | Keep it local to the feature workflow, route, or owner. | Multiple verticals need the same stable primitive. |
|
||||
| How should route/tab folders be named? | Match the current route segment, tab name, or user-visible surface. | Keep a historical or broader parent only when it still owns multiple surfaces. |
|
||||
| Who owns state, data, and handlers? | The lowest component that uses them. | A parent coordinates shared loading, errors, empty UI, selection, submission, navigation, or one consistent snapshot. |
|
||||
| Should this become Jotai state? | Keep synchronous UI/form state in component or DOM state. | Siblings need one source of truth, the value drives atoms, or scoped workflow state must survive hidden/unmounted steps. |
|
||||
| Should URL state enter Jotai? | Let Next.js route params and `nuqs` own URL state and updates. | Query atoms or shared derived atoms need a read-only bridge hydrated at the route/surface boundary. |
|
||||
| Should this query/mutation become an atom? | Use TanStack Query hooks at the lowest owner. | It reads atom state, feeds derived atoms, or participates in shared Jotai workflow orchestration. |
|
||||
| Should this be a helper/wrapper? | Prefer direct readable code at the use site. | The name captures a stable domain rule or the wrapper owns real behavior, validation, state, error handling, or semantics. |
|
||||
| Is an Effect needed? | No. Derive during render or handle the user action in the event handler. | It synchronizes with an external system such as browser APIs, subscriptions, timers, analytics, or imperative DOM/non-React widgets. |
|
||||
|
||||
## Core Defaults
|
||||
|
||||
- Search before adding UI, hooks, helpers, or styling patterns. Reuse existing base components, feature components, hooks, utilities, and design styles when they fit.
|
||||
- Group code by feature workflow, route, or ownership area: components, hooks, local types, query helpers, atoms, constants, and small utilities should live near the code that changes with them.
|
||||
- Promote code to shared only when multiple verticals need the same stable primitive. Otherwise keep it local and compose shared primitives inside the owning feature.
|
||||
- Prefer local code and purpose-named helpers over catch-all utility modules; do not group workflow-specific defaults, validation, payload shaping, or metadata merging in a generic utils file just because they share a DTO.
|
||||
- Keep source/default selection, validation, and payload shaping close to the workflow that owns the behavior. Do not extract a shared helper just because two flows read the same DTO when their priority order, fallback behavior, or submit semantics differ.
|
||||
- Prefer direct, readable conditionals at the use site for small branch-specific decisions, especially form source selection and request payload assembly. Extract only when the helper name captures a stable domain rule and removes repeated complexity without hiding flow-specific behavior.
|
||||
- When fixing an invalid pattern, scan the touched feature or branch for equivalent patterns and fix them together.
|
||||
- Follow Dify's CSS-first Tailwind v4 contract from `packages/dify-ui/README.md` and `packages/dify-ui/AGENTS.md`. Prefer design-system tokens, utilities, and radius mappings over generic Tailwind guidance.
|
||||
- Search before adding UI, hooks, helpers, query utilities, or styling patterns. Reuse existing base components, feature components, hooks, utilities, and design styles when they fit.
|
||||
- Follow Dify's CSS-first Tailwind v4 contract from `packages/dify-ui/README.md` and `packages/dify-ui/AGENTS.md`. Prefer design-system tokens, utilities, and radius mappings over generic Tailwind choices.
|
||||
- Group feature code by workflow, route, or ownership area with route-aligned names: components, hooks, local types, query helpers, atoms, constants, tests, and small utilities should live near the code that changes with them.
|
||||
- For each feature module, keep a module-local `README.md` as a boundary note. Start with the module name, a brief one-sentence description, then split dependencies into `Internal Modules` and `External Modules` sections; keep both sections and write `None.` when one category is empty. `Internal Modules` lists modules inside the same overall feature using paths from that feature root, such as `shared/domain/runtime-status`; `External Modules` lists project modules outside the feature using paths from the web root without a `web/` prefix, such as `app/components/base/skeleton`. Omit npm packages, workspace package dependencies, and whitelisted plumbing modules. Do not copy caller-relative import paths into the README.
|
||||
- Module README whitelist: `@/service/client`, `@/next/*`.
|
||||
- Keep source/default selection, validation, dirty checks, and payload shaping close to the workflow that owns submit behavior. Do not hide flow-specific priority order, fallback behavior, or submit semantics in generic utilities.
|
||||
- Prefer direct conditionals for small branch-specific decisions, especially form source selection and request payload assembly.
|
||||
- Loading states for page sections, cards, lists, tables, forms, and drawers should be skeletons scoped to the content being loaded. Use spinners only for small inline busy indicators.
|
||||
|
||||
## Feature Workflow Layout
|
||||
## Layout And Ownership
|
||||
|
||||
- State-heavy wizards, drawers, modals, and secondary workflows work best as a small feature surface with route/entry files, a single feature-local state file, and feature-local UI.
|
||||
- Keep `ui/` shallow with owner files that map to the workflow's real composition boundaries and major visual regions.
|
||||
- Owner files contain the section components, field components, skeletons, and one-off helper components that belong to their visual region.
|
||||
- Folders represent groups of related files with a shared owner and a stable reason to change together.
|
||||
- The entry file handles route integration, provider wiring, close behavior, and feature surface mounting. The composition owner handles high-level workflow branching, and the closest visual owner handles section branching.
|
||||
- State-heavy wizards, drawers, modals, and secondary workflows can be a small feature surface: an entry file, one feature-local state file when Jotai is actually needed, and shallow `ui/` owners that match real visual regions.
|
||||
- The entry file handles route integration, provider wiring, close behavior, and surface mounting. The composition owner handles high-level workflow branching. The closest visual owner handles section branching.
|
||||
- When a page or tab maps to a route segment, name its feature folder after that route/tab surface instead of a stale parent grouping. Remove misleading intermediate folders when only one surface remains.
|
||||
- When a tab folder grows into several independent sections or action areas, split the first level by product/visual owners. Keep the root for the entry component and cross-owner state, colocate tests with the owner folder, and put truly shared local UI under a specifically named `components/` file.
|
||||
- Repeated TanStack query calls in sibling components are acceptable when each component independently consumes the data; TanStack Query deduplicates and shares cache.
|
||||
- Pass stable domain identity across boundaries. Do not forward derived presentation state when the receiver can derive it from its own data source.
|
||||
- A component that owns a visual surface should also own data access, loading, empty, and error states for content rendered inside it unless a parent truly coordinates that state.
|
||||
- Avoid prop drilling. One pass-through layer is acceptable; repeated forwarding means ownership should move down or into feature-scoped Jotai UI state. Keep server/cache state in Query and API flow.
|
||||
- Do not replace prop drilling with one large view-model hook threaded through section props. Move each hook, query, derived value, and handler to the concrete section that consumes it.
|
||||
- Keep callbacks in a parent only for workflow coordination such as form submission, shared selection, batch behavior, or navigation. Otherwise let the child, menu, or row own the action.
|
||||
|
||||
## Ownership
|
||||
## Feature-Scoped Jotai
|
||||
|
||||
- Put local state, queries, mutations, handlers, and derived UI data in the lowest component that uses them. Extract a purpose-built owner component only when the logic has no natural home.
|
||||
- Repeated TanStack query calls in sibling components are acceptable when each component independently consumes the data. Do not hoist a query only because it is duplicated; TanStack Query handles deduplication and cache sharing.
|
||||
- Hoist state, queries, or callbacks to a parent only when the parent consumes the data, coordinates shared loading/error/empty UI, needs one consistent snapshot, or owns a workflow spanning children.
|
||||
- Pass stable domain identity across boundaries; avoid forwarding derived presentation state when the receiver can derive it from its own data source. A component that owns a visual surface should also own the data access, loading, empty, and error states for content rendered inside it unless a parent truly coordinates that state.
|
||||
- Loading states for visual surfaces should use skeleton placeholders scoped to the content that is actually loading, with shape, density, and dimensions close to the final UI. Avoid generic loading text or centered spinners for page sections, cards, lists, tables, forms, and drawers; reserve spinners for small inline busy indicators such as an in-progress status icon.
|
||||
- Avoid prop drilling. One pass-through layer is acceptable; repeated forwarding means ownership should move down or into feature-scoped Jotai UI state. Keep server/cache state in query and API data flow.
|
||||
- Do not replace prop drilling with one top-level hook that returns a large view model and then thread that object through section props. Move each hook, query, derived value, and handler to the concrete section that consumes it, or use feature-scoped Jotai atoms for simple shared form/UI state when siblings need the same source of truth.
|
||||
- When using feature-scoped Jotai state for a form, drawer, or other secondary surface, scope the store to that surface instance when stale cross-instance state is possible. Initialize stable config at the owning boundary, then let descendants read only the atoms or purpose-named hooks they actually need.
|
||||
- For Jotai-backed surfaces, put shared query atoms, mutation atoms, derived state, and write actions in the feature state file when they coordinate multiple descendants. The lowest-owner rule still applies to independent visual surfaces that do not participate in shared state.
|
||||
- For repeated row/menu action surfaces that need reset, hydrate the stable identity at the surface entry and scope only the primitives that truly need per-instance reset, such as open flags, drafts, or selected local options.
|
||||
- Keep callbacks in a parent only for workflow coordination such as form submission, shared selection, batch behavior, or navigation. Otherwise let the child or row own its action.
|
||||
- Prefer uncontrolled DOM state and CSS variables before adding controlled props.
|
||||
|
||||
## Feature-Scoped Jotai State
|
||||
|
||||
- A module's feature-local state lives in one state file for Jotai-backed features: primitive atoms, query atoms, derived atoms, write-only action atoms, mutation atoms, submission orchestration, provider exports, and optional scope configuration.
|
||||
- Keep state local when one component owns it, even inside Jotai-backed features. Dialog open flags, menu/popover visibility, confirmation visibility, form/input drafts, row-local pending flags, and in-flight refs usually belong in component state.
|
||||
- Promote UI state to an atom only when siblings need the same source of truth, the value drives a query or mutation atom, a parent workflow coordinates the state, or the state intentionally persists across hidden or unmounted descendants within a scoped surface.
|
||||
- Reflect atom-backed surface-wide locks or invariants in every affected trigger. If only one row, menu, or dialog should be disabled, keep the pending or lock state local to that row, menu, or dialog.
|
||||
- Atom order in the state file follows the dependency graph: types/constants, editable primitives, query atoms, query-data derived atoms, readiness/business derived atoms, write actions, mutation atoms, submission orchestration, provider exports.
|
||||
- Derived atom names read as business facts. Write atom names read as user or workflow commands.
|
||||
- UI components read and write the exact atom they use with `useAtomValue` or `useSetAtom`. Repeated workflow semantics live in named derived atoms or write atoms.
|
||||
- Non-query derived atoms return a narrow value with a clear domain name; avoid pass-through aliases or bundling unrelated UI facts. Query atoms expose the TanStack Query result object so loading, error, fetch, and pagination state stay attached to the query contract.
|
||||
- Write-only atoms own synchronous state transitions that update multiple primitives, reset dependent state, or advance the workflow. Async work with loading, error, caching, retry, or stale-result concerns should be modeled as query or mutation atoms, with write atoms only changing the inputs that drive them.
|
||||
- Avoid feature hooks that aggregate form values, query results, derived state, and commands for sibling components. Prefer named derived atoms and write atoms so UI components read the exact shared fact or command they need.
|
||||
- When a form library owns validation, keep submit orchestration in feature state when post-submit result or error state is shared by the surface. Avoid duplicating validation gates or request shaping in UI hooks.
|
||||
- `jotai-tanstack-query` atoms use the same QueryClient as the React Query provider. Query atoms belong in feature state when atoms are the feature's local state surface.
|
||||
- Jotai scope is an optional instance-isolation tool for secondary surfaces with independent local state. Query and mutation atoms keep shared cache behavior through the shared QueryClient.
|
||||
- Do not put `atomWithQuery`, `atomWithInfiniteQuery`, `atomWithMutation`, or broad derived orchestration atoms in a `ScopeProvider` just to reset a surface. Scoped derived atoms implicitly scope their dependencies, which can duplicate query client access and break shared invalidation. Leave query/mutation atoms unscoped; let them read scoped primitive inputs.
|
||||
- Scope providers should list resettable primitive atoms and explicit hydration tuples. If a derived atom must be scoped, confirm that every dependency it implicitly scopes is meant to be private to that surface.
|
||||
- Keep independent dialog lifecycles separate. Avoid a single discriminated "current action dialog" atom when edit, delete, and other dialogs have their own open state, loading guard, or reset behavior.
|
||||
- Route-derived stable identities that do not need instance reset or scoped isolation can be hydrated at the route or layout boundary into a feature route atom. Use scoped atoms only when stale cross-instance state or per-surface reset semantics are needed.
|
||||
- A Jotai-backed feature has one feature-local state file for shared primitive atoms, query atoms, derived atoms, write-only actions, mutation atoms, submission orchestration, provider exports, and optional scope configuration.
|
||||
- Keep component-owned synchronous UI state local even inside Jotai features: dialog open flags, menus/popovers, confirmations, field drafts, and selected local options usually belong in component state.
|
||||
- Use uncontrolled `@langgenius/dify-ui/form` and `@langgenius/dify-ui/field` controls for edit/create forms whose fields are read only at submit time. Initialize query-backed defaults with `defaultValue` and keyed remounts.
|
||||
- Promote form state to atoms only when another component must react to in-progress values, a draft must survive unmount/remount in the scoped workflow, or multiple steps share the same editable draft before submit.
|
||||
- Treat `useParams`, route args, and `nuqs` query state as framework-owned state. When atom logic needs those values, hydrate primitive atoms at the route or surface boundary, such as with `useHydrateAtoms(..., { dangerouslyForceHydrate: true })`; keep URL updates in the route/query-state APIs instead of write atoms.
|
||||
- Within a route-owned feature, choose one source for route identity. If route params are bridged into feature atoms, use that bridge consistently for route-derived queries and actions instead of also threading the same route id through page, tab, and section props.
|
||||
- For async work tied to atom state, use `atomWithQuery` or `atomWithMutation`; write atoms should update only the inputs that drive those atoms. This applies to pure frontend async work as well as network requests, so do not hand-roll loading/error/in-flight state with `useState` or `useRef` for atom-orchestrated async behavior. For component-owned remote work, use `useQuery` or `useMutation` directly.
|
||||
- Row-local async state belongs to the row owner unless it participates in a shared Jotai workflow or needs atom-scoped reset semantics.
|
||||
- Leave query and mutation atoms unscoped so they keep shared QueryClient cache and invalidation behavior. Scope resettable primitives and explicit hydration tuples; scope a derived atom only when every dependency should be private to that surface.
|
||||
- For scoped primitives that are always hydrated by `ScopeProvider`, prefer `atomWithLazy<T>(() => { throw new Error(...) })` when consumers should see a non-null type.
|
||||
- Order state files by dependency graph: types/constants, primitives, query atoms, query-data derived atoms, business/readiness derived atoms, write actions, mutation atoms, submission orchestration, provider exports.
|
||||
- Name derived atoms as business facts and write atoms as user or workflow commands. Components should read or write the exact atom they need with `useAtomValue` or `useSetAtom`.
|
||||
- Menu/dialog `open` state usually stays local, but a scoped atom is acceptable when a composed menu plus secondary surface would otherwise pass confusing `open`/`onClose` props through unrelated layers. Scope that primitive with the surface instance so reset behavior stays local.
|
||||
- Keep independent dialog lifecycles separate. Avoid one discriminated "current action dialog" atom when dialogs have separate open state, loading guards, or reset behavior.
|
||||
|
||||
## Components, Props, And Types
|
||||
|
||||
- Type component signatures directly; do not use `FC` or `React.FC`.
|
||||
- Prefer `function` for top-level components and module helpers. Use arrow functions for local callbacks, handlers, and lambda-style APIs.
|
||||
- Prefer named exports. Use default exports only where the framework requires them, such as Next.js route files.
|
||||
- Avoid barrel files that only re-export secondary owners. `index.tsx` is acceptable for a route/tab entry component; import header controls, switches, sections, and row owners from their concrete owner files.
|
||||
- Type simple one-off props inline. Use a named `Props` type only when reused, exported, complex, or clearer.
|
||||
- Use API-generated or API-returned types at component boundaries. Keep small UI conversion helpers and one-off UI extensions beside the component that needs them.
|
||||
- Do not create type aliases that only rename another type. Use an alias only when it encodes a real UI concept, refinement, or reusable local contract.
|
||||
- Name values by their domain role and backend API contract, and keep that name stable across the call chain, especially persistent IDs and route params. Normalize framework or route params at the boundary.
|
||||
- Keep fallback and invariant checks at the lowest component that already handles that state; avoid defensive fallbacks that mask impossible states.
|
||||
- Do not extract fallback helpers whose only behavior is hiding missing display data. The component that renders the surface owns the empty, disabled, hidden, or placeholder state.
|
||||
- Avoid `common.tsx` buckets for shared UI. Use a feature-local `components/` folder with concrete filenames that describe the shared role.
|
||||
- Do not create type aliases that only rename another type. Use aliases only for real UI concepts, refinements, or reusable local contracts.
|
||||
- Name values by their domain role and backend API contract, especially persistent IDs and route params. Normalize framework or route params at the boundary.
|
||||
- Put fallback and invariant checks in the lowest component that already handles that state. Do not extract helpers whose only behavior is hiding missing display data.
|
||||
|
||||
## Generated API Contracts
|
||||
## Generated API And Nullable Data
|
||||
|
||||
- Treat generated contracts as authoritative at API, query, mutation, cache, and service boundaries. For enterprise APIs, use `packages/contracts/generated/enterprise/*`.
|
||||
- Do not hand-write local request/response/reply/page/cache-data types that mirror generated DTOs. Import or infer the generated type.
|
||||
- Do not widen generated fields or enums for compatibility. Normalize legacy input at the boundary, then return the generated field type.
|
||||
- Do not repair generated or API-returned contract fields in components unless the API contract or product requirement says they need normalization. Treat enums, statuses, and presence flags as exact contract values.
|
||||
- Use generated enum objects and union types directly in props, comparisons, status logic, and i18n keys. Do not add local enum constants or parallel frontend enum/status layers unless they model real product state not represented by the API. Presentation-only tone maps should be keyed by the generated enum.
|
||||
- Normalize or coerce only at a real boundary, such as user-entered forms, search, URL/query params, file names, DOM IDs, or legacy adapters. Preserve user-entered values when whitespace or formatting can be meaningful.
|
||||
- Do not coerce nullable or optional API strings to `''` in query, derived model, or payload-building code. Keep `undefined` or `null` until the final boundary that requires a string.
|
||||
- Do not use `value || undefined` for mutation payload fields where an empty string means "clear this value". Trim or normalize at the form boundary, then preserve `''` when the API contract treats it as an intentional update.
|
||||
- Local UI models are fine for presentation, form state, select options, or guarded required-field refinements. Name them as UI concepts, not generated DTO mirrors.
|
||||
- Required-value refinements are allowed only after same-branch filtering or early return. Prefer nullable-tolerant props for render-only data.
|
||||
- When a component needs a stricter shape than a generated DTO, refine once at the API/query-to-UI boundary into a purpose-named UI type instead of hiding missing fields with generic fallback or coercion helpers.
|
||||
|
||||
## Nullable API Data
|
||||
|
||||
- Prefer nullable-tolerant call boundaries. Pass API-returned types through for render-only rows, and let the component render fallback, disabled state, or nothing.
|
||||
- Narrow only where a real value is required, such as mutation params, route hrefs, select values, or query input. Build that target model with `flatMap`, a local loop, or an early return so the required value is captured in the same branch.
|
||||
- If design says a field is the display value, use that field. Only the final component should decide whether a nullable display value renders a placeholder, hides content, or disables an action.
|
||||
- Do not wrap required arrays or fields in null-fallback helpers. Use empty collection fallbacks only for not-yet-loaded query data or genuinely nullable collections at the owning render boundary.
|
||||
- Do not drop rows only to satisfy props or React keys; use a stable fallback key when possible.
|
||||
- Use conditional spreads or explicit pushes for conditional array items instead of `undefined` placeholders followed by a narrowing filter.
|
||||
- Avoid truthiness type guards, `filter(Boolean)`, `filter(item => item.id)`, and `!` after those filters.
|
||||
- Use type guards only for meaningful domain or runtime validation, such as enum membership, object shape, or a reusable business invariant.
|
||||
- Do not hand-write DTO mirrors, widen generated fields/enums, or add parallel frontend enum/status layers unless they model product state not represented by the API.
|
||||
- Use generated enum objects and union types directly in props, comparisons, status logic, and i18n keys. Presentation-only tone maps should be keyed by generated enums.
|
||||
- Normalize or coerce only at real boundaries: user-entered forms, search, URL/query params, file names, DOM IDs, or legacy adapters.
|
||||
- Do not coerce nullable or optional API strings to `''` in query, derived model, or payload-building code. Keep `null` or `undefined` until the final boundary requiring a string.
|
||||
- Do not use `value || undefined` for mutation fields where `''` means "clear this value". Trim or normalize at the form boundary, then preserve intentional empty strings.
|
||||
- Prefer nullable-tolerant render props for API-returned rows. Narrow only where a real value is required, such as mutation params, route hrefs, select values, query input, or required React keys.
|
||||
- Build required values in the same branch that proves them, using `flatMap`, a local loop, or an early return. Avoid truthiness guards, `filter(Boolean)`, `filter(item => item.id)`, and `!` after filters.
|
||||
- Use conditional spreads or explicit pushes for conditional array items instead of `undefined` placeholders followed by narrowing filters.
|
||||
- Empty collection fallbacks are for not-yet-loaded query data or genuinely nullable collections at the owning render boundary, not for hiding required API fields.
|
||||
|
||||
## Queries And Mutations
|
||||
|
||||
- Keep `web/contract/*` as the single source of truth for API shape; follow existing domain/router patterns and the `{ params, query?, body? }` input shape.
|
||||
- Consume queries directly with `useQuery(consoleQuery.xxx.queryOptions(...))` or `useQuery(marketplaceQuery.xxx.queryOptions(...))`.
|
||||
- In `atomWithQuery` and `atomWithInfiniteQuery`, return generated `queryOptions()` or `infiniteOptions()` directly. Pass `enabled`, `retry`, `placeholderData`, `select`, and pagination options into that call instead of spreading generated options into a hand-built object.
|
||||
- In `atomWithMutation`, return generated `mutationOptions()` directly when using generated clients. Put request shaping and submit orchestration in write atoms; do not rebuild mutation option objects just to pass through the generated mutation function.
|
||||
- For custom query functions that do not come from generated clients, wrap the options object with TanStack `queryOptions(...)` so query atoms still return a query options contract.
|
||||
- Avoid pass-through hooks and thin `web/service/use-*` wrappers that only rename `queryOptions()` or `mutationOptions()`. Extract a small `queryOptions` helper only when repeated call-site options justify it.
|
||||
- Keep feature hooks for real orchestration, workflow state, or shared domain behavior.
|
||||
- For TanStack cache data, use generated or query-derived types; do not create local wrappers for `getQueryData` or `getQueriesData`.
|
||||
- For generated oRPC `queryOptions()` / `infiniteOptions()`, keep returning the generated options directly. When required input is missing, use a whole-input branch such as `input: condition ? validInput : skipToken` together with `enabled: Boolean(condition)` so no request runs and no fake payload is built.
|
||||
- Do not put `skipToken` inside a nested placeholder payload, such as `{ params: { appInstanceId: skipToken } }`. Do not create hand-written "missing queryOptions" objects or coerce required IDs to `''`.
|
||||
- Consume mutations directly with `useMutation(consoleQuery.xxx.mutationOptions(...))` or `useMutation(marketplaceQuery.xxx.mutationOptions(...))`; use oRPC clients as `mutationFn` only for custom flows.
|
||||
- Put shared cache behavior in `createTanstackQueryUtils(...experimental_defaults...)`; components may add UI feedback callbacks, but should not own shared invalidation rules.
|
||||
- Component or atom mutation callbacks can handle local UI feedback such as toasts, closing dialogs, or navigation. They should not replace shared invalidation or add local cache patches for shared server state.
|
||||
- Keep `web/contract/*` as the API shape source of truth and follow the `{ params, query?, body? }` input shape.
|
||||
- Consume generated queries with `useQuery(consoleQuery.xxx.queryOptions(...))` or `useQuery(marketplaceQuery.xxx.queryOptions(...))`.
|
||||
- If a generated query input comes from an atom, including a route-identity bridge atom, keep the query in `atomWithQuery`; do not unwrap the atom in a component just to call `useQuery`.
|
||||
- Consume owner-local mutations with `useMutation(consoleQuery.xxx.mutationOptions(...))` or `useMutation(marketplaceQuery.xxx.mutationOptions(...))` when pending/error state is not consumed by feature atoms.
|
||||
- In `atomWithQuery`, `atomWithInfiniteQuery`, and `atomWithMutation`, return generated `queryOptions()`, `infiniteOptions()`, or `mutationOptions()` directly. Pass `enabled`, `retry`, `placeholderData`, `select`, and pagination options into the generated call instead of spreading options into a hand-built object.
|
||||
- For generated oRPC options with missing required input, branch the whole input with `input: condition ? validInput : skipToken` and `enabled: Boolean(condition)`. Never place `skipToken` inside a nested placeholder payload or coerce required IDs to `''`.
|
||||
- When prefetch and render use the same request, extract local query options or a query-options atom so `prefetchQuery` and `useQuery`/`atomWithQuery` share the exact options.
|
||||
- For custom query or mutation functions, wrap options with TanStack `queryOptions(...)` or `mutationOptions(...)`.
|
||||
- Do not extract generated `queryOptions(...)` into a helper solely to share input construction; extract only when prefetch/render must share exact options or the helper owns real domain behavior.
|
||||
- Avoid pass-through hooks and thin `web/service/use-*` wrappers that only rename generated options. Keep feature hooks for real orchestration, workflow state, or shared domain behavior.
|
||||
- Put shared cache behavior in `createTanstackQueryUtils(...experimental_defaults...)`. Component or atom callbacks may handle local toasts, closing dialogs, and navigation, but should not replace shared invalidation or patch shared server state locally.
|
||||
- For overlays that may open heavier secondary content, prefetch from the trigger/menu open event with `queryClient.prefetchQuery(queryOptions)` when `onOpenChange` is available. Do not mount hidden subscribers just to warm cache.
|
||||
- Do not use deprecated `useInvalid` or `useReset`.
|
||||
- Prefer `mutate(...)`; use `mutateAsync(...)` only when Promise semantics are required, and wrap awaited calls in `try/catch`.
|
||||
|
||||
## Component Boundaries
|
||||
## Boundaries And Overlays
|
||||
|
||||
- Use the first level below a page or tab to organize independent page sections when it adds real structure. This layer is layout/semantic first, not automatically the data owner.
|
||||
- Treat component names, semantic roles, and user- or design-marked visual regions as boundary constraints. Do not expand a child component's responsibility just because its data is useful nearby; keep adjacent UI as a sibling owner or introduce a correctly named broader owner.
|
||||
- Split deeper components by the data and state each layer actually needs. Each component should access only necessary data, and ownership should stay at the lowest consumer.
|
||||
- Use the first level below a page or tab to organize independent page sections when it adds structure or the root folder becomes noisy. This layer is layout/semantic first, not automatically the data owner.
|
||||
- Treat component names, semantic roles, and user- or design-marked visual regions as boundary constraints. Keep adjacent UI as a sibling owner or introduce a correctly named broader owner.
|
||||
- Keep cohesive forms, menu bodies, and one-off helpers local unless they need their own state, reuse, or semantic boundary.
|
||||
- Separate hidden secondary surfaces from the trigger's main flow. For dialogs, dropdowns, popovers, and similar branches, extract a small local component that owns the trigger, open state, and hidden content when it would obscure the parent flow.
|
||||
- Preserve composability by separating behavior ownership from layout ownership. A dropdown action may own its trigger, open state, and menu content; the caller owns placement such as slots, offsets, and alignment.
|
||||
- When a dialog, dropdown, or popover component already accepts controlled `open` state, mount the surface unconditionally unless unmounting is required for performance or reset semantics. Use keyed scope or local state reset for reset behavior instead of `{open && <Surface />}` wrappers.
|
||||
- Avoid unnecessary DOM hierarchy. Do not add wrapper elements unless they provide layout, semantics, accessibility, state ownership, or integration with a library API; prefer fragments or styling an existing element when possible.
|
||||
- Avoid shallow wrappers, hook-to-props adapter components, layout-only render-prop wrappers, children-as-pass-through composition, and prop renaming unless the wrapper adds validation, orchestration, error handling, state ownership, or a real semantic boundary. If a component only calls a hook, forwards props, or passes trigger/content through to one child, move the logic into that child or make the wrapper own a real surface.
|
||||
- Separate hidden secondary surfaces from the trigger's main flow. For dialogs, dropdowns, popovers, and similar branches, extract a small local component when hidden content would obscure the parent.
|
||||
- Preserve composability by separating behavior ownership from placement ownership: an action can own trigger/open/menu content while the caller owns slots, offsets, and alignment.
|
||||
- When a dialog, dropdown, or popover accepts controlled `open`, mount it unconditionally unless unmounting is required for performance or reset semantics. Use keyed scope or local state reset instead of `{open && <Surface />}` wrappers.
|
||||
- When opening a dialog from a menu item, keep the menu and dialog as sibling surfaces. Let the menu command open the dialog, and mount the dialog outside menu popup content.
|
||||
- For dialogs and alert dialogs, keep the root responsible for `open` wiring and put query/mutation hooks inside the content component when work should mount only after the overlay opens.
|
||||
- Prefer uncontrolled overlay roots when the library can own open state. Use `onOpenChange` for side effects and CSS/data selectors for open-state styling.
|
||||
- Avoid wrapper DOM unless it provides layout, semantics, accessibility, state ownership, or library integration. Avoid shallow wrappers, hook-to-props adapters, layout-only render props, children pass-through wrappers, and prop renaming unless they add real behavior or a real boundary.
|
||||
|
||||
## You Might Not Need An Effect
|
||||
|
||||
- Use Effects only to synchronize with external systems such as browser APIs, non-React widgets, subscriptions, timers, analytics that must run because the component was shown, or imperative DOM integration.
|
||||
- Do not use Effects to transform props or state for rendering. Calculate derived values during render, and use `useMemo` only when the calculation is actually expensive.
|
||||
- Do not use Effects to handle user actions. Put action-specific logic in the event handler where the cause is known.
|
||||
- Do not use Effects to copy one state value into another state value representing the same concept. Pick one source of truth and derive the rest during render.
|
||||
- Do not reset or adjust state from props with an Effect. Prefer a `key` reset, storing a stable ID and deriving the selected object, or guarded same-component render-time adjustment when truly necessary.
|
||||
- For forms initialized from query data, prefer keyed remounts or surface-entry hydration of form/field atoms over an Effect that copies query data into form state.
|
||||
- Prefer framework data APIs or TanStack Query for data fetching instead of writing request Effects in components.
|
||||
- If an Effect still seems necessary, first name the external system it synchronizes with. If there is no external system, remove the Effect and restructure the state or event flow.
|
||||
|
||||
## Navigation And Performance
|
||||
## Effects, Navigation, And Performance
|
||||
|
||||
- Use Effects only to synchronize with external systems. Do not use Effects to transform props/state for rendering, handle user actions, copy state, reset state from props, or fetch data.
|
||||
- For forms initialized from query data, prefer keyed remounts or surface-entry atom hydration over Effects that copy query data into form state.
|
||||
- Prefer framework data APIs or TanStack Query for data fetching.
|
||||
- Prefer `Link` for normal navigation. Use router APIs only for command-flow side effects such as mutation success, guarded redirects, or form submission.
|
||||
- Before reaching for `memo`, first try moving changing state down to the smallest component that actually uses it so unrelated sibling trees stay untouched.
|
||||
- If changing state must wrap other content, lift the unchanged content up and pass it as `children` so the stateful wrapper can update without React visiting that subtree.
|
||||
- Before using `memo`, move changing state down to the smallest component that uses it. If state must wrap stable content, lift the stable content up and pass it as `children`.
|
||||
- Avoid `memo`, `useMemo`, and `useCallback` unless there is a clear performance reason.
|
||||
|
||||
@@ -21,6 +21,7 @@ env:
|
||||
DIFY_WEB_IMAGE_NAME: ${{ vars.DIFY_WEB_IMAGE_NAME || 'langgenius/dify-web' }}
|
||||
DIFY_API_IMAGE_NAME: ${{ vars.DIFY_API_IMAGE_NAME || 'langgenius/dify-api' }}
|
||||
DIFY_AGENT_IMAGE_NAME: ${{ vars.DIFY_AGENT_IMAGE_NAME || 'langgenius/dify-agent-backend' }}
|
||||
DIFY_AGENT_LOCAL_SANDBOX_IMAGE_NAME: ${{ vars.DIFY_AGENT_LOCAL_SANDBOX_IMAGE_NAME || 'langgenius/dify-agent-local-sandbox' }}
|
||||
|
||||
jobs:
|
||||
build:
|
||||
@@ -74,6 +75,20 @@ jobs:
|
||||
file: "dify-agent/Dockerfile"
|
||||
platform: linux/arm64
|
||||
runs_on: depot-ubuntu-24.04-4
|
||||
- service_name: "build-agent-local-sandbox-amd64"
|
||||
image_name_env: "DIFY_AGENT_LOCAL_SANDBOX_IMAGE_NAME"
|
||||
artifact_context: "local-sandbox"
|
||||
build_context: "{{defaultContext}}:dify-agent"
|
||||
file: "docker/local-sandbox/Dockerfile"
|
||||
platform: linux/amd64
|
||||
runs_on: depot-ubuntu-24.04-4
|
||||
- service_name: "build-agent-local-sandbox-arm64"
|
||||
image_name_env: "DIFY_AGENT_LOCAL_SANDBOX_IMAGE_NAME"
|
||||
artifact_context: "local-sandbox"
|
||||
build_context: "{{defaultContext}}:dify-agent"
|
||||
file: "docker/local-sandbox/Dockerfile"
|
||||
platform: linux/arm64
|
||||
runs_on: depot-ubuntu-24.04-4
|
||||
|
||||
steps:
|
||||
- name: Prepare
|
||||
@@ -139,6 +154,9 @@ jobs:
|
||||
- service_name: "validate-agent-amd64"
|
||||
build_context: "{{defaultContext}}"
|
||||
file: "dify-agent/Dockerfile"
|
||||
- service_name: "validate-agent-local-sandbox-amd64"
|
||||
build_context: "{{defaultContext}}:dify-agent"
|
||||
file: "docker/local-sandbox/Dockerfile"
|
||||
steps:
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
||||
@@ -167,6 +185,9 @@ jobs:
|
||||
- service_name: "merge-agent-images"
|
||||
image_name_env: "DIFY_AGENT_IMAGE_NAME"
|
||||
context: "agent"
|
||||
- service_name: "merge-agent-local-sandbox-images"
|
||||
image_name_env: "DIFY_AGENT_LOCAL_SANDBOX_IMAGE_NAME"
|
||||
context: "local-sandbox"
|
||||
steps:
|
||||
- name: Download digests
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
|
||||
@@ -53,6 +53,8 @@ jobs:
|
||||
|
||||
- name: Run Type Checks
|
||||
if: steps.changed-files.outputs.any_changed == 'true'
|
||||
env:
|
||||
PYREFLY_OUTPUT_FORMAT: github
|
||||
run: make type-check-core
|
||||
|
||||
- name: Dotenv check
|
||||
@@ -105,6 +107,14 @@ jobs:
|
||||
if: steps.changed-files.outputs.any_changed == 'true'
|
||||
run: vp run knip
|
||||
|
||||
- name: Web dead code check production
|
||||
if: steps.changed-files.outputs.any_changed == 'true'
|
||||
run: vp run knip:production
|
||||
|
||||
- name: Web production unused declarations check
|
||||
if: steps.changed-files.outputs.any_changed == 'true'
|
||||
run: vp run knip:production-unused-check
|
||||
|
||||
ts-common-style:
|
||||
name: TS Common
|
||||
runs-on: depot-ubuntu-24.04
|
||||
|
||||
@@ -116,7 +116,7 @@ All of Dify's offerings come with corresponding APIs, so you could effortlessly
|
||||
## Using Dify
|
||||
|
||||
- **Cloud <br/>**
|
||||
We host a [Dify Cloud](https://dify.ai) service for anyone to try with zero setup. It provides all the capabilities of the self-deployed version, and includes 200 free GPT-4 calls in the sandbox plan.
|
||||
We host a [Dify Cloud](https://dify.ai) service for anyone to try with zero setup. It provides all the capabilities of the self-deployed version, and includes 200 free GPT-4 calls in the sandbox plan. If you run into issues with Dify Cloud, [contact our Cloud support team](mailto:[email protected]?subject=%5BGitHub%5DDify%20Cloud%20Support).
|
||||
|
||||
- **Self-hosting Dify Community Edition<br/>**
|
||||
Quickly get Dify running in your environment with this [starter guide](#quick-start).
|
||||
|
||||
@@ -36,6 +36,9 @@ FILES_ACCESS_TIMEOUT=300
|
||||
# Collaboration mode toggle
|
||||
ENABLE_COLLABORATION_MODE=true
|
||||
|
||||
# Learn app feature toggle
|
||||
ENABLE_LEARN_APP=true
|
||||
|
||||
# Access token expiration time in minutes
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES=60
|
||||
|
||||
|
||||
@@ -78,6 +78,24 @@ def _filter_snapshot_to_specs(
|
||||
return CompositorSessionSnapshot(schema_version=snapshot.schema_version, layers=filtered_layers)
|
||||
|
||||
|
||||
def _shell_layer_deps() -> dict[str, str]:
|
||||
return {"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID}
|
||||
|
||||
|
||||
def _drive_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})
|
||||
|
||||
|
||||
class AgentBackendModelConfig(BaseModel):
|
||||
"""API-side model/plugin selection before it is converted to Dify Agent layers."""
|
||||
|
||||
@@ -256,13 +274,29 @@ class AgentBackendRunRequestBuilder:
|
||||
]
|
||||
)
|
||||
|
||||
include_shell = run_input.include_shell or run_input.drive_config is not None
|
||||
if include_shell:
|
||||
# Sandboxed bash workspace (dify.shell). It enters before drive so
|
||||
# drive can materialize mentioned targets with `dify-agent drive pull`
|
||||
# in the same shell-visible filesystem used by model commands.
|
||||
layers.append(
|
||||
RunLayerSpec(
|
||||
name=DIFY_SHELL_LAYER_ID,
|
||||
type=DIFY_SHELL_LAYER_TYPE_ID,
|
||||
deps=_shell_layer_deps(),
|
||||
metadata=run_input.metadata,
|
||||
config=_shell_config_with_drive_ref(run_input.shell_config, run_input.drive_config),
|
||||
)
|
||||
)
|
||||
|
||||
if run_input.drive_config is not None:
|
||||
# Drive Skills & Files declaration (dify.drive): a config-only index;
|
||||
# the agent pulls listed entries through the back proxy by drive_ref.
|
||||
# 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,
|
||||
)
|
||||
@@ -304,7 +338,7 @@ class AgentBackendRunRequestBuilder:
|
||||
)
|
||||
)
|
||||
|
||||
if run_input.knowledge is not None and run_input.knowledge.dataset_ids:
|
||||
if run_input.knowledge is not None and run_input.knowledge.sets:
|
||||
layers.append(
|
||||
RunLayerSpec(
|
||||
name=DIFY_KNOWLEDGE_BASE_LAYER_ID,
|
||||
@@ -328,20 +362,6 @@ class AgentBackendRunRequestBuilder:
|
||||
)
|
||||
)
|
||||
|
||||
if run_input.include_shell:
|
||||
# Sandboxed bash workspace (dify.shell). Depends on execution_context so
|
||||
# the agent server can mint per-command Agent Stub env (back proxy);
|
||||
# shellctl connection itself is server-injected.
|
||||
layers.append(
|
||||
RunLayerSpec(
|
||||
name=DIFY_SHELL_LAYER_ID,
|
||||
type=DIFY_SHELL_LAYER_TYPE_ID,
|
||||
deps={"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID},
|
||||
metadata=run_input.metadata,
|
||||
config=run_input.shell_config or DifyShellLayerConfig(),
|
||||
)
|
||||
)
|
||||
|
||||
if run_input.output is not None:
|
||||
layers.append(
|
||||
RunLayerSpec(
|
||||
@@ -436,7 +456,7 @@ class AgentBackendRunRequestBuilder:
|
||||
name=WORKFLOW_NODE_JOB_PROMPT_LAYER_ID,
|
||||
type=PLAIN_PROMPT_LAYER_TYPE_ID,
|
||||
metadata={**run_input.metadata, "origin": "workflow_node_job"},
|
||||
config=PromptLayerConfig(prefix=run_input.workflow_node_job_prompt),
|
||||
config=PromptLayerConfig(user=run_input.workflow_node_job_prompt),
|
||||
),
|
||||
RunLayerSpec(
|
||||
name=WORKFLOW_USER_PROMPT_LAYER_ID,
|
||||
@@ -453,13 +473,29 @@ class AgentBackendRunRequestBuilder:
|
||||
]
|
||||
)
|
||||
|
||||
include_shell = run_input.include_shell or run_input.drive_config is not None
|
||||
if include_shell:
|
||||
# Sandboxed bash workspace (dify.shell). It enters before drive so
|
||||
# drive can materialize mentioned targets with `dify-agent drive pull`
|
||||
# in the same shell-visible filesystem used by model commands.
|
||||
layers.append(
|
||||
RunLayerSpec(
|
||||
name=DIFY_SHELL_LAYER_ID,
|
||||
type=DIFY_SHELL_LAYER_TYPE_ID,
|
||||
deps=_shell_layer_deps(),
|
||||
metadata=run_input.metadata,
|
||||
config=_shell_config_with_drive_ref(run_input.shell_config, run_input.drive_config),
|
||||
)
|
||||
)
|
||||
|
||||
if run_input.drive_config is not None:
|
||||
# Drive Skills & Files declaration (dify.drive): a config-only index;
|
||||
# the agent pulls listed entries through the back proxy by drive_ref.
|
||||
# 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,
|
||||
)
|
||||
@@ -503,7 +539,7 @@ class AgentBackendRunRequestBuilder:
|
||||
)
|
||||
)
|
||||
|
||||
if run_input.knowledge is not None and run_input.knowledge.dataset_ids:
|
||||
if run_input.knowledge is not None and run_input.knowledge.sets:
|
||||
layers.append(
|
||||
RunLayerSpec(
|
||||
name=DIFY_KNOWLEDGE_BASE_LAYER_ID,
|
||||
@@ -527,20 +563,6 @@ class AgentBackendRunRequestBuilder:
|
||||
)
|
||||
)
|
||||
|
||||
if run_input.include_shell:
|
||||
# Sandboxed bash workspace (dify.shell). Depends on execution_context so
|
||||
# the agent server can mint per-command Agent Stub env (back proxy);
|
||||
# shellctl connection itself is server-injected.
|
||||
layers.append(
|
||||
RunLayerSpec(
|
||||
name=DIFY_SHELL_LAYER_ID,
|
||||
type=DIFY_SHELL_LAYER_TYPE_ID,
|
||||
deps={"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID},
|
||||
metadata=run_input.metadata,
|
||||
config=run_input.shell_config or DifyShellLayerConfig(),
|
||||
)
|
||||
)
|
||||
|
||||
if run_input.output is not None:
|
||||
layers.append(
|
||||
RunLayerSpec(
|
||||
|
||||
@@ -25,7 +25,7 @@ def reset_password(email, new_password, password_confirm):
|
||||
return
|
||||
normalized_email = email.strip().lower()
|
||||
|
||||
account = AccountService.get_account_by_email_with_case_fallback(email.strip())
|
||||
account = AccountService.get_account_by_email_with_case_fallback(db.session, email.strip())
|
||||
|
||||
if not account:
|
||||
click.echo(click.style(f"Account not found for email: {email}", fg="red"))
|
||||
@@ -67,7 +67,7 @@ def reset_email(email, new_email, email_confirm):
|
||||
return
|
||||
normalized_new_email = new_email.strip().lower()
|
||||
|
||||
account = AccountService.get_account_by_email_with_case_fallback(email.strip())
|
||||
account = AccountService.get_account_by_email_with_case_fallback(db.session, email.strip())
|
||||
|
||||
if not account:
|
||||
click.echo(click.style(f"Account not found for email: {email}", fg="red"))
|
||||
@@ -133,8 +133,9 @@ def create_tenant(email: str, language: str | None = None, name: str | None = No
|
||||
password=new_password,
|
||||
language=language,
|
||||
create_workspace_required=False,
|
||||
session=db.session,
|
||||
)
|
||||
TenantService.create_owner_tenant_if_not_exist(account, name)
|
||||
TenantService.create_owner_tenant_if_not_exist(account, name, session=db.session)
|
||||
|
||||
click.echo(
|
||||
click.style(
|
||||
|
||||
+217
-69
@@ -1,12 +1,52 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterator
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
|
||||
import click
|
||||
from sqlalchemy import select
|
||||
|
||||
from configs import dify_config
|
||||
from core.db.session_factory import session_factory
|
||||
from models import TenantAccountJoin, TenantAccountRole
|
||||
from services.enterprise.rbac_service import ListOption, RBACService
|
||||
|
||||
_LEGACY_ROLE_TO_BUILTIN_TAG = {
|
||||
TenantAccountRole.OWNER.value: "owner",
|
||||
TenantAccountRole.ADMIN.value: "admin",
|
||||
TenantAccountRole.EDITOR.value: "editor",
|
||||
TenantAccountRole.NORMAL.value: "normal",
|
||||
TenantAccountRole.DATASET_OPERATOR.value: "dataset_operator",
|
||||
}
|
||||
|
||||
|
||||
def _resolve_builtin_role_ids(tenant_id: str, operator_account_id: str) -> dict[str, str]:
|
||||
"""Resolve every legacy workspace role to the current tenant's builtin RBAC role id.
|
||||
|
||||
The migration replays the old `TenantAccountJoin.role` values onto the
|
||||
RBAC member-role binding API. Builtin RBAC roles are tenant-scoped and
|
||||
identified by runtime ids, so the command must look them up per tenant.
|
||||
"""
|
||||
roles = RBACService.Roles.list(
|
||||
tenant_id=tenant_id,
|
||||
account_id=operator_account_id,
|
||||
options=ListOption(page_number=1, results_per_page=100),
|
||||
).data
|
||||
role_id_by_tag = {
|
||||
role.role_tag: role.id
|
||||
for role in roles
|
||||
if role.is_builtin and role.category == "global_system_default" and role.role_tag
|
||||
}
|
||||
resolved: dict[str, str] = {}
|
||||
for legacy_role, expected_builtin_tag in _LEGACY_ROLE_TO_BUILTIN_TAG.items():
|
||||
role_id = role_id_by_tag.get(expected_builtin_tag)
|
||||
if expected_builtin_tag == "dataset_operator" and not dify_config.DATASET_OPERATOR_ENABLED:
|
||||
continue
|
||||
if not role_id:
|
||||
raise ValueError(f"Builtin RBAC role not found for tenant={tenant_id}, legacy_role={legacy_role}")
|
||||
resolved[legacy_role] = role_id
|
||||
return resolved
|
||||
|
||||
|
||||
def _resolve_builtin_role_id(tenant_id: str, operator_account_id: str, legacy_role: str) -> str:
|
||||
"""Resolve a legacy workspace role to the current tenant's builtin RBAC role id.
|
||||
@@ -15,26 +55,86 @@ def _resolve_builtin_role_id(tenant_id: str, operator_account_id: str, legacy_ro
|
||||
RBAC member-role binding API. Builtin RBAC roles are tenant-scoped and
|
||||
identified by runtime ids, so the command must look them up per tenant.
|
||||
"""
|
||||
expected_builtin_tag = {
|
||||
TenantAccountRole.OWNER.value: "owner",
|
||||
TenantAccountRole.ADMIN.value: "admin",
|
||||
TenantAccountRole.EDITOR.value: "editor",
|
||||
TenantAccountRole.NORMAL.value: "normal",
|
||||
TenantAccountRole.DATASET_OPERATOR.value: "dataset_operator",
|
||||
}.get(legacy_role)
|
||||
if not expected_builtin_tag:
|
||||
if legacy_role not in _LEGACY_ROLE_TO_BUILTIN_TAG:
|
||||
raise ValueError(f"Unsupported legacy workspace role: {legacy_role}")
|
||||
|
||||
roles = RBACService.Roles.list(
|
||||
return _resolve_builtin_role_ids(tenant_id, operator_account_id)[legacy_role]
|
||||
|
||||
|
||||
def _iter_tenant_member_batches(
|
||||
tenant_id: str | None,
|
||||
*,
|
||||
db_batch_size: int,
|
||||
api_batch_size: int,
|
||||
) -> Iterator[tuple[str, str, list[tuple[str, str]]]]:
|
||||
"""Yield legacy member roles in tenant-scoped API-sized batches.
|
||||
|
||||
Rows are projected to primitive values and streamed from the database, so
|
||||
the command never materializes every TenantAccountJoin ORM object. The
|
||||
iterator only keeps one tenant's API-sized batches in memory while it
|
||||
finds that tenant's owner account.
|
||||
"""
|
||||
with session_factory.create_session() as session:
|
||||
stmt = (
|
||||
select(TenantAccountJoin.tenant_id, TenantAccountJoin.account_id, TenantAccountJoin.role)
|
||||
.order_by(TenantAccountJoin.tenant_id.asc(), TenantAccountJoin.id.asc())
|
||||
.execution_options(yield_per=db_batch_size)
|
||||
)
|
||||
if tenant_id:
|
||||
stmt = stmt.where(TenantAccountJoin.tenant_id == tenant_id)
|
||||
|
||||
current_tenant_id: str | None = None
|
||||
owner_account_id: str | None = None
|
||||
batches: list[list[tuple[str, str]]] = []
|
||||
batch: list[tuple[str, str]] = []
|
||||
|
||||
def flush_current_tenant() -> Iterator[tuple[str, str, list[tuple[str, str]]]]:
|
||||
if current_tenant_id is None:
|
||||
return
|
||||
if batch:
|
||||
batches.append(batch.copy())
|
||||
if not owner_account_id:
|
||||
raise ValueError(f"Workspace owner not found for tenant={current_tenant_id}")
|
||||
for item in batches:
|
||||
yield current_tenant_id, owner_account_id, item
|
||||
|
||||
for row in session.execute(stmt):
|
||||
workspace_id = str(row.tenant_id)
|
||||
if current_tenant_id is not None and workspace_id != current_tenant_id:
|
||||
yield from flush_current_tenant()
|
||||
owner_account_id = None
|
||||
batches = []
|
||||
batch = []
|
||||
current_tenant_id = workspace_id
|
||||
account_id = str(row.account_id)
|
||||
role = str(row.role)
|
||||
if role == TenantAccountRole.OWNER.value:
|
||||
owner_account_id = account_id
|
||||
batch.append((account_id, role))
|
||||
if len(batch) >= api_batch_size:
|
||||
batches.append(batch)
|
||||
batch = []
|
||||
|
||||
yield from flush_current_tenant()
|
||||
|
||||
|
||||
def _member_already_has_role(current_roles_by_account_id: dict[str, set[str]], account_id: str, role_id: str) -> bool:
|
||||
return current_roles_by_account_id.get(account_id) == {role_id}
|
||||
|
||||
|
||||
def _replace_member_role(
|
||||
tenant_id: str,
|
||||
operator_account_id: str,
|
||||
member_account_id: str,
|
||||
role_id: str,
|
||||
) -> str:
|
||||
RBACService.MemberRoles.replace(
|
||||
tenant_id=tenant_id,
|
||||
account_id=operator_account_id,
|
||||
options=ListOption(page_number=1, results_per_page=100),
|
||||
).data
|
||||
for role in roles:
|
||||
if role.is_builtin and role.category == "global_system_default" and role.role_tag == expected_builtin_tag:
|
||||
return str(role.id)
|
||||
|
||||
raise ValueError(f"Builtin RBAC role not found for tenant={tenant_id}, legacy_role={legacy_role}")
|
||||
member_account_id=member_account_id,
|
||||
role_ids=[role_id],
|
||||
)
|
||||
return member_account_id
|
||||
|
||||
|
||||
@click.command(
|
||||
@@ -42,7 +142,16 @@ def _resolve_builtin_role_id(tenant_id: str, operator_account_id: str, legacy_ro
|
||||
)
|
||||
@click.option("--tenant-id", help="Only migrate a single workspace.")
|
||||
@click.option("--dry-run", is_flag=True, default=False, help="Preview the migration without writing RBAC bindings.")
|
||||
def migrate_member_roles_to_rbac(tenant_id: str | None, dry_run: bool) -> None:
|
||||
@click.option("--db-batch-size", default=5000, show_default=True, help="Rows fetched per database batch.")
|
||||
@click.option("--api-batch-size", default=200, show_default=True, help="Members checked per RBAC batch_get call.")
|
||||
@click.option("--workers", default=1, show_default=True, help="Concurrent member role replace calls per tenant batch.")
|
||||
def migrate_member_roles_to_rbac(
|
||||
tenant_id: str | None,
|
||||
dry_run: bool,
|
||||
db_batch_size: int,
|
||||
api_batch_size: int,
|
||||
workers: int,
|
||||
) -> None:
|
||||
"""Backfill RBAC member-role bindings from legacy `TenantAccountJoin.role` data.
|
||||
|
||||
This is an offline migration command for workspaces that already have
|
||||
@@ -50,63 +159,102 @@ def migrate_member_roles_to_rbac(tenant_id: str | None, dry_run: bool) -> None:
|
||||
member-role binding store.
|
||||
"""
|
||||
click.echo(click.style("Starting RBAC member-role migration.", fg="green"))
|
||||
if workers < 1:
|
||||
raise click.BadParameter("workers must be >= 1", param_hint="--workers")
|
||||
|
||||
with session_factory.create_session() as session:
|
||||
stmt = select(TenantAccountJoin).order_by(TenantAccountJoin.tenant_id.asc(), TenantAccountJoin.id.asc())
|
||||
if tenant_id:
|
||||
stmt = stmt.where(TenantAccountJoin.tenant_id == tenant_id)
|
||||
tenant_count = 0
|
||||
scanned_count = 0
|
||||
skipped_count = 0
|
||||
migrated_count = 0
|
||||
current_tenant_id: str | None = None
|
||||
role_ids_by_legacy_role: dict[str, str] = {}
|
||||
|
||||
joins = list(session.scalars(stmt).all())
|
||||
for workspace_id, owner_account_id, batch in _iter_tenant_member_batches(
|
||||
tenant_id,
|
||||
db_batch_size=db_batch_size,
|
||||
api_batch_size=api_batch_size,
|
||||
):
|
||||
scanned_count += len(batch)
|
||||
if workspace_id != current_tenant_id:
|
||||
tenant_count += 1
|
||||
current_tenant_id = workspace_id
|
||||
role_ids_by_legacy_role = _resolve_builtin_role_ids(workspace_id, owner_account_id)
|
||||
click.echo(f"tenant={workspace_id}")
|
||||
|
||||
if not joins:
|
||||
current_roles_by_account_id: dict[str, set[str]] = {}
|
||||
if not dry_run:
|
||||
current_roles = RBACService.MemberRoles.batch_get(
|
||||
tenant_id=workspace_id,
|
||||
account_id=owner_account_id,
|
||||
member_account_ids=[account_id for account_id, _ in batch],
|
||||
)
|
||||
current_roles_by_account_id = {
|
||||
item.account_id: {str(role.id) for role in item.roles} for item in current_roles
|
||||
}
|
||||
|
||||
replace_jobs: list[tuple[str, str]] = []
|
||||
for member_account_id, legacy_role in batch:
|
||||
resolved_role_id = role_ids_by_legacy_role.get(legacy_role)
|
||||
if not resolved_role_id:
|
||||
raise ValueError(f"Unsupported legacy workspace role: {legacy_role}")
|
||||
|
||||
if dry_run:
|
||||
click.echo(
|
||||
f"tenant={workspace_id} member={member_account_id} "
|
||||
f"legacy_role={legacy_role} -> rbac_role_id={resolved_role_id}"
|
||||
)
|
||||
continue
|
||||
|
||||
if _member_already_has_role(current_roles_by_account_id, member_account_id, resolved_role_id):
|
||||
skipped_count += 1
|
||||
continue
|
||||
|
||||
replace_jobs.append((member_account_id, resolved_role_id))
|
||||
|
||||
if replace_jobs:
|
||||
if workers == 1:
|
||||
for member_account_id, resolved_role_id in replace_jobs:
|
||||
_replace_member_role(workspace_id, owner_account_id, member_account_id, resolved_role_id)
|
||||
migrated_count += 1
|
||||
else:
|
||||
with ThreadPoolExecutor(max_workers=workers) as executor:
|
||||
futures = [
|
||||
executor.submit(
|
||||
_replace_member_role,
|
||||
workspace_id,
|
||||
owner_account_id,
|
||||
member_account_id,
|
||||
resolved_role_id,
|
||||
)
|
||||
for member_account_id, resolved_role_id in replace_jobs
|
||||
]
|
||||
for future in as_completed(futures):
|
||||
future.result()
|
||||
migrated_count += 1
|
||||
|
||||
if scanned_count % 10000 == 0:
|
||||
click.echo(
|
||||
f"progress scanned={scanned_count} migrated={migrated_count} skipped={skipped_count}",
|
||||
err=True,
|
||||
)
|
||||
|
||||
if scanned_count == 0:
|
||||
click.echo(click.style("No workspace members found for migration.", fg="yellow"))
|
||||
return
|
||||
|
||||
owner_account_by_tenant: dict[str, str] = {}
|
||||
resolved_role_ids: dict[tuple[str, str], str] = {}
|
||||
migrated_count = 0
|
||||
|
||||
for join in joins:
|
||||
workspace_id = str(join.tenant_id)
|
||||
member_account_id = str(join.account_id)
|
||||
legacy_role = str(join.role)
|
||||
|
||||
if workspace_id not in owner_account_by_tenant:
|
||||
owner_join = next(
|
||||
(
|
||||
item
|
||||
for item in joins
|
||||
if str(item.tenant_id) == workspace_id and str(item.role) == TenantAccountRole.OWNER.value
|
||||
),
|
||||
None,
|
||||
)
|
||||
if not owner_join:
|
||||
raise ValueError(f"Workspace owner not found for tenant={workspace_id}")
|
||||
owner_account_by_tenant[workspace_id] = str(owner_join.account_id)
|
||||
|
||||
operator_account_id = owner_account_by_tenant[workspace_id]
|
||||
cache_key = (workspace_id, legacy_role)
|
||||
if cache_key not in resolved_role_ids:
|
||||
resolved_role_ids[cache_key] = _resolve_builtin_role_id(workspace_id, operator_account_id, legacy_role)
|
||||
|
||||
resolved_role_id = resolved_role_ids[cache_key]
|
||||
click.echo(
|
||||
f"tenant={workspace_id} member={member_account_id} "
|
||||
f"legacy_role={legacy_role} -> rbac_role_id={resolved_role_id}"
|
||||
)
|
||||
|
||||
if dry_run:
|
||||
continue
|
||||
|
||||
RBACService.MemberRoles.replace(
|
||||
tenant_id=workspace_id,
|
||||
account_id=operator_account_id,
|
||||
member_account_id=member_account_id,
|
||||
role_ids=[resolved_role_id],
|
||||
)
|
||||
migrated_count += 1
|
||||
|
||||
if dry_run:
|
||||
click.echo(click.style("Dry run completed. No RBAC bindings were written.", fg="yellow"))
|
||||
click.echo(
|
||||
click.style(
|
||||
f"Dry run completed. Scanned {scanned_count} members across {tenant_count} tenants. "
|
||||
"No RBAC bindings were written.",
|
||||
fg="yellow",
|
||||
)
|
||||
)
|
||||
else:
|
||||
click.echo(click.style(f"RBAC member-role migration completed. Migrated {migrated_count} members.", fg="green"))
|
||||
click.echo(
|
||||
click.style(
|
||||
f"RBAC member-role migration completed. Scanned {scanned_count} members across {tenant_count} tenants, "
|
||||
f"migrated {migrated_count}, skipped {skipped_count} already up-to-date.",
|
||||
fg="green",
|
||||
)
|
||||
)
|
||||
|
||||
@@ -35,6 +35,12 @@ class WorkflowRunArchiveTenantPlan(TypedDict):
|
||||
unpaid_tenant_ids: list[str]
|
||||
|
||||
|
||||
def _normalize_utc_datetime(value: datetime.datetime) -> datetime.datetime:
|
||||
if value.tzinfo is None:
|
||||
return value.replace(tzinfo=datetime.UTC)
|
||||
return value.astimezone(datetime.UTC)
|
||||
|
||||
|
||||
def _parse_tenant_prefixes(prefixes: str | None) -> list[str]:
|
||||
if not prefixes:
|
||||
return []
|
||||
@@ -156,11 +162,16 @@ def _resolve_archive_time_range(
|
||||
raise click.UsageError("Choose either day offsets or explicit dates, not both.")
|
||||
if from_days_ago <= to_days_ago:
|
||||
raise click.UsageError("--from-days-ago must be greater than --to-days-ago.")
|
||||
now = datetime.datetime.now()
|
||||
now = datetime.datetime.now(datetime.UTC)
|
||||
start_from = now - datetime.timedelta(days=from_days_ago)
|
||||
end_before = now - datetime.timedelta(days=to_days_ago)
|
||||
before_days = 0
|
||||
|
||||
if start_from is not None:
|
||||
start_from = _normalize_utc_datetime(start_from)
|
||||
if end_before is not None:
|
||||
end_before = _normalize_utc_datetime(end_before)
|
||||
|
||||
if start_from and end_before and start_from >= end_before:
|
||||
raise click.UsageError("--start-from must be earlier than --end-before.")
|
||||
|
||||
@@ -402,6 +413,13 @@ def archive_workflow_runs_plan(
|
||||
fg="white",
|
||||
)
|
||||
)
|
||||
click.echo(
|
||||
click.style(
|
||||
"fixed_archive_window="
|
||||
f"{start_from.isoformat() if start_from else 'unbounded'},{plan_end_before.isoformat()}",
|
||||
fg="white",
|
||||
)
|
||||
)
|
||||
click.echo("tenant_prefix,total_tenants,workflow_runs,workflow_node_executions,paid_tenants,unpaid_tenants")
|
||||
for row in rows:
|
||||
click.echo(
|
||||
@@ -451,7 +469,7 @@ def archive_workflow_runs_plan(
|
||||
default=None,
|
||||
help="Archive runs created before this timestamp (UTC if no timezone).",
|
||||
)
|
||||
@click.option("--batch-size", default=100, show_default=True, help="Maximum workflow runs per archive bundle.")
|
||||
@click.option("--batch-size", default=10000, show_default=True, help="Maximum workflow runs per archive bundle.")
|
||||
@click.option(
|
||||
"--workers",
|
||||
default=1,
|
||||
@@ -521,6 +539,7 @@ def archive_workflow_runs(
|
||||
)
|
||||
)
|
||||
|
||||
uses_relative_window = start_from is None and end_before is None
|
||||
try:
|
||||
before_days, start_from, end_before = _resolve_archive_time_range(
|
||||
before_days=before_days,
|
||||
@@ -546,6 +565,14 @@ def archive_workflow_runs(
|
||||
if delete_after_archive:
|
||||
click.echo(click.style("delete-after-archive is not supported by bundle archive.", fg="red"))
|
||||
return
|
||||
if uses_relative_window:
|
||||
click.echo(
|
||||
click.style(
|
||||
"Relative archive windows are evaluated at command start. For multi-day prefix/shard rollout, "
|
||||
"reuse absolute --start-from/--end-before values from archive-workflow-runs-plan.",
|
||||
fg="yellow",
|
||||
)
|
||||
)
|
||||
|
||||
try:
|
||||
tenant_plan = _resolve_archive_tenant_ids_from_plan(
|
||||
|
||||
@@ -16,7 +16,7 @@ class EnterpriseFeatureConfig(BaseSettings):
|
||||
|
||||
CAN_REPLACE_LOGO: bool = Field(
|
||||
description="Allow customization of the enterprise logo.",
|
||||
default=True,
|
||||
default=False,
|
||||
)
|
||||
|
||||
ENTERPRISE_REQUEST_TIMEOUT: int = Field(
|
||||
@@ -34,6 +34,12 @@ class EnterpriseFeatureConfig(BaseSettings):
|
||||
default=False,
|
||||
)
|
||||
|
||||
ENTERPRISE_RBAC_REQUEST_TIMEOUT: int = Field(
|
||||
ge=1,
|
||||
description="Maximum timeout in seconds for inner RBAC requests.",
|
||||
default=30,
|
||||
)
|
||||
|
||||
|
||||
class EnterpriseTelemetryConfig(BaseSettings):
|
||||
"""
|
||||
|
||||
@@ -36,8 +36,8 @@ class AgentBackendConfig(BaseSettings):
|
||||
description=(
|
||||
"Inject the dify.drive layer (Skills & Files drive manifest declaration) "
|
||||
"into Agent runs. The declaration is an index only — the agent backend "
|
||||
"pulls the actual SKILL.md / files through the back proxy. Keep it off "
|
||||
"until the agent backend registers the dify.drive layer type."
|
||||
"pulls the actual SKILL.md / files through the back proxy. Set this to "
|
||||
"false only when temporarily rolling back the drive integration."
|
||||
),
|
||||
default=False,
|
||||
default=True,
|
||||
)
|
||||
|
||||
@@ -1073,6 +1073,12 @@ class MailConfig(BaseSettings):
|
||||
default=None,
|
||||
)
|
||||
|
||||
|
||||
class HomepageConfig(BaseSettings):
|
||||
"""
|
||||
Configuration for homepage feature toggles exposed through system features.
|
||||
"""
|
||||
|
||||
ENABLE_TRIAL_APP: bool = Field(
|
||||
description="Enable trial app",
|
||||
default=False,
|
||||
@@ -1083,6 +1089,11 @@ class MailConfig(BaseSettings):
|
||||
default=False,
|
||||
)
|
||||
|
||||
ENABLE_LEARN_APP: bool = Field(
|
||||
description="Enable Learn App",
|
||||
default=True,
|
||||
)
|
||||
|
||||
|
||||
class RagEtlConfig(BaseSettings):
|
||||
"""
|
||||
@@ -1489,6 +1500,7 @@ class FeatureConfig(
|
||||
EndpointConfig,
|
||||
FileAccessConfig,
|
||||
FileUploadConfig,
|
||||
HomepageConfig,
|
||||
HttpConfig,
|
||||
InnerAPIConfig,
|
||||
IndexingConfig,
|
||||
|
||||
@@ -183,6 +183,7 @@ class Site(BaseModel):
|
||||
description: str | None = None
|
||||
copyright: str | None = None
|
||||
privacy_policy: str | None = None
|
||||
input_placeholder: str | None = None
|
||||
custom_disclaimer: str | None = None
|
||||
default_language: str
|
||||
show_workflow_steps: bool
|
||||
|
||||
@@ -6,5 +6,15 @@ from services.agent.roster_service import AgentRosterService
|
||||
|
||||
|
||||
def resolve_agent_app_model(*, tenant_id: str, agent_id: UUID) -> App:
|
||||
"""Resolve the hidden Agent App backing an Agent Console resource."""
|
||||
"""Resolve a roster Agent's public Agent App."""
|
||||
return AgentRosterService(db.session).get_agent_app_model(tenant_id=tenant_id, agent_id=str(agent_id))
|
||||
|
||||
|
||||
def resolve_agent_runtime_app_model(*, tenant_id: str, agent_id: UUID) -> App:
|
||||
"""Resolve the App that backs an Agent runtime surface.
|
||||
|
||||
This accepts both roster Agent Apps and workflow-only inline Agents with a
|
||||
hidden backing App.
|
||||
"""
|
||||
|
||||
return AgentRosterService(db.session).get_agent_runtime_app_model(tenant_id=tenant_id, agent_id=str(agent_id))
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
from uuid import UUID
|
||||
|
||||
from flask import request
|
||||
from flask_restx import Resource
|
||||
|
||||
from controllers.common.schema import register_response_schema_models, register_schema_models
|
||||
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.agent.app_helpers import resolve_agent_app_model
|
||||
from controllers.console.app.wraps import get_app_model
|
||||
from controllers.console.wraps import (
|
||||
RBACPermission,
|
||||
@@ -28,9 +28,15 @@ from libs.login import login_required
|
||||
from models.model import App, AppMode
|
||||
from services.agent.composer_service import AgentComposerService
|
||||
from services.agent.composer_validator import ComposerConfigValidator
|
||||
from services.entities.agent_entities import ComposerSavePayload
|
||||
from services.entities.agent_entities import (
|
||||
ComposerSavePayload,
|
||||
WorkflowAgentComposerQuery,
|
||||
WorkflowComposerCopyFromRosterPayload,
|
||||
)
|
||||
|
||||
register_schema_models(console_ns, ComposerSavePayload)
|
||||
register_schema_models(
|
||||
console_ns, ComposerSavePayload, WorkflowAgentComposerQuery, WorkflowComposerCopyFromRosterPayload
|
||||
)
|
||||
register_response_schema_models(
|
||||
console_ns,
|
||||
AgentAppComposerResponse,
|
||||
@@ -41,27 +47,26 @@ register_response_schema_models(
|
||||
)
|
||||
|
||||
|
||||
def _resolve_agent_app_id(*, tenant_id: str, agent_id: UUID) -> str:
|
||||
return resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id).id
|
||||
|
||||
|
||||
@console_ns.route("/apps/<uuid:app_id>/workflows/draft/nodes/<string:node_id>/agent-composer")
|
||||
class WorkflowAgentComposerApi(Resource):
|
||||
@console_ns.response(
|
||||
200, "Workflow agent composer state", console_ns.models[WorkflowAgentComposerResponse.__name__]
|
||||
)
|
||||
@console_ns.doc(params=query_params_from_model(WorkflowAgentComposerQuery))
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@get_app_model(mode=[AppMode.WORKFLOW, AppMode.ADVANCED_CHAT])
|
||||
@with_current_tenant_id
|
||||
def get(self, tenant_id: str, app_model: App, node_id: str):
|
||||
query = WorkflowAgentComposerQuery.model_validate(request.args.to_dict(flat=True))
|
||||
return dump_response(
|
||||
WorkflowAgentComposerResponse,
|
||||
AgentComposerService.load_workflow_composer(
|
||||
tenant_id=tenant_id,
|
||||
app_id=app_model.id,
|
||||
node_id=node_id,
|
||||
snapshot_id=query.snapshot_id,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -91,6 +96,38 @@ class WorkflowAgentComposerApi(Resource):
|
||||
)
|
||||
|
||||
|
||||
@console_ns.route("/apps/<uuid:app_id>/workflows/draft/nodes/<string:node_id>/agent-composer/copy-from-roster")
|
||||
class WorkflowAgentComposerCopyFromRosterApi(Resource):
|
||||
@console_ns.expect(console_ns.models[WorkflowComposerCopyFromRosterPayload.__name__])
|
||||
@console_ns.response(
|
||||
200,
|
||||
"Workflow roster agent copied to inline agent",
|
||||
console_ns.models[WorkflowAgentComposerResponse.__name__],
|
||||
)
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@edit_permission_required
|
||||
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_EDIT)
|
||||
@get_app_model(mode=[AppMode.WORKFLOW, AppMode.ADVANCED_CHAT])
|
||||
@with_current_user_id
|
||||
@with_current_tenant_id
|
||||
def post(self, tenant_id: str, account_id: str, app_model: App, node_id: str):
|
||||
payload = WorkflowComposerCopyFromRosterPayload.model_validate(console_ns.payload or {})
|
||||
return dump_response(
|
||||
WorkflowAgentComposerResponse,
|
||||
AgentComposerService.copy_workflow_composer_from_roster(
|
||||
tenant_id=tenant_id,
|
||||
app_id=app_model.id,
|
||||
node_id=node_id,
|
||||
account_id=account_id,
|
||||
source_agent_id=payload.source_agent_id,
|
||||
source_snapshot_id=payload.source_snapshot_id,
|
||||
idempotency_key=payload.idempotency_key,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@console_ns.route("/apps/<uuid:app_id>/workflows/draft/nodes/<string:node_id>/agent-composer/validate")
|
||||
class WorkflowAgentComposerValidateApi(Resource):
|
||||
@console_ns.expect(console_ns.models[ComposerSavePayload.__name__])
|
||||
@@ -104,7 +141,8 @@ class WorkflowAgentComposerValidateApi(Resource):
|
||||
@with_current_tenant_id
|
||||
def post(self, tenant_id: str, app_model: App, node_id: str):
|
||||
payload = ComposerSavePayload.model_validate(console_ns.payload or {})
|
||||
ComposerConfigValidator.validate_save_payload(payload)
|
||||
ComposerConfigValidator.validate_publish_payload(payload)
|
||||
AgentComposerService.validate_knowledge_datasets(tenant_id=tenant_id, agent_soul=payload.agent_soul)
|
||||
findings = AgentComposerService.collect_validation_findings(
|
||||
tenant_id=tenant_id,
|
||||
payload=payload,
|
||||
@@ -196,10 +234,9 @@ class AgentComposerApi(Resource):
|
||||
@account_initialization_required
|
||||
@with_current_tenant_id
|
||||
def get(self, tenant_id: str, agent_id: UUID):
|
||||
app_id = _resolve_agent_app_id(tenant_id=tenant_id, agent_id=agent_id)
|
||||
return dump_response(
|
||||
AgentAppComposerResponse,
|
||||
AgentComposerService.load_agent_app_composer(tenant_id=tenant_id, app_id=app_id),
|
||||
AgentComposerService.load_agent_composer(tenant_id=tenant_id, agent_id=str(agent_id)),
|
||||
)
|
||||
|
||||
@console_ns.expect(console_ns.models[ComposerSavePayload.__name__])
|
||||
@@ -212,13 +249,12 @@ class AgentComposerApi(Resource):
|
||||
@with_current_user_id
|
||||
@with_current_tenant_id
|
||||
def put(self, tenant_id: str, account_id: str, agent_id: UUID):
|
||||
app_id = _resolve_agent_app_id(tenant_id=tenant_id, agent_id=agent_id)
|
||||
payload = ComposerSavePayload.model_validate(console_ns.payload or {})
|
||||
return dump_response(
|
||||
AgentAppComposerResponse,
|
||||
AgentComposerService.save_agent_app_composer(
|
||||
AgentComposerService.save_agent_composer(
|
||||
tenant_id=tenant_id,
|
||||
app_id=app_id,
|
||||
agent_id=str(agent_id),
|
||||
account_id=account_id,
|
||||
payload=payload,
|
||||
),
|
||||
@@ -236,9 +272,10 @@ class AgentComposerValidateApi(Resource):
|
||||
@account_initialization_required
|
||||
@with_current_tenant_id
|
||||
def post(self, tenant_id: str, agent_id: UUID):
|
||||
_resolve_agent_app_id(tenant_id=tenant_id, agent_id=agent_id)
|
||||
AgentComposerService.load_agent_composer(tenant_id=tenant_id, agent_id=str(agent_id))
|
||||
payload = ComposerSavePayload.model_validate(console_ns.payload or {})
|
||||
ComposerConfigValidator.validate_save_payload(payload)
|
||||
ComposerConfigValidator.validate_publish_payload(payload)
|
||||
AgentComposerService.validate_knowledge_datasets(tenant_id=tenant_id, agent_soul=payload.agent_soul)
|
||||
findings = AgentComposerService.collect_validation_findings(
|
||||
tenant_id=tenant_id,
|
||||
payload=payload,
|
||||
@@ -258,12 +295,11 @@ class AgentComposerCandidatesApi(Resource):
|
||||
@with_current_user_id
|
||||
@with_current_tenant_id
|
||||
def get(self, tenant_id: str, current_user_id: str, agent_id: UUID):
|
||||
app_id = _resolve_agent_app_id(tenant_id=tenant_id, agent_id=agent_id)
|
||||
return dump_response(
|
||||
AgentComposerCandidatesResponse,
|
||||
AgentComposerService.get_agent_app_candidates(
|
||||
tenant_id=tenant_id,
|
||||
app_id=app_id,
|
||||
agent_id=str(agent_id),
|
||||
user_id=current_user_id,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -7,7 +7,7 @@ from sqlalchemy import func, select
|
||||
|
||||
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.agent.app_helpers import resolve_agent_app_model
|
||||
from controllers.console.agent.app_helpers import resolve_agent_app_model, resolve_agent_runtime_app_model
|
||||
from controllers.console.apikey import ApiKeyItem, ApiKeyList, BaseApiKeyListResource, BaseApiKeyResource
|
||||
from controllers.console.app.app import (
|
||||
AppDetailWithSite as GenericAppDetailWithSite,
|
||||
@@ -54,8 +54,10 @@ from libs.datetime_utils import parse_time_range
|
||||
from libs.helper import dump_response
|
||||
from libs.login import login_required
|
||||
from models import Account
|
||||
from models.agent import Agent, AgentStatus
|
||||
from models.enums import ApiTokenType
|
||||
from models.model import ApiToken, App, IconType
|
||||
from services.agent.composer_service import AgentComposerService
|
||||
from services.agent.errors import AgentNotFoundError
|
||||
from services.agent.observability_service import (
|
||||
AgentLogQueryParams,
|
||||
@@ -65,7 +67,7 @@ from services.agent.observability_service import (
|
||||
from services.agent.roster_service import AgentRosterService
|
||||
from services.app_service import AppListParams, AppService, CreateAppParams
|
||||
from services.enterprise.enterprise_service import EnterpriseService
|
||||
from services.entities.agent_entities import RosterListQuery
|
||||
from services.entities.agent_entities import ComposerSavePayload, RosterListQuery
|
||||
from services.feature_service import FeatureService
|
||||
|
||||
|
||||
@@ -232,6 +234,8 @@ class AgentStatisticsQuery(BaseModel):
|
||||
|
||||
class AgentAppPartial(GenericAppPartial):
|
||||
app_id: str | None = None
|
||||
backing_app_id: str | None = None
|
||||
hidden_app_backed: bool = False
|
||||
debug_conversation_id: str | None = None
|
||||
role: str | None = None
|
||||
active_config_is_published: bool = False
|
||||
@@ -241,6 +245,8 @@ class AgentAppPartial(GenericAppPartial):
|
||||
|
||||
class AgentAppDetailWithSite(GenericAppDetailWithSite):
|
||||
app_id: str | None = None
|
||||
backing_app_id: str | None = None
|
||||
hidden_app_backed: bool = False
|
||||
debug_conversation_id: str | None = None
|
||||
role: str | None = None
|
||||
active_config_is_published: bool = False
|
||||
@@ -250,6 +256,36 @@ class AgentDebugConversationRefreshResponse(BaseModel):
|
||||
debug_conversation_id: str
|
||||
|
||||
|
||||
class AgentPublishPayload(BaseModel):
|
||||
version_note: str | None = Field(default=None, description="Optional note for this published Agent version")
|
||||
|
||||
|
||||
class AgentPublishResponse(BaseModel):
|
||||
result: str
|
||||
active_config_snapshot_id: str
|
||||
active_config_snapshot: dict[str, object] | None = None
|
||||
draft: dict[str, object] | None = None
|
||||
|
||||
|
||||
class AgentBuildDraftCheckoutPayload(BaseModel):
|
||||
force: bool = Field(default=False, description="Overwrite the existing current-user build draft")
|
||||
|
||||
|
||||
class AgentBuildDraftResponse(BaseModel):
|
||||
variant: str
|
||||
draft: dict[str, object]
|
||||
agent_soul: dict[str, object]
|
||||
|
||||
|
||||
class AgentBuildDraftApplyResponse(BaseModel):
|
||||
result: str
|
||||
draft: dict[str, object]
|
||||
|
||||
|
||||
class AgentSimpleResultResponse(BaseModel):
|
||||
result: str
|
||||
|
||||
|
||||
class AgentAppPagination(GenericAppPagination):
|
||||
data: list[AgentAppPartial] = Field( # type: ignore[assignment] # pyrefly: ignore[bad-override-mutable-attribute]
|
||||
validation_alias=AliasChoices("items", "data")
|
||||
@@ -261,6 +297,9 @@ register_schema_models(
|
||||
AgentAppCreatePayload,
|
||||
AgentAppUpdatePayload,
|
||||
AgentAppCopyPayload,
|
||||
AgentPublishPayload,
|
||||
AgentBuildDraftCheckoutPayload,
|
||||
ComposerSavePayload,
|
||||
AgentApiStatusPayload,
|
||||
AgentInviteOptionsQuery,
|
||||
AgentLogsQuery,
|
||||
@@ -277,6 +316,10 @@ register_response_schema_models(
|
||||
AgentAppDetailWithSite,
|
||||
AgentAppPartial,
|
||||
AgentDebugConversationRefreshResponse,
|
||||
AgentPublishResponse,
|
||||
AgentBuildDraftResponse,
|
||||
AgentBuildDraftApplyResponse,
|
||||
AgentSimpleResultResponse,
|
||||
AgentConfigSnapshotDetailResponse,
|
||||
AgentConfigSnapshotListResponse,
|
||||
AgentConfigSnapshotRestoreResponse,
|
||||
@@ -294,7 +337,7 @@ def _agent_roster_service() -> AgentRosterService:
|
||||
return AgentRosterService(db.session)
|
||||
|
||||
|
||||
def _serialize_agent_app_detail(app_model, *, current_user: Account) -> dict:
|
||||
def _serialize_agent_app_detail(app_model, *, current_user: Account, agent_id: str | None = None) -> dict:
|
||||
"""Serialize an Agent App detail using roster-only DTOs.
|
||||
|
||||
`/agent` responses are roster-shaped rather than raw app-shaped: `id`
|
||||
@@ -311,11 +354,23 @@ def _serialize_agent_app_detail(app_model, *, current_user: Account) -> dict:
|
||||
|
||||
roster_service = _agent_roster_service()
|
||||
payload = AgentAppDetailWithSite.model_validate(app_model, from_attributes=True).model_dump(mode="json")
|
||||
agent = roster_service.get_app_backing_agent(tenant_id=app_model.tenant_id, app_id=str(app_model.id))
|
||||
agent = (
|
||||
db.session.scalar(
|
||||
select(Agent).where(
|
||||
Agent.tenant_id == app_model.tenant_id,
|
||||
Agent.id == agent_id,
|
||||
Agent.status == AgentStatus.ACTIVE,
|
||||
)
|
||||
)
|
||||
if agent_id
|
||||
else roster_service.get_app_backing_agent(tenant_id=app_model.tenant_id, app_id=str(app_model.id))
|
||||
)
|
||||
if not agent:
|
||||
raise AgentNotFoundError()
|
||||
payload.pop("bound_agent_id", None)
|
||||
payload["app_id"] = str(app_model.id)
|
||||
payload["app_id"] = agent.app_id
|
||||
payload["backing_app_id"] = roster_service.runtime_backing_app_id(agent)
|
||||
payload["hidden_app_backed"] = bool(agent.backing_app_id and agent.backing_app_id != agent.app_id)
|
||||
payload["id"] = agent.id
|
||||
payload["debug_conversation_id"] = roster_service.get_or_create_agent_app_debug_conversation_id(
|
||||
tenant_id=app_model.tenant_id,
|
||||
@@ -365,6 +420,8 @@ def _serialize_agent_app_pagination(app_pagination, *, tenant_id: str, current_u
|
||||
agent = agents_by_app_id.get(app_id)
|
||||
if agent:
|
||||
item["app_id"] = app_id
|
||||
item["backing_app_id"] = agent.backing_app_id or app_id
|
||||
item["hidden_app_backed"] = False
|
||||
item["id"] = agent.id
|
||||
item["debug_conversation_id"] = debug_conversation_ids_by_agent_id.get(agent.id)
|
||||
item["role"] = agent.role or ""
|
||||
@@ -516,8 +573,8 @@ class AgentAppApi(Resource):
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
def get(self, tenant_id: str, current_user: Account, agent_id: UUID):
|
||||
app_model = _resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
|
||||
return _serialize_agent_app_detail(app_model, current_user=current_user)
|
||||
app_model = resolve_agent_runtime_app_model(tenant_id=tenant_id, agent_id=agent_id)
|
||||
return _serialize_agent_app_detail(app_model, current_user=current_user, agent_id=str(agent_id))
|
||||
|
||||
@console_ns.expect(console_ns.models[AgentAppUpdatePayload.__name__])
|
||||
@console_ns.response(200, "Agent app updated successfully", console_ns.models[AgentAppDetailWithSite.__name__])
|
||||
@@ -583,6 +640,112 @@ class AgentDebugConversationRefreshApi(Resource):
|
||||
)
|
||||
|
||||
|
||||
@console_ns.route("/agent/<uuid:agent_id>/publish")
|
||||
class AgentPublishApi(Resource):
|
||||
@console_ns.expect(console_ns.models[AgentPublishPayload.__name__])
|
||||
@console_ns.response(200, "Agent draft published", console_ns.models[AgentPublishResponse.__name__])
|
||||
@console_ns.response(403, "Insufficient permissions")
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@edit_permission_required
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
def post(self, tenant_id: str, current_user: Account, agent_id: UUID):
|
||||
args = AgentPublishPayload.model_validate(console_ns.payload or {})
|
||||
return AgentComposerService.publish_agent_app_draft(
|
||||
tenant_id=tenant_id,
|
||||
agent_id=str(agent_id),
|
||||
account_id=current_user.id,
|
||||
version_note=args.version_note,
|
||||
)
|
||||
|
||||
|
||||
@console_ns.route("/agent/<uuid:agent_id>/build-draft/checkout")
|
||||
class AgentBuildDraftCheckoutApi(Resource):
|
||||
@console_ns.expect(console_ns.models[AgentBuildDraftCheckoutPayload.__name__])
|
||||
@console_ns.response(200, "Agent build draft checked out", console_ns.models[AgentBuildDraftResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@edit_permission_required
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
def post(self, tenant_id: str, current_user: Account, agent_id: UUID):
|
||||
args = AgentBuildDraftCheckoutPayload.model_validate(console_ns.payload or {})
|
||||
return AgentComposerService.checkout_agent_app_build_draft(
|
||||
tenant_id=tenant_id,
|
||||
agent_id=str(agent_id),
|
||||
account_id=current_user.id,
|
||||
force=args.force,
|
||||
)
|
||||
|
||||
|
||||
@console_ns.route("/agent/<uuid:agent_id>/build-draft")
|
||||
class AgentBuildDraftApi(Resource):
|
||||
@console_ns.response(200, "Agent build draft", console_ns.models[AgentBuildDraftResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@edit_permission_required
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
def get(self, tenant_id: str, current_user: Account, agent_id: UUID):
|
||||
return AgentComposerService.load_agent_app_build_draft(
|
||||
tenant_id=tenant_id,
|
||||
agent_id=str(agent_id),
|
||||
account_id=current_user.id,
|
||||
)
|
||||
|
||||
@console_ns.expect(console_ns.models[ComposerSavePayload.__name__])
|
||||
@console_ns.response(200, "Agent build draft saved", console_ns.models[AgentBuildDraftResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@edit_permission_required
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
def put(self, tenant_id: str, current_user: Account, agent_id: UUID):
|
||||
payload = ComposerSavePayload.model_validate(console_ns.payload or {})
|
||||
return AgentComposerService.save_agent_app_build_draft(
|
||||
tenant_id=tenant_id,
|
||||
agent_id=str(agent_id),
|
||||
account_id=current_user.id,
|
||||
payload=payload,
|
||||
)
|
||||
|
||||
@console_ns.response(200, "Agent build draft discarded", console_ns.models[AgentSimpleResultResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@edit_permission_required
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
def delete(self, tenant_id: str, current_user: Account, agent_id: UUID):
|
||||
return AgentComposerService.discard_agent_app_build_draft(
|
||||
tenant_id=tenant_id,
|
||||
agent_id=str(agent_id),
|
||||
account_id=current_user.id,
|
||||
)
|
||||
|
||||
|
||||
@console_ns.route("/agent/<uuid:agent_id>/build-draft/apply")
|
||||
class AgentBuildDraftApplyApi(Resource):
|
||||
@console_ns.response(200, "Agent build draft applied", console_ns.models[AgentBuildDraftApplyResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@edit_permission_required
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
def post(self, tenant_id: str, current_user: Account, agent_id: UUID):
|
||||
return AgentComposerService.apply_agent_app_build_draft(
|
||||
tenant_id=tenant_id,
|
||||
agent_id=str(agent_id),
|
||||
account_id=current_user.id,
|
||||
)
|
||||
|
||||
|
||||
@console_ns.route("/agent/<uuid:agent_id>/copy")
|
||||
class AgentAppCopyApi(Resource):
|
||||
@console_ns.expect(console_ns.models[AgentAppCopyPayload.__name__])
|
||||
@@ -712,7 +875,7 @@ class AgentLogsApi(Resource):
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
def get(self, tenant_id: str, current_user: Account, agent_id: UUID):
|
||||
app_model = _resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
|
||||
app_model = resolve_agent_runtime_app_model(tenant_id=tenant_id, agent_id=agent_id)
|
||||
query_data: dict[str, object] = dict(request.args.to_dict(flat=True))
|
||||
query_data["sources"] = _multi_query_values("sources", "source")
|
||||
query_data["statuses"] = _multi_query_values("statuses", "status")
|
||||
@@ -749,7 +912,7 @@ class AgentLogMessagesApi(Resource):
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
def get(self, tenant_id: str, current_user: Account, agent_id: UUID, conversation_id: UUID):
|
||||
app_model = _resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
|
||||
app_model = resolve_agent_runtime_app_model(tenant_id=tenant_id, agent_id=agent_id)
|
||||
query_data: dict[str, object] = dict(request.args.to_dict(flat=True))
|
||||
query_data["sources"] = _multi_query_values("sources", "source")
|
||||
query_data["statuses"] = _multi_query_values("statuses", "status")
|
||||
@@ -786,7 +949,7 @@ class AgentLogSourcesApi(Resource):
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
def get(self, tenant_id: str, current_user: Account, agent_id: UUID):
|
||||
app_model = _resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
|
||||
app_model = resolve_agent_runtime_app_model(tenant_id=tenant_id, agent_id=agent_id)
|
||||
payload = _agent_observability_service().list_log_sources(app=app_model, agent_id=str(agent_id))
|
||||
return dump_response(AgentLogSourceListResponse, payload)
|
||||
|
||||
@@ -805,7 +968,7 @@ class AgentStatisticsSummaryApi(Resource):
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
def get(self, tenant_id: str, current_user: Account, agent_id: UUID):
|
||||
app_model = _resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
|
||||
app_model = resolve_agent_runtime_app_model(tenant_id=tenant_id, agent_id=agent_id)
|
||||
query = AgentStatisticsQuery.model_validate(request.args.to_dict(flat=True))
|
||||
timezone = current_user.timezone or "UTC"
|
||||
start, end = _parse_observability_time_range(query.start, query.end, current_user)
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import logging
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
@@ -14,7 +13,7 @@ from controllers.common.schema import (
|
||||
register_schema_models,
|
||||
)
|
||||
from controllers.console import console_ns
|
||||
from controllers.console.agent.app_helpers import resolve_agent_app_model
|
||||
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,
|
||||
@@ -30,7 +29,6 @@ from fields.base import ResponseModel
|
||||
from libs.helper import uuid_value
|
||||
from libs.login import login_required
|
||||
from models import Account
|
||||
from models.agent_config_entities import AgentFileRefConfig, AgentSkillRefConfig
|
||||
from models.model import App, AppMode, UploadFile
|
||||
from services.agent.composer_service import AgentComposerService
|
||||
from services.agent.skill_package_service import SkillManifest, SkillPackageError
|
||||
@@ -49,8 +47,6 @@ from services.agent_drive_service import (
|
||||
)
|
||||
from services.agent_service import AgentService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_WORKFLOW_AGENT_DRIVE_APP_MODES = [AppMode.WORKFLOW, AppMode.ADVANCED_CHAT]
|
||||
_AGENT_SKILL_UPLOAD_PARAMS = {
|
||||
"file": {
|
||||
@@ -130,8 +126,16 @@ 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: AgentSkillRefConfig
|
||||
skill: AgentUploadedSkillResponse
|
||||
manifest: SkillManifest
|
||||
|
||||
|
||||
@@ -145,13 +149,11 @@ class AgentDriveFileResponse(ResponseModel):
|
||||
|
||||
class AgentDriveFileCommitResponse(ResponseModel):
|
||||
file: AgentDriveFileResponse
|
||||
config_version_id: str | None = None
|
||||
|
||||
|
||||
class AgentDriveDeleteResponse(ResponseModel):
|
||||
result: str
|
||||
removed_keys: list[str] = Field(default_factory=list)
|
||||
config_version_id: str | None = None
|
||||
|
||||
|
||||
register_schema_models(console_ns, AgentLogQuery, AgentDriveFilePayload, AgentDriveDeleteFileByAgentQuery)
|
||||
@@ -161,6 +163,7 @@ register_response_schema_models(
|
||||
AgentDriveFileCommitResponse,
|
||||
AgentDriveFileResponse,
|
||||
AgentLogResponse,
|
||||
AgentUploadedSkillResponse,
|
||||
AgentSkillUploadResponse,
|
||||
SkillToolInferenceResult,
|
||||
)
|
||||
@@ -242,24 +245,6 @@ def _commit_drive_file_for_app(*, current_user: Account, app_model: App, allow_n
|
||||
return {"code": exc.code, "message": exc.message}, exc.status_code
|
||||
|
||||
row = committed[0]
|
||||
file_ref = AgentFileRefConfig.model_validate(
|
||||
{
|
||||
"id": row["key"],
|
||||
"name": upload_file.name,
|
||||
"file_id": upload_file.id,
|
||||
"drive_key": row["key"],
|
||||
"type": row.get("mime_type"),
|
||||
"size": row.get("size"),
|
||||
}
|
||||
)
|
||||
config_version_id = AgentComposerService.add_drive_file_ref(
|
||||
tenant_id=app_model.tenant_id,
|
||||
agent_id=agent_id,
|
||||
account_id=current_user.id,
|
||||
file_ref=file_ref,
|
||||
app_id=app_model.id,
|
||||
node_id=node_id,
|
||||
)
|
||||
return {
|
||||
"file": {
|
||||
"name": upload_file.name,
|
||||
@@ -268,7 +253,6 @@ def _commit_drive_file_for_app(*, current_user: Account, app_model: App, allow_n
|
||||
"size": row.get("size"),
|
||||
"mime_type": row.get("mime_type"),
|
||||
},
|
||||
"config_version_id": config_version_id,
|
||||
}, 201
|
||||
|
||||
|
||||
@@ -283,24 +267,17 @@ def _delete_drive_file_for_app(*, current_user: Account, app_model: App, allow_n
|
||||
except AgentDriveError as exc:
|
||||
return {"code": exc.code, "message": exc.message}, exc.status_code
|
||||
|
||||
config_version_id = AgentComposerService.remove_drive_refs(
|
||||
tenant_id=app_model.tenant_id,
|
||||
agent_id=agent_id,
|
||||
account_id=current_user.id,
|
||||
file_key=key,
|
||||
app_id=app_model.id,
|
||||
node_id=node_id,
|
||||
)
|
||||
removed_keys: list[str] = []
|
||||
try:
|
||||
removed_keys = AgentDriveService().delete(tenant_id=app_model.tenant_id, agent_id=agent_id, key=key)
|
||||
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)],
|
||||
)
|
||||
except AgentDriveError as exc:
|
||||
return {"code": exc.code, "message": exc.message}, exc.status_code
|
||||
except Exception:
|
||||
# Soul-first ordering: the ref is already gone; orphan KV rows are
|
||||
# harmless and an idempotent DELETE retry cleans them.
|
||||
logger.exception("agent drive delete failed for key %s (soul already updated)", key)
|
||||
return {"result": "success", "removed_keys": removed_keys, "config_version_id": config_version_id}
|
||||
removed_keys = [item["key"] for item in result if item.get("removed")]
|
||||
return {"result": "success", "removed_keys": removed_keys}
|
||||
|
||||
|
||||
def _delete_skill_for_app(*, current_user: Account, app_model: App, slug: str, allow_node_id: bool = True):
|
||||
@@ -312,22 +289,20 @@ def _delete_skill_for_app(*, current_user: Account, app_model: App, slug: str, a
|
||||
if "/" in slug or not slug.strip():
|
||||
return {"code": "drive_key_invalid", "message": "skill slug must be a single path segment"}, 400
|
||||
|
||||
config_version_id = AgentComposerService.remove_drive_refs(
|
||||
tenant_id=app_model.tenant_id,
|
||||
agent_id=agent_id,
|
||||
account_id=current_user.id,
|
||||
skill_slug=slug,
|
||||
app_id=app_model.id,
|
||||
node_id=node_id,
|
||||
)
|
||||
removed_keys: list[str] = []
|
||||
try:
|
||||
removed_keys = AgentDriveService().delete(tenant_id=app_model.tenant_id, agent_id=agent_id, prefix=f"{slug}/")
|
||||
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),
|
||||
],
|
||||
)
|
||||
except AgentDriveError as exc:
|
||||
return {"code": exc.code, "message": exc.message}, exc.status_code
|
||||
except Exception:
|
||||
logger.exception("agent drive delete failed for skill %s (soul already updated)", slug)
|
||||
return {"result": "success", "removed_keys": removed_keys, "config_version_id": config_version_id}
|
||||
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(*, app_model: App, slug: str):
|
||||
@@ -376,7 +351,7 @@ class AgentSkillUploadByAgentApi(Resource):
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
def post(self, tenant_id: str, current_user: Account, agent_id: UUID):
|
||||
app_model = resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
|
||||
app_model = resolve_agent_runtime_app_model(tenant_id=tenant_id, agent_id=agent_id)
|
||||
return _upload_skill_for_app(current_user=current_user, app_model=app_model)
|
||||
|
||||
|
||||
@@ -419,7 +394,7 @@ class AgentDriveFilesByAgentApi(Resource):
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
def post(self, tenant_id: str, current_user: Account, agent_id: UUID):
|
||||
app_model = resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
|
||||
app_model = resolve_agent_runtime_app_model(tenant_id=tenant_id, agent_id=agent_id)
|
||||
return _commit_drive_file_for_app(current_user=current_user, app_model=app_model, allow_node_id=False)
|
||||
|
||||
@console_ns.doc("delete_agent_drive_file_by_agent")
|
||||
@@ -432,7 +407,7 @@ class AgentDriveFilesByAgentApi(Resource):
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
def delete(self, tenant_id: str, current_user: Account, agent_id: UUID):
|
||||
app_model = resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
|
||||
app_model = resolve_agent_runtime_app_model(tenant_id=tenant_id, agent_id=agent_id)
|
||||
return _delete_drive_file_for_app(current_user=current_user, app_model=app_model, allow_node_id=False)
|
||||
|
||||
|
||||
@@ -455,7 +430,7 @@ class AgentDriveFilesApi(Resource):
|
||||
return _commit_drive_file_for_app(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; soul ref first, then the KV row (ENG-625 D5)")
|
||||
@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
|
||||
@@ -479,16 +454,14 @@ class AgentSkillByAgentApi(Resource):
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
def delete(self, tenant_id: str, current_user: Account, agent_id: UUID, slug: str):
|
||||
app_model = resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
|
||||
app_model = resolve_agent_runtime_app_model(tenant_id=tenant_id, agent_id=agent_id)
|
||||
return _delete_skill_for_app(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: soul ref first, then the <slug>/ drive prefix (ENG-625 D5)"
|
||||
)
|
||||
@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",
|
||||
@@ -521,7 +494,7 @@ class AgentSkillInferToolsByAgentApi(Resource):
|
||||
@account_initialization_required
|
||||
@with_current_tenant_id
|
||||
def post(self, tenant_id: str, agent_id: UUID, slug: str):
|
||||
app_model = resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
|
||||
app_model = resolve_agent_runtime_app_model(tenant_id=tenant_id, agent_id=agent_id)
|
||||
return _infer_skill_tools_for_app(app_model=app_model, slug=slug)
|
||||
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ from pydantic import BaseModel, Field
|
||||
from controllers.common.fields import SimpleResultResponse
|
||||
from controllers.common.schema import register_response_schema_models, register_schema_models
|
||||
from controllers.console import console_ns
|
||||
from controllers.console.agent.app_helpers import resolve_agent_app_model
|
||||
from controllers.console.agent.app_helpers import resolve_agent_runtime_app_model
|
||||
from controllers.console.wraps import (
|
||||
RBACPermission,
|
||||
RBACResourceScope,
|
||||
@@ -87,11 +87,14 @@ class AgentAppFeatureConfigResource(Resource):
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
def post(self, tenant_id: str, current_user: Account, agent_id: UUID):
|
||||
app_model = resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
|
||||
app_model = resolve_agent_runtime_app_model(tenant_id=tenant_id, agent_id=agent_id)
|
||||
args = AgentAppFeaturesPayload.model_validate(console_ns.payload or {})
|
||||
|
||||
new_app_model_config = AgentAppFeatureConfigService.update_features(
|
||||
app_model=app_model, account=current_user, config=args.model_dump(exclude_none=True), session=db.session
|
||||
app_model=app_model,
|
||||
account=current_user,
|
||||
config=args.model_dump(exclude_none=True),
|
||||
session=db.session,
|
||||
)
|
||||
|
||||
app_model_config_was_updated.send(app_model, app_model_config=new_app_model_config)
|
||||
|
||||
@@ -22,7 +22,7 @@ from controllers.common.schema import (
|
||||
register_schema_models,
|
||||
)
|
||||
from controllers.console import console_ns
|
||||
from controllers.console.agent.app_helpers import resolve_agent_app_model
|
||||
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
|
||||
@@ -144,7 +144,7 @@ class AgentAppSandboxListResource(Resource):
|
||||
@account_initialization_required
|
||||
@with_current_tenant_id
|
||||
def get(self, tenant_id: str, agent_id: UUID):
|
||||
app_model = resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
|
||||
app_model = resolve_agent_runtime_app_model(tenant_id=tenant_id, agent_id=agent_id)
|
||||
query = query_params_from_request(AgentSandboxListQuery)
|
||||
try:
|
||||
result = AgentAppSandboxService().list_files(
|
||||
@@ -169,7 +169,7 @@ class AgentAppSandboxReadResource(Resource):
|
||||
@account_initialization_required
|
||||
@with_current_tenant_id
|
||||
def get(self, tenant_id: str, agent_id: UUID):
|
||||
app_model = resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
|
||||
app_model = resolve_agent_runtime_app_model(tenant_id=tenant_id, agent_id=agent_id)
|
||||
query = query_params_from_request(AgentSandboxFileQuery)
|
||||
try:
|
||||
result = AgentAppSandboxService().read_file(
|
||||
@@ -194,7 +194,7 @@ class AgentAppSandboxUploadResource(Resource):
|
||||
@account_initialization_required
|
||||
@with_current_tenant_id
|
||||
def post(self, tenant_id: str, agent_id: UUID):
|
||||
app_model = resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
|
||||
app_model = resolve_agent_runtime_app_model(tenant_id=tenant_id, agent_id=agent_id)
|
||||
payload = AgentSandboxUploadPayload.model_validate(request.get_json(silent=True) or {})
|
||||
try:
|
||||
result = AgentAppSandboxService().upload_file(
|
||||
|
||||
@@ -25,7 +25,7 @@ from controllers.common.schema import (
|
||||
register_response_schema_models,
|
||||
)
|
||||
from controllers.console import console_ns
|
||||
from controllers.console.agent.app_helpers import resolve_agent_app_model
|
||||
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
|
||||
@@ -182,7 +182,7 @@ class AgentDriveListByAgentApi(Resource):
|
||||
@with_current_tenant_id
|
||||
def get(self, tenant_id: str, agent_id: UUID):
|
||||
query = query_params_from_request(AgentDriveListByAgentQuery)
|
||||
resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
|
||||
resolve_agent_runtime_app_model(tenant_id=tenant_id, agent_id=agent_id)
|
||||
try:
|
||||
items = AgentDriveService().manifest(tenant_id=tenant_id, agent_id=str(agent_id), prefix=query.prefix)
|
||||
except AgentDriveError as exc:
|
||||
@@ -201,7 +201,7 @@ class AgentDriveSkillListByAgentApi(Resource):
|
||||
@account_initialization_required
|
||||
@with_current_tenant_id
|
||||
def get(self, tenant_id: str, agent_id: UUID):
|
||||
resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
|
||||
resolve_agent_runtime_app_model(tenant_id=tenant_id, agent_id=agent_id)
|
||||
try:
|
||||
items = AgentDriveService().list_skills(tenant_id=tenant_id, agent_id=str(agent_id))
|
||||
except AgentDriveError as exc:
|
||||
@@ -220,7 +220,7 @@ class AgentDriveSkillInspectByAgentApi(Resource):
|
||||
@account_initialization_required
|
||||
@with_current_tenant_id
|
||||
def get(self, tenant_id: str, agent_id: UUID, skill_path: str):
|
||||
resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
|
||||
resolve_agent_runtime_app_model(tenant_id=tenant_id, agent_id=agent_id)
|
||||
try:
|
||||
return _json_response(
|
||||
AgentDriveService().inspect_skill(
|
||||
@@ -245,7 +245,7 @@ class AgentDrivePreviewByAgentApi(Resource):
|
||||
@with_current_tenant_id
|
||||
def get(self, tenant_id: str, agent_id: UUID):
|
||||
query = query_params_from_request(AgentDriveFileByAgentQuery)
|
||||
resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
|
||||
resolve_agent_runtime_app_model(tenant_id=tenant_id, agent_id=agent_id)
|
||||
try:
|
||||
return AgentDriveService().preview(tenant_id=tenant_id, agent_id=str(agent_id), key=query.key)
|
||||
except AgentDriveError as exc:
|
||||
@@ -264,7 +264,7 @@ class AgentDriveDownloadByAgentApi(Resource):
|
||||
@with_current_tenant_id
|
||||
def get(self, tenant_id: str, agent_id: UUID):
|
||||
query = query_params_from_request(AgentDriveFileByAgentQuery)
|
||||
resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
|
||||
resolve_agent_runtime_app_model(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)
|
||||
except AgentDriveError as exc:
|
||||
|
||||
@@ -19,6 +19,7 @@ from controllers.console.wraps import (
|
||||
rbac_permission_required,
|
||||
setup_required,
|
||||
)
|
||||
from extensions.ext_database import db
|
||||
from extensions.ext_redis import redis_client
|
||||
from fields.annotation_fields import (
|
||||
Annotation,
|
||||
@@ -388,7 +389,9 @@ class AnnotationUpdateDeleteApi(Resource):
|
||||
update_args["answer"] = args.answer
|
||||
if args.question is not None:
|
||||
update_args["question"] = args.question
|
||||
annotation = AppAnnotationService.update_app_annotation_directly(update_args, str(app_id), str(annotation_id))
|
||||
annotation = AppAnnotationService.update_app_annotation_directly(
|
||||
update_args, str(app_id), str(annotation_id), db.session
|
||||
)
|
||||
return Annotation.model_validate(annotation, from_attributes=True).model_dump(mode="json")
|
||||
|
||||
@setup_required
|
||||
@@ -398,7 +401,7 @@ class AnnotationUpdateDeleteApi(Resource):
|
||||
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_EDIT)
|
||||
@console_ns.response(204, "Annotation deleted successfully")
|
||||
def delete(self, app_id: UUID, annotation_id: UUID):
|
||||
AppAnnotationService.delete_app_annotation(str(app_id), str(annotation_id))
|
||||
AppAnnotationService.delete_app_annotation(str(app_id), str(annotation_id), db.session)
|
||||
return "", 204
|
||||
|
||||
|
||||
|
||||
@@ -331,7 +331,7 @@ class ModelConfig(ResponseModel):
|
||||
return to_timestamp(value)
|
||||
|
||||
|
||||
class Site(ResponseModel):
|
||||
class AppDetailSiteResponse(ResponseModel):
|
||||
access_token: str | None = Field(default=None, validation_alias="code")
|
||||
code: str | None = None
|
||||
title: str | None = None
|
||||
@@ -345,6 +345,7 @@ class Site(ResponseModel):
|
||||
customize_domain: str | None = None
|
||||
copyright: str | None = None
|
||||
privacy_policy: str | None = None
|
||||
input_placeholder: str | None = None
|
||||
custom_disclaimer: str | None = None
|
||||
customize_token_strategy: str | None = None
|
||||
prompt_public: bool | None = None
|
||||
@@ -461,7 +462,7 @@ class AppDetailWithSite(AppDetail):
|
||||
api_base_url: str | None = None
|
||||
max_active_requests: int | None = None
|
||||
deleted_tools: list[DeletedTool] = Field(default_factory=list)
|
||||
site: Site | None = None
|
||||
site: AppDetailSiteResponse | None = None
|
||||
# For Agent App type: the roster Agent backing this app (None otherwise).
|
||||
bound_agent_id: str | None = None
|
||||
# For Agent App responses exposed through /agent.
|
||||
@@ -546,7 +547,7 @@ register_schema_models(
|
||||
WorkflowPartial,
|
||||
ModelConfigPartial,
|
||||
ModelConfig,
|
||||
Site,
|
||||
AppDetailSiteResponse,
|
||||
DeletedTool,
|
||||
AppDetail,
|
||||
AppExportResponse,
|
||||
@@ -1094,7 +1095,7 @@ class AppTraceApi(Resource):
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@edit_permission_required
|
||||
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_MONITOR)
|
||||
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_TRACING_CONFIG)
|
||||
@get_app_model
|
||||
def post(self, app_model: App):
|
||||
# add app trace
|
||||
|
||||
@@ -30,6 +30,7 @@ from controllers.console.wraps import (
|
||||
setup_required,
|
||||
)
|
||||
from core.errors.error import ModelCurrentlyNotSupportError, ProviderTokenNotInitError, QuotaExceededError
|
||||
from extensions.ext_database import db
|
||||
from graphon.model_runtime.errors.invoke import InvokeError
|
||||
from libs.login import login_required
|
||||
from models import App, AppMode
|
||||
@@ -142,6 +143,7 @@ class ChatMessageTextApi(Resource):
|
||||
|
||||
response = AudioService.transcript_tts(
|
||||
app_model=app_model,
|
||||
session=db.session,
|
||||
text=payload.text,
|
||||
voice=payload.voice,
|
||||
message_id=payload.message_id,
|
||||
|
||||
@@ -11,7 +11,7 @@ import services
|
||||
from controllers.common.fields import GeneratedAppResponse, SimpleResultResponse
|
||||
from controllers.common.schema import register_response_schema_models, register_schema_models
|
||||
from controllers.console import console_ns
|
||||
from controllers.console.agent.app_helpers import resolve_agent_app_model
|
||||
from controllers.console.agent.app_helpers import resolve_agent_runtime_app_model
|
||||
from controllers.console.app.error import (
|
||||
AppUnavailableError,
|
||||
CompletionRequestError,
|
||||
@@ -93,6 +93,10 @@ class ChatMessagePayload(BaseMessagePayload):
|
||||
query: str = Field(..., description="User query")
|
||||
conversation_id: str | None = Field(default=None, description="Conversation ID")
|
||||
parent_message_id: str | None = Field(default=None, description="Parent message ID")
|
||||
draft_type: Literal["draft", "debug_build"] = Field(
|
||||
default="draft",
|
||||
description="Agent App debug config source. Use debug_build while the Agent is in build mode.",
|
||||
)
|
||||
|
||||
@field_validator("conversation_id", "parent_message_id")
|
||||
@classmethod
|
||||
@@ -218,7 +222,7 @@ class AgentChatMessageApi(Resource):
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
def post(self, current_tenant_id: str, current_user: Account, agent_id: UUID):
|
||||
app_model = resolve_agent_app_model(tenant_id=current_tenant_id, agent_id=agent_id)
|
||||
app_model = resolve_agent_runtime_app_model(tenant_id=current_tenant_id, agent_id=agent_id)
|
||||
return _create_chat_message(
|
||||
current_tenant_id=current_tenant_id,
|
||||
current_user=current_user,
|
||||
@@ -254,7 +258,7 @@ class AgentChatMessageStopApi(Resource):
|
||||
@with_current_user_id
|
||||
@with_current_tenant_id
|
||||
def post(self, current_tenant_id: str, current_user_id: str, agent_id: UUID, task_id: str):
|
||||
app_model = resolve_agent_app_model(tenant_id=current_tenant_id, agent_id=agent_id)
|
||||
app_model = resolve_agent_runtime_app_model(tenant_id=current_tenant_id, agent_id=agent_id)
|
||||
return _stop_chat_message(current_user_id=current_user_id, app_model=app_model, task_id=task_id)
|
||||
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from collections.abc import Sequence
|
||||
import json
|
||||
from collections.abc import Generator, Sequence
|
||||
from typing import Any, Literal
|
||||
|
||||
from flask_restx import Resource
|
||||
@@ -23,8 +24,10 @@ from core.helper.code_executor.javascript.javascript_code_provider import Javasc
|
||||
from core.helper.code_executor.python3.python3_code_provider import Python3CodeProvider
|
||||
from core.llm_generator.entities import RuleCodeGeneratePayload, RuleGeneratePayload, RuleStructuredOutputPayload
|
||||
from core.llm_generator.llm_generator import LLMGenerator
|
||||
from core.workflow.generator.types import WorkflowGenerateErrorCode
|
||||
from graphon.model_runtime.entities.llm_entities import LLMMode
|
||||
from graphon.model_runtime.errors.invoke import InvokeError
|
||||
from libs.helper import compact_generate_response
|
||||
from libs.login import login_required
|
||||
from models import App
|
||||
from services.workflow_generator_service import WorkflowGeneratorService
|
||||
@@ -64,7 +67,10 @@ class WorkflowGeneratePayload(BaseModel):
|
||||
can reuse its existing handler.
|
||||
"""
|
||||
|
||||
mode: Literal["workflow", "advanced-chat"] = Field(..., description="Target app mode for the generated graph")
|
||||
mode: Literal["workflow", "advanced-chat", "auto"] = Field(
|
||||
...,
|
||||
description="Target app mode for the generated graph; 'auto' lets the backend classify the instruction",
|
||||
)
|
||||
instruction: str = Field(..., description="Natural-language workflow description")
|
||||
ideal_output: str = Field(default="", description="Optional sample output for grounding")
|
||||
model_config_data: ModelConfig = Field(
|
||||
@@ -78,6 +84,19 @@ class WorkflowGeneratePayload(BaseModel):
|
||||
)
|
||||
|
||||
|
||||
class WorkflowInstructionSuggestionsPayload(BaseModel):
|
||||
"""Payload for the workflow-generator instruction-suggestions endpoint.
|
||||
|
||||
Runs before the user picks a model, so the suggestions come from the
|
||||
tenant's default model. The underlying generator never raises — an empty
|
||||
``suggestions`` list is a valid 200 (soft-fail).
|
||||
"""
|
||||
|
||||
mode: Literal["workflow", "advanced-chat"] = Field(..., description="Target app mode for the suggestions")
|
||||
language: str | None = Field(default=None, description="Optional language to write the suggestions in")
|
||||
count: int = Field(default=4, ge=1, le=6, description="Number of suggestions to return (1-6)")
|
||||
|
||||
|
||||
class GeneratorResponse(RootModel[Any]):
|
||||
root: Any
|
||||
|
||||
@@ -91,6 +110,7 @@ register_schema_models(
|
||||
InstructionGeneratePayload,
|
||||
InstructionTemplatePayload,
|
||||
WorkflowGeneratePayload,
|
||||
WorkflowInstructionSuggestionsPayload,
|
||||
ModelConfig,
|
||||
)
|
||||
register_response_schema_models(console_ns, GeneratorResponse, SimpleDataResponse)
|
||||
@@ -313,6 +333,34 @@ class InstructionGenerationTemplateApi(Resource):
|
||||
raise ValueError(f"Invalid type: {args.type}")
|
||||
|
||||
|
||||
def _workflow_instruction_guard(args: WorkflowGeneratePayload) -> tuple[dict, int] | None:
|
||||
"""Shared boundary guard for the workflow-generate endpoints.
|
||||
|
||||
Returns a ``(body, 400)`` tuple when the instruction is empty / whitespace
|
||||
or either free-text field exceeds the cap, else ``None``. Pydantic only
|
||||
validates the field is a str; a whitespace-only or pasted-document input
|
||||
would otherwise waste a slow planner+builder roundtrip on a response the
|
||||
validator rejects anyway. Both the blocking and streaming endpoints call
|
||||
this so they reject identical inputs.
|
||||
"""
|
||||
if not args.instruction.strip():
|
||||
return {
|
||||
"error": "Instruction is required",
|
||||
"errors": [{"code": WorkflowGenerateErrorCode.EMPTY_INSTRUCTION, "detail": "Instruction is required"}],
|
||||
}, 400
|
||||
if len(args.instruction) > _MAX_INSTRUCTION_LENGTH or len(args.ideal_output) > _MAX_INSTRUCTION_LENGTH:
|
||||
return {
|
||||
"error": "Instruction is too long",
|
||||
"errors": [
|
||||
{
|
||||
"code": WorkflowGenerateErrorCode.INSTRUCTION_TOO_LONG,
|
||||
"detail": f"Instruction and ideal output must each be at most {_MAX_INSTRUCTION_LENGTH} characters",
|
||||
}
|
||||
],
|
||||
}, 400
|
||||
return None
|
||||
|
||||
|
||||
@console_ns.route("/workflow-generate")
|
||||
class WorkflowGenerateApi(Resource):
|
||||
"""Generate a Workflow / Chatflow draft graph from a natural-language description.
|
||||
@@ -335,31 +383,11 @@ class WorkflowGenerateApi(Resource):
|
||||
def post(self, current_tenant_id: str):
|
||||
args = WorkflowGeneratePayload.model_validate(console_ns.payload)
|
||||
|
||||
# Reject obviously-empty instructions at the boundary — Pydantic only
|
||||
# validates ``instruction`` is a str, but a whitespace-only string
|
||||
# would still hit the LLM and waste a planner+builder roundtrip on a
|
||||
# response that the postprocess validator would reject anyway.
|
||||
if not args.instruction.strip():
|
||||
return {
|
||||
"error": "Instruction is required",
|
||||
"errors": [{"code": "EMPTY_INSTRUCTION", "detail": "Instruction is required"}],
|
||||
}, 400
|
||||
|
||||
# Bound the prompt at the boundary too: an arbitrarily long
|
||||
# instruction (or pasted document) blows the planner/builder context
|
||||
# window and fails with an opaque provider error after two slow LLM
|
||||
# calls. The cap matches the frontend textarea's maxLength.
|
||||
if len(args.instruction) > _MAX_INSTRUCTION_LENGTH or len(args.ideal_output) > _MAX_INSTRUCTION_LENGTH:
|
||||
return {
|
||||
"error": "Instruction is too long",
|
||||
"errors": [
|
||||
{
|
||||
"code": "INSTRUCTION_TOO_LONG",
|
||||
"detail": f"Instruction and ideal output must each be at most "
|
||||
f"{_MAX_INSTRUCTION_LENGTH} characters",
|
||||
}
|
||||
],
|
||||
}, 400
|
||||
# Reject empty / over-length instructions at the boundary (shared with
|
||||
# the streaming endpoint) before spending a planner+builder roundtrip.
|
||||
guard = _workflow_instruction_guard(args)
|
||||
if guard is not None:
|
||||
return guard
|
||||
|
||||
try:
|
||||
result = WorkflowGeneratorService.generate_workflow_graph(
|
||||
@@ -380,3 +408,93 @@ class WorkflowGenerateApi(Resource):
|
||||
raise CompletionRequestError(e.description)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@console_ns.route("/workflow-generate/suggestions")
|
||||
class WorkflowInstructionSuggestionsApi(Resource):
|
||||
"""Suggest short, buildable example instructions for the cmd+k generator.
|
||||
|
||||
Runs before a model is selected (uses the tenant's default model). The
|
||||
underlying generator never raises, so an empty list is a valid 200 — the
|
||||
frontend renders "no suggestions" rather than an error, so no provider-error
|
||||
mapping is needed here.
|
||||
"""
|
||||
|
||||
@console_ns.doc("generate_workflow_instruction_suggestions")
|
||||
@console_ns.doc(description="Suggest example workflow-generator instructions for the tenant")
|
||||
@console_ns.expect(console_ns.models[WorkflowInstructionSuggestionsPayload.__name__])
|
||||
@console_ns.response(200, "Suggestions generated successfully", console_ns.models[GeneratorResponse.__name__])
|
||||
@console_ns.response(400, "Invalid request parameters")
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_current_tenant_id
|
||||
def post(self, current_tenant_id: str):
|
||||
args = WorkflowInstructionSuggestionsPayload.model_validate(console_ns.payload)
|
||||
suggestions = LLMGenerator.generate_workflow_instruction_suggestions(
|
||||
tenant_id=current_tenant_id,
|
||||
mode=args.mode,
|
||||
language=args.language,
|
||||
count=args.count,
|
||||
)
|
||||
return {"suggestions": suggestions}
|
||||
|
||||
|
||||
@console_ns.route("/workflow-generate/stream")
|
||||
class WorkflowGenerateStreamApi(Resource):
|
||||
"""Plan-first streaming variant of ``/workflow-generate`` (Server-Sent Events).
|
||||
|
||||
Emits a ``plan`` event (high-level node list + app metadata) as soon as the
|
||||
planner returns, then a final ``result`` event with the full graph — the
|
||||
SAME envelope ``/workflow-generate`` returns. Provider-init / invoke errors
|
||||
are surfaced as a single ``result`` event (code ``MODEL_ERROR``) so the
|
||||
frontend's stream parser always receives a result rather than a non-SSE HTTP
|
||||
error.
|
||||
"""
|
||||
|
||||
@console_ns.doc("generate_workflow_graph_stream")
|
||||
@console_ns.doc(description="Stream a Dify workflow graph (plan then result) via SSE")
|
||||
@console_ns.expect(console_ns.models[WorkflowGeneratePayload.__name__])
|
||||
@console_ns.response(200, "Server-Sent Events stream of plan/result events")
|
||||
@console_ns.response(400, "Invalid request parameters")
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_current_tenant_id
|
||||
def post(self, current_tenant_id: str):
|
||||
args = WorkflowGeneratePayload.model_validate(console_ns.payload)
|
||||
|
||||
# Same boundary guards as the blocking endpoint — return a normal 400
|
||||
# JSON for these BEFORE opening the stream.
|
||||
guard = _workflow_instruction_guard(args)
|
||||
if guard is not None:
|
||||
return guard
|
||||
|
||||
def generate() -> Generator[str, None, None]:
|
||||
try:
|
||||
for event_name, payload in WorkflowGeneratorService.generate_workflow_graph_stream(
|
||||
tenant_id=current_tenant_id,
|
||||
mode=args.mode,
|
||||
instruction=args.instruction,
|
||||
model_config=args.model_config_data,
|
||||
ideal_output=args.ideal_output,
|
||||
current_graph=args.current_graph,
|
||||
):
|
||||
body = {"event": event_name, **payload}
|
||||
yield f"data: {json.dumps(body)}\n\n"
|
||||
except (ProviderTokenNotInitError, QuotaExceededError, ModelCurrentlyNotSupportError, InvokeError) as e:
|
||||
# The model instance is resolved inside the service (lazily, on
|
||||
# first iteration), so a provider / init error surfaces here.
|
||||
# Emit it as a single SSE result event rather than a non-SSE
|
||||
# error response so the frontend's stream parser always gets a
|
||||
# result it can render.
|
||||
detail = getattr(e, "description", None) or str(e) or "Model invocation failed"
|
||||
error_body = {
|
||||
"event": "result",
|
||||
"graph": {"nodes": [], "edges": [], "viewport": {"x": 0.0, "y": 0.0, "zoom": 0.7}},
|
||||
"error": detail,
|
||||
"errors": [{"code": WorkflowGenerateErrorCode.MODEL_ERROR, "detail": detail}],
|
||||
}
|
||||
yield f"data: {json.dumps(error_body)}\n\n"
|
||||
|
||||
return compact_generate_response(generate())
|
||||
|
||||
@@ -13,7 +13,7 @@ from controllers.common.controller_schemas import MessageFeedbackPayload as _Mes
|
||||
from controllers.common.fields import SimpleResultResponse, TextFileResponse
|
||||
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.agent.app_helpers import resolve_agent_app_model
|
||||
from controllers.console.agent.app_helpers import resolve_agent_runtime_app_model
|
||||
from controllers.console.app.error import (
|
||||
CompletionRequestError,
|
||||
ProviderModelCurrentlyNotSupportError,
|
||||
@@ -167,12 +167,16 @@ register_schema_models(
|
||||
ChatMessagesQuery,
|
||||
MessageFeedbackPayload,
|
||||
FeedbackExportQuery,
|
||||
)
|
||||
register_response_schema_models(
|
||||
console_ns,
|
||||
AnnotationCountResponse,
|
||||
SuggestedQuestionsResponse,
|
||||
MessageDetailResponse,
|
||||
MessageInfiniteScrollPaginationResponse,
|
||||
SimpleResultResponse,
|
||||
TextFileResponse,
|
||||
)
|
||||
register_response_schema_models(console_ns, SimpleResultResponse, TextFileResponse)
|
||||
|
||||
|
||||
@console_ns.route("/apps/<uuid:app_id>/chat-messages")
|
||||
@@ -210,7 +214,7 @@ class AgentChatMessageListApi(Resource):
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
def get(self, current_tenant_id: str, current_user: Account, agent_id: UUID):
|
||||
app_model = resolve_agent_app_model(tenant_id=current_tenant_id, agent_id=agent_id)
|
||||
app_model = resolve_agent_runtime_app_model(tenant_id=current_tenant_id, agent_id=agent_id)
|
||||
return _list_chat_messages(app_model=app_model, current_user=current_user)
|
||||
|
||||
|
||||
@@ -246,7 +250,7 @@ class AgentMessageFeedbackApi(Resource):
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
def post(self, current_tenant_id: str, current_user: Account, agent_id: UUID):
|
||||
app_model = resolve_agent_app_model(tenant_id=current_tenant_id, agent_id=agent_id)
|
||||
app_model = resolve_agent_runtime_app_model(tenant_id=current_tenant_id, agent_id=agent_id)
|
||||
return _update_message_feedback(current_user=current_user, app_model=app_model)
|
||||
|
||||
|
||||
@@ -311,7 +315,7 @@ class AgentMessageSuggestedQuestionApi(Resource):
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
def get(self, current_tenant_id: str, current_user: Account, agent_id: UUID, message_id: UUID):
|
||||
app_model = resolve_agent_app_model(tenant_id=current_tenant_id, agent_id=agent_id)
|
||||
app_model = resolve_agent_runtime_app_model(tenant_id=current_tenant_id, agent_id=agent_id)
|
||||
return _get_message_suggested_questions(current_user=current_user, app_model=app_model, message_id=message_id)
|
||||
|
||||
|
||||
@@ -341,8 +345,8 @@ class MessageFeedbackExportApi(Resource):
|
||||
|
||||
try:
|
||||
export_data = FeedbackService.export_feedbacks(
|
||||
db.session(),
|
||||
app_id=app_model.id,
|
||||
app_model.id,
|
||||
session=db.session(),
|
||||
from_source=args.from_source,
|
||||
rating=args.rating,
|
||||
has_comment=args.has_comment,
|
||||
@@ -389,7 +393,7 @@ class AgentMessageApi(Resource):
|
||||
@account_initialization_required
|
||||
@with_current_tenant_id
|
||||
def get(self, current_tenant_id: str, agent_id: UUID, message_id: UUID):
|
||||
app_model = resolve_agent_app_model(tenant_id=current_tenant_id, agent_id=agent_id)
|
||||
app_model = resolve_agent_runtime_app_model(tenant_id=current_tenant_id, agent_id=agent_id)
|
||||
return _get_message_detail(app_model=app_model, message_id=message_id)
|
||||
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ from controllers.console.wraps import (
|
||||
RBACPermission,
|
||||
RBACResourceScope,
|
||||
account_initialization_required,
|
||||
edit_permission_required,
|
||||
rbac_permission_required,
|
||||
setup_required,
|
||||
)
|
||||
@@ -70,7 +71,7 @@ class TraceAppConfigApi(Resource):
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_MONITOR)
|
||||
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_TRACING_CONFIG)
|
||||
@get_app_model
|
||||
def get(self, app_model: App):
|
||||
args = TraceProviderQuery.model_validate(request.args.to_dict(flat=True)) # type: ignore
|
||||
@@ -95,9 +96,12 @@ class TraceAppConfigApi(Resource):
|
||||
console_ns.models[TraceAppConfigResponse.__name__],
|
||||
)
|
||||
@console_ns.response(400, "Invalid request parameters or configuration already exists")
|
||||
@console_ns.response(403, "Insufficient permissions")
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@edit_permission_required
|
||||
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_TRACING_CONFIG)
|
||||
@get_app_model
|
||||
def post(self, app_model: App):
|
||||
"""Create a new trace app configuration"""
|
||||
@@ -125,9 +129,12 @@ class TraceAppConfigApi(Resource):
|
||||
console_ns.models[TraceAppConfigResponse.__name__],
|
||||
)
|
||||
@console_ns.response(400, "Invalid request parameters or configuration not found")
|
||||
@console_ns.response(403, "Insufficient permissions")
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@edit_permission_required
|
||||
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_TRACING_CONFIG)
|
||||
@get_app_model
|
||||
def patch(self, app_model: App):
|
||||
"""Update an existing trace app configuration"""
|
||||
@@ -149,9 +156,12 @@ class TraceAppConfigApi(Resource):
|
||||
@console_ns.doc(params=query_params_from_model(TraceProviderQuery))
|
||||
@console_ns.response(204, "Tracing configuration deleted successfully")
|
||||
@console_ns.response(400, "Invalid request parameters or configuration not found")
|
||||
@console_ns.response(403, "Insufficient permissions")
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@edit_permission_required
|
||||
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_TRACING_CONFIG)
|
||||
@get_app_model
|
||||
def delete(self, app_model: App):
|
||||
"""Delete an existing trace app configuration"""
|
||||
|
||||
@@ -40,6 +40,7 @@ class AppSiteUpdatePayload(BaseModel):
|
||||
customize_domain: str | None = Field(default=None)
|
||||
copyright: str | None = Field(default=None)
|
||||
privacy_policy: str | None = Field(default=None)
|
||||
input_placeholder: str | None = Field(default=None)
|
||||
custom_disclaimer: str | None = Field(default=None)
|
||||
customize_token_strategy: Literal["must", "allow", "not_allow"] | None = Field(default=None)
|
||||
prompt_public: bool | None = Field(default=None)
|
||||
@@ -66,6 +67,7 @@ class AppSiteResponse(ResponseModel):
|
||||
customize_domain: str | None = None
|
||||
copyright: str | None = None
|
||||
privacy_policy: str | None = None
|
||||
input_placeholder: str | None = None
|
||||
custom_disclaimer: str | None = None
|
||||
customize_token_strategy: str
|
||||
prompt_public: bool
|
||||
@@ -110,6 +112,7 @@ class AppSite(Resource):
|
||||
"customize_domain",
|
||||
"copyright",
|
||||
"privacy_policy",
|
||||
"input_placeholder",
|
||||
"custom_disclaimer",
|
||||
"customize_token_strategy",
|
||||
"prompt_public",
|
||||
|
||||
@@ -181,7 +181,7 @@ class WorkflowAppLogApi(Resource):
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_MONITOR)
|
||||
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_LOG_AND_ANNOTATION)
|
||||
@get_app_model(mode=[AppMode.WORKFLOW])
|
||||
def get(self, app_model: App):
|
||||
"""
|
||||
@@ -225,7 +225,7 @@ class WorkflowArchivedLogApi(Resource):
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_MONITOR)
|
||||
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_LOG_AND_ANNOTATION)
|
||||
@get_app_model(mode=[AppMode.WORKFLOW])
|
||||
def get(self, app_model: App):
|
||||
"""
|
||||
|
||||
@@ -17,6 +17,7 @@ from controllers.console.wraps import (
|
||||
with_current_tenant_id,
|
||||
with_current_user,
|
||||
)
|
||||
from extensions.ext_database import db
|
||||
from fields.base import ResponseModel
|
||||
from fields.member_fields import AccountWithRole
|
||||
from libs.helper import build_avatar_url, dump_response, to_timestamp
|
||||
@@ -489,7 +490,7 @@ class WorkflowCommentMentionUsersApi(Resource):
|
||||
current_tenant = current_user.current_tenant # need the tenant object here
|
||||
if current_tenant is None:
|
||||
raise ValueError("current tenant is required")
|
||||
members = TenantService.get_tenant_members(current_tenant)
|
||||
members = TenantService.get_tenant_members(current_tenant, session=db.session)
|
||||
users = TypeAdapter(list[AccountWithRole]).validate_python(members, from_attributes=True)
|
||||
response = WorkflowCommentMentionUsersPayload(users=users)
|
||||
return response.model_dump(mode="json"), 200
|
||||
|
||||
@@ -89,7 +89,9 @@ class ActivateCheckApi(Resource):
|
||||
workspaceId = args.workspace_id
|
||||
token = args.token
|
||||
|
||||
invitation = RegisterService.get_invitation_with_case_fallback(workspaceId, args.email, token)
|
||||
invitation = RegisterService.get_invitation_with_case_fallback(
|
||||
workspaceId, args.email, token, session=db.session
|
||||
)
|
||||
if invitation:
|
||||
data = invitation.get("data", {})
|
||||
tenant = invitation.get("tenant", None)
|
||||
@@ -137,7 +139,9 @@ class ActivateApi(Resource):
|
||||
args = ActivatePayload.model_validate(console_ns.payload)
|
||||
|
||||
normalized_request_email = args.email.lower() if args.email else None
|
||||
invitation = RegisterService.get_invitation_with_case_fallback(args.workspace_id, args.email, args.token)
|
||||
invitation = RegisterService.get_invitation_with_case_fallback(
|
||||
args.workspace_id, args.email, args.token, session=db.session
|
||||
)
|
||||
if invitation is None:
|
||||
raise AlreadyActivateError()
|
||||
|
||||
@@ -184,6 +188,6 @@ class ActivateApi(Resource):
|
||||
account.status = AccountStatus.ACTIVE
|
||||
account.initialized_at = naive_utc_now()
|
||||
|
||||
TenantService.switch_tenant(account, tenant.id)
|
||||
TenantService.switch_tenant(account, tenant.id, session=db.session)
|
||||
|
||||
return {"result": "success"}
|
||||
|
||||
@@ -5,6 +5,7 @@ from pydantic import BaseModel, Field
|
||||
|
||||
from controllers.common.fields import SimpleResultResponse
|
||||
from controllers.common.schema import register_response_schema_models, register_schema_models
|
||||
from extensions.ext_database import db
|
||||
from fields.base import ResponseModel
|
||||
from libs.login import login_required
|
||||
from services.auth.api_key_auth_service import ApiKeyAuthService
|
||||
@@ -58,7 +59,7 @@ class ApiKeyAuthDataSource(Resource):
|
||||
@account_initialization_required
|
||||
@with_current_tenant_id
|
||||
def get(self, current_tenant_id: str):
|
||||
data_source_api_key_bindings = ApiKeyAuthService.get_provider_auth_list(current_tenant_id)
|
||||
data_source_api_key_bindings = ApiKeyAuthService.get_provider_auth_list(db.session(), current_tenant_id)
|
||||
if data_source_api_key_bindings:
|
||||
return {
|
||||
"sources": [
|
||||
@@ -92,7 +93,7 @@ class ApiKeyAuthDataSourceBinding(Resource):
|
||||
data = payload.model_dump()
|
||||
ApiKeyAuthService.validate_api_key_auth_args(data)
|
||||
try:
|
||||
ApiKeyAuthService.create_provider_auth(current_tenant_id, data)
|
||||
ApiKeyAuthService.create_provider_auth(db.session(), current_tenant_id, data)
|
||||
except Exception as e:
|
||||
raise ApiKeyAuthFailedError(str(e))
|
||||
return {"result": "success"}, 200
|
||||
@@ -109,6 +110,6 @@ class ApiKeyAuthDataSourceBindingDelete(Resource):
|
||||
@with_current_tenant_id
|
||||
def delete(self, current_tenant_id: str, binding_id: UUID):
|
||||
# The role of the current user in the table must be admin or owner
|
||||
ApiKeyAuthService.delete_provider_auth(current_tenant_id, str(binding_id))
|
||||
ApiKeyAuthService.delete_provider_auth(db.session(), current_tenant_id, str(binding_id))
|
||||
|
||||
return "", 204
|
||||
|
||||
@@ -15,6 +15,7 @@ from controllers.console.auth.error import (
|
||||
InvalidTokenError,
|
||||
PasswordMismatchError,
|
||||
)
|
||||
from extensions.ext_database import db
|
||||
from fields.base import ResponseModel
|
||||
from libs.helper import EmailStr, extract_remote_ip
|
||||
from libs.helper import timezone as validate_timezone_string
|
||||
@@ -100,7 +101,7 @@ class EmailRegisterSendEmailApi(Resource):
|
||||
if dify_config.BILLING_ENABLED and BillingService.is_email_in_freeze(normalized_email):
|
||||
raise AccountInFreezeError()
|
||||
|
||||
account = AccountService.get_account_by_email_with_case_fallback(args.email)
|
||||
account = AccountService.get_account_by_email_with_case_fallback(db.session, args.email)
|
||||
token = AccountService.send_email_register_email(email=normalized_email, account=account, language=language)
|
||||
return {"result": "success", "data": token}
|
||||
|
||||
@@ -175,7 +176,7 @@ class EmailRegisterResetApi(Resource):
|
||||
email = register_data.get("email", "")
|
||||
normalized_email = email.lower()
|
||||
|
||||
account = AccountService.get_account_by_email_with_case_fallback(email)
|
||||
account = AccountService.get_account_by_email_with_case_fallback(db.session, email)
|
||||
|
||||
if account:
|
||||
raise EmailAlreadyInUseError()
|
||||
@@ -186,7 +187,7 @@ class EmailRegisterResetApi(Resource):
|
||||
timezone=args.timezone,
|
||||
language=args.language,
|
||||
)
|
||||
token_pair = AccountService.login(account=account, ip_address=extract_remote_ip(request))
|
||||
token_pair = AccountService.login(account=account, session=db.session, ip_address=extract_remote_ip(request))
|
||||
AccountService.reset_login_error_rate_limit(normalized_email)
|
||||
|
||||
return {"result": "success", "data": token_pair.model_dump()}
|
||||
@@ -205,6 +206,7 @@ class EmailRegisterResetApi(Resource):
|
||||
password=password,
|
||||
interface_language=get_valid_language(language),
|
||||
timezone=timezone,
|
||||
session=db.session,
|
||||
)
|
||||
except AccountRegisterError:
|
||||
raise AccountInFreezeError()
|
||||
|
||||
@@ -82,7 +82,7 @@ class ForgotPasswordSendEmailApi(Resource):
|
||||
else:
|
||||
language = "en-US"
|
||||
|
||||
account = AccountService.get_account_by_email_with_case_fallback(args.email)
|
||||
account = AccountService.get_account_by_email_with_case_fallback(db.session, args.email)
|
||||
|
||||
token = AccountService.send_reset_password_email(
|
||||
account=account,
|
||||
@@ -180,7 +180,7 @@ class ForgotPasswordResetApi(Resource):
|
||||
password_hashed = hash_password(args.new_password, salt)
|
||||
|
||||
email = reset_data.get("email", "")
|
||||
account = AccountService.get_account_by_email_with_case_fallback(email)
|
||||
account = AccountService.get_account_by_email_with_case_fallback(db.session, email)
|
||||
|
||||
if account:
|
||||
account = db.session.merge(account)
|
||||
@@ -198,10 +198,10 @@ class ForgotPasswordResetApi(Resource):
|
||||
|
||||
# Create workspace if needed
|
||||
if (
|
||||
not TenantService.get_join_tenants(account)
|
||||
not TenantService.get_join_tenants(account, session=db.session)
|
||||
and FeatureService.get_system_features().is_allow_create_workspace
|
||||
):
|
||||
tenant = TenantService.create_tenant(f"{account.name}'s Workspace")
|
||||
tenant = TenantService.create_tenant(f"{account.name}'s Workspace", session=db.session)
|
||||
TenantService.create_tenant_member(tenant, account, db.session, role="owner")
|
||||
account.current_tenant = tenant
|
||||
tenant_was_created.send(tenant)
|
||||
|
||||
@@ -119,7 +119,9 @@ class LoginApi(Resource):
|
||||
invite_token = args.invite_token
|
||||
invitation_data: InvitationDetailDict | None = None
|
||||
if invite_token:
|
||||
invitation_data = RegisterService.get_invitation_with_case_fallback(None, request_email, invite_token)
|
||||
invitation_data = RegisterService.get_invitation_with_case_fallback(
|
||||
None, request_email, invite_token, session=db.session
|
||||
)
|
||||
if invitation_data is None:
|
||||
invite_token = None
|
||||
|
||||
@@ -145,7 +147,7 @@ class LoginApi(Resource):
|
||||
_log_console_login_failure(email=normalized_email, reason=LoginFailureReason.INVALID_CREDENTIALS)
|
||||
raise AuthenticationFailedError() from exc
|
||||
# SELF_HOSTED only have one workspace
|
||||
tenants = TenantService.get_join_tenants(account)
|
||||
tenants = TenantService.get_join_tenants(account, session=db.session)
|
||||
if len(tenants) == 0:
|
||||
system_features = FeatureService.get_system_features()
|
||||
|
||||
@@ -157,7 +159,7 @@ class LoginApi(Resource):
|
||||
"data": "workspace not found, please contact system admin to invite you to join in a workspace",
|
||||
}
|
||||
|
||||
token_pair = AccountService.login(account=account, ip_address=extract_remote_ip(request))
|
||||
token_pair = AccountService.login(account=account, session=db.session, ip_address=extract_remote_ip(request))
|
||||
AccountService.reset_login_error_rate_limit(normalized_email)
|
||||
|
||||
# Create response with cookies instead of returning tokens in body
|
||||
@@ -291,7 +293,7 @@ class EmailCodeLoginApi(Resource):
|
||||
_log_console_login_failure(email=user_email, reason=LoginFailureReason.ACCOUNT_IN_FREEZE)
|
||||
raise AccountInFreezeError()
|
||||
if account:
|
||||
tenants = TenantService.get_join_tenants(account)
|
||||
tenants = TenantService.get_join_tenants(account, session=db.session)
|
||||
if not tenants:
|
||||
workspaces = FeatureService.get_system_features().license.workspaces
|
||||
if not workspaces.is_available():
|
||||
@@ -299,7 +301,7 @@ class EmailCodeLoginApi(Resource):
|
||||
if not FeatureService.get_system_features().is_allow_create_workspace:
|
||||
raise NotAllowedCreateWorkspace()
|
||||
else:
|
||||
new_tenant = TenantService.create_tenant(f"{account.name}'s Workspace")
|
||||
new_tenant = TenantService.create_tenant(f"{account.name}'s Workspace", session=db.session)
|
||||
TenantService.create_tenant_member(new_tenant, account, db.session, role="owner")
|
||||
account.current_tenant = new_tenant
|
||||
tenant_was_created.send(new_tenant)
|
||||
@@ -311,6 +313,7 @@ class EmailCodeLoginApi(Resource):
|
||||
name=user_email,
|
||||
interface_language=get_valid_language(language),
|
||||
timezone=args.timezone,
|
||||
session=db.session,
|
||||
)
|
||||
except WorkSpaceNotAllowedCreateError:
|
||||
raise NotAllowedCreateWorkspace()
|
||||
@@ -319,7 +322,7 @@ class EmailCodeLoginApi(Resource):
|
||||
raise AccountInFreezeError()
|
||||
except WorkspacesLimitExceededError:
|
||||
raise WorkspacesLimitExceeded()
|
||||
token_pair = AccountService.login(account, ip_address=extract_remote_ip(request))
|
||||
token_pair = AccountService.login(account, session=db.session, ip_address=extract_remote_ip(request))
|
||||
AccountService.reset_login_error_rate_limit(user_email)
|
||||
|
||||
# Create response with cookies instead of returning tokens in body
|
||||
@@ -343,7 +346,7 @@ class RefreshTokenApi(Resource):
|
||||
return {"result": "fail", "message": "No refresh token provided"}, 401
|
||||
|
||||
try:
|
||||
new_token_pair = AccountService.refresh_token(refresh_token)
|
||||
new_token_pair = AccountService.refresh_token(refresh_token, session=db.session)
|
||||
|
||||
# Create response with new cookies
|
||||
response = make_response({"result": "success"})
|
||||
@@ -358,22 +361,22 @@ class RefreshTokenApi(Resource):
|
||||
|
||||
|
||||
def _get_account_with_case_fallback(email: str):
|
||||
account = AccountService.get_user_through_email(email)
|
||||
account = AccountService.get_user_through_email(email, session=db.session)
|
||||
if account or email == email.lower():
|
||||
return account
|
||||
|
||||
return AccountService.get_user_through_email(email.lower())
|
||||
return AccountService.get_user_through_email(email.lower(), session=db.session)
|
||||
|
||||
|
||||
def _authenticate_account_with_case_fallback(
|
||||
original_email: str, normalized_email: str, password: str, invite_token: str | None
|
||||
):
|
||||
try:
|
||||
return AccountService.authenticate(original_email, password, invite_token)
|
||||
return AccountService.authenticate(original_email, password, invite_token, session=db.session)
|
||||
except services.errors.account.AccountPasswordError:
|
||||
if original_email == normalized_email:
|
||||
raise
|
||||
return AccountService.authenticate(normalized_email, password, invite_token)
|
||||
return AccountService.authenticate(normalized_email, password, invite_token, session=db.session)
|
||||
|
||||
|
||||
def _log_console_login_failure(*, email: str, reason: LoginFailureReason) -> None:
|
||||
|
||||
@@ -195,7 +195,7 @@ class OAuthCallback(Resource):
|
||||
db.session.commit()
|
||||
|
||||
try:
|
||||
TenantService.create_owner_tenant_if_not_exist(account)
|
||||
TenantService.create_owner_tenant_if_not_exist(account, session=db.session)
|
||||
except Unauthorized:
|
||||
return redirect(f"{dify_config.CONSOLE_WEB_URL}/signin?message=Workspace not found.")
|
||||
except WorkSpaceNotAllowedCreateError:
|
||||
@@ -206,6 +206,7 @@ class OAuthCallback(Resource):
|
||||
|
||||
token_pair = AccountService.login(
|
||||
account=account,
|
||||
session=db.session,
|
||||
ip_address=extract_remote_ip(request),
|
||||
)
|
||||
|
||||
@@ -224,7 +225,7 @@ def _get_account_by_openid_or_email(provider: str, user_info: OAuthUserInfo) ->
|
||||
account: Account | None = Account.get_by_openid(provider, user_info.id)
|
||||
|
||||
if not account:
|
||||
account = AccountService.get_account_by_email_with_case_fallback(user_info.email)
|
||||
account = AccountService.get_account_by_email_with_case_fallback(db.session, user_info.email)
|
||||
|
||||
return account
|
||||
|
||||
@@ -240,12 +241,12 @@ def _generate_account(
|
||||
oauth_new_user = False
|
||||
|
||||
if account:
|
||||
tenants = TenantService.get_join_tenants(account)
|
||||
tenants = TenantService.get_join_tenants(account, session=db.session)
|
||||
if not tenants:
|
||||
if not FeatureService.get_system_features().is_allow_create_workspace:
|
||||
raise WorkSpaceNotAllowedCreateError()
|
||||
else:
|
||||
new_tenant = TenantService.create_tenant(f"{account.name}'s Workspace")
|
||||
new_tenant = TenantService.create_tenant(f"{account.name}'s Workspace", session=db.session)
|
||||
TenantService.create_tenant_member(new_tenant, account, db.session, role="owner")
|
||||
account.current_tenant = new_tenant
|
||||
tenant_was_created.send(new_tenant)
|
||||
@@ -272,9 +273,10 @@ def _generate_account(
|
||||
provider=provider,
|
||||
language=interface_language,
|
||||
timezone=timezone,
|
||||
session=db.session,
|
||||
)
|
||||
|
||||
# Link account
|
||||
AccountService.link_account_integrate(provider, user_info.id, account)
|
||||
AccountService.link_account_integrate(provider, user_info.id, account, session=db.session)
|
||||
|
||||
return account, oauth_new_user
|
||||
|
||||
@@ -16,6 +16,7 @@ from controllers.console.wraps import (
|
||||
with_current_user,
|
||||
)
|
||||
from enums.cloud_plan import CloudPlan
|
||||
from extensions.ext_database import db
|
||||
from libs.login import login_required
|
||||
from models import Account
|
||||
from services.billing_service import BillingService
|
||||
@@ -50,7 +51,7 @@ class Subscription(Resource):
|
||||
@with_current_tenant_id
|
||||
def get(self, current_tenant_id: str, current_user: Account):
|
||||
args = SubscriptionQuery.model_validate(request.args.to_dict(flat=True))
|
||||
BillingService.is_tenant_owner_or_admin(current_user)
|
||||
BillingService.is_tenant_owner_or_admin(db.session, current_user)
|
||||
return BillingService.get_subscription(args.plan, args.interval, current_user.email, current_tenant_id)
|
||||
|
||||
|
||||
@@ -64,7 +65,7 @@ class Invoices(Resource):
|
||||
@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)
|
||||
BillingService.is_tenant_owner_or_admin(db.session, current_user)
|
||||
return BillingService.get_invoices(current_user.email, current_tenant_id)
|
||||
|
||||
|
||||
|
||||
@@ -602,7 +602,7 @@ class DatasetApi(Resource):
|
||||
if dataset is None:
|
||||
raise NotFound("Dataset not found.")
|
||||
try:
|
||||
DatasetService.check_dataset_permission(dataset, current_user)
|
||||
DatasetService.check_dataset_permission(dataset, current_user, db.session)
|
||||
except services.errors.account.NoPermissionError as e:
|
||||
raise Forbidden(str(e))
|
||||
permissions = enterprise_rbac_service.RBACService.MyPermissions.get(
|
||||
@@ -774,7 +774,7 @@ class DatasetQueryApi(Resource):
|
||||
raise NotFound("Dataset not found.")
|
||||
|
||||
try:
|
||||
DatasetService.check_dataset_permission(dataset, current_user)
|
||||
DatasetService.check_dataset_permission(dataset, current_user, db.session)
|
||||
except services.errors.account.NoPermissionError as e:
|
||||
raise Forbidden(str(e))
|
||||
|
||||
@@ -915,7 +915,7 @@ class DatasetRelatedAppListApi(Resource):
|
||||
raise NotFound("Dataset not found.")
|
||||
|
||||
try:
|
||||
DatasetService.check_dataset_permission(dataset, current_user)
|
||||
DatasetService.check_dataset_permission(dataset, current_user, db.session)
|
||||
except services.errors.account.NoPermissionError as e:
|
||||
raise Forbidden(str(e))
|
||||
|
||||
@@ -1194,7 +1194,7 @@ class DatasetPermissionUserListApi(Resource):
|
||||
if dataset is None:
|
||||
raise NotFound("Dataset not found.")
|
||||
try:
|
||||
DatasetService.check_dataset_permission(dataset, current_user)
|
||||
DatasetService.check_dataset_permission(dataset, current_user, db.session)
|
||||
except services.errors.account.NoPermissionError as e:
|
||||
raise Forbidden(str(e))
|
||||
|
||||
|
||||
@@ -186,7 +186,7 @@ class DocumentResource(Resource):
|
||||
raise NotFound("Dataset not found.")
|
||||
|
||||
try:
|
||||
DatasetService.check_dataset_permission(dataset, current_user)
|
||||
DatasetService.check_dataset_permission(dataset, current_user, db.session)
|
||||
except services.errors.account.NoPermissionError as e:
|
||||
raise Forbidden(str(e))
|
||||
|
||||
@@ -206,7 +206,7 @@ class DocumentResource(Resource):
|
||||
raise NotFound("Dataset not found.")
|
||||
|
||||
try:
|
||||
DatasetService.check_dataset_permission(dataset, current_user)
|
||||
DatasetService.check_dataset_permission(dataset, current_user, db.session)
|
||||
except services.errors.account.NoPermissionError as e:
|
||||
raise Forbidden(str(e))
|
||||
|
||||
@@ -247,7 +247,7 @@ class GetProcessRuleApi(Resource):
|
||||
raise NotFound("Dataset not found.")
|
||||
|
||||
try:
|
||||
DatasetService.check_dataset_permission(dataset, current_user)
|
||||
DatasetService.check_dataset_permission(dataset, current_user, db.session)
|
||||
except services.errors.account.NoPermissionError as e:
|
||||
raise Forbidden(str(e))
|
||||
|
||||
@@ -322,7 +322,7 @@ class DatasetDocumentListApi(Resource):
|
||||
raise NotFound("Dataset not found.")
|
||||
|
||||
try:
|
||||
DatasetService.check_dataset_permission(dataset, current_user)
|
||||
DatasetService.check_dataset_permission(dataset, current_user, db.session)
|
||||
except services.errors.account.NoPermissionError as e:
|
||||
raise Forbidden(str(e))
|
||||
|
||||
@@ -431,7 +431,7 @@ class DatasetDocumentListApi(Resource):
|
||||
raise Forbidden()
|
||||
|
||||
try:
|
||||
DatasetService.check_dataset_permission(dataset, current_user)
|
||||
DatasetService.check_dataset_permission(dataset, current_user, db.session)
|
||||
except services.errors.account.NoPermissionError as e:
|
||||
raise Forbidden(str(e))
|
||||
|
||||
@@ -1173,7 +1173,7 @@ class DocumentStatusApi(DocumentResource):
|
||||
DatasetService.check_dataset_model_setting(dataset)
|
||||
|
||||
# check user's permission
|
||||
DatasetService.check_dataset_permission(dataset, current_user)
|
||||
DatasetService.check_dataset_permission(dataset, current_user, db.session)
|
||||
|
||||
document_ids = request.args.getlist("document_id")
|
||||
|
||||
@@ -1440,7 +1440,7 @@ class DocumentGenerateSummaryApi(Resource):
|
||||
raise Forbidden()
|
||||
|
||||
try:
|
||||
DatasetService.check_dataset_permission(dataset, current_user)
|
||||
DatasetService.check_dataset_permission(dataset, current_user, db.session)
|
||||
except services.errors.account.NoPermissionError as e:
|
||||
raise Forbidden(str(e))
|
||||
|
||||
@@ -1537,7 +1537,7 @@ class DocumentSummaryStatusApi(DocumentResource):
|
||||
|
||||
# Check permissions
|
||||
try:
|
||||
DatasetService.check_dataset_permission(dataset, current_user)
|
||||
DatasetService.check_dataset_permission(dataset, current_user, db.session)
|
||||
except services.errors.account.NoPermissionError as e:
|
||||
raise Forbidden(str(e))
|
||||
|
||||
|
||||
@@ -181,7 +181,7 @@ class DatasetDocumentSegmentListApi(Resource):
|
||||
raise NotFound("Dataset not found.")
|
||||
|
||||
try:
|
||||
DatasetService.check_dataset_permission(dataset, current_user)
|
||||
DatasetService.check_dataset_permission(dataset, current_user, db.session)
|
||||
except services.errors.account.NoPermissionError as e:
|
||||
raise Forbidden(str(e))
|
||||
|
||||
@@ -302,7 +302,7 @@ class DatasetDocumentSegmentListApi(Resource):
|
||||
if not current_user.is_dataset_editor:
|
||||
raise Forbidden()
|
||||
try:
|
||||
DatasetService.check_dataset_permission(dataset, current_user)
|
||||
DatasetService.check_dataset_permission(dataset, current_user, db.session)
|
||||
except services.errors.account.NoPermissionError as e:
|
||||
raise Forbidden(str(e))
|
||||
SegmentService.delete_segments(segment_ids, document, dataset)
|
||||
@@ -345,7 +345,7 @@ class DatasetDocumentSegmentApi(Resource):
|
||||
raise Forbidden()
|
||||
|
||||
try:
|
||||
DatasetService.check_dataset_permission(dataset, current_user)
|
||||
DatasetService.check_dataset_permission(dataset, current_user, db.session)
|
||||
except services.errors.account.NoPermissionError as e:
|
||||
raise Forbidden(str(e))
|
||||
if dataset.indexing_technique == IndexTechniqueType.HIGH_QUALITY:
|
||||
@@ -421,7 +421,7 @@ class DatasetDocumentSegmentAddApi(Resource):
|
||||
except ProviderTokenNotInitError as ex:
|
||||
raise ProviderNotInitializeError(ex.description)
|
||||
try:
|
||||
DatasetService.check_dataset_permission(dataset, current_user)
|
||||
DatasetService.check_dataset_permission(dataset, current_user, db.session)
|
||||
except services.errors.account.NoPermissionError as e:
|
||||
raise Forbidden(str(e))
|
||||
# validate args
|
||||
@@ -494,7 +494,7 @@ class DatasetDocumentSegmentUpdateApi(Resource):
|
||||
if not current_user.is_dataset_editor:
|
||||
raise Forbidden()
|
||||
try:
|
||||
DatasetService.check_dataset_permission(dataset, current_user)
|
||||
DatasetService.check_dataset_permission(dataset, current_user, db.session)
|
||||
except services.errors.account.NoPermissionError as e:
|
||||
raise Forbidden(str(e))
|
||||
# validate args
|
||||
@@ -550,7 +550,7 @@ class DatasetDocumentSegmentUpdateApi(Resource):
|
||||
if not current_user.is_dataset_editor:
|
||||
raise Forbidden()
|
||||
try:
|
||||
DatasetService.check_dataset_permission(dataset, current_user)
|
||||
DatasetService.check_dataset_permission(dataset, current_user, db.session)
|
||||
except services.errors.account.NoPermissionError as e:
|
||||
raise Forbidden(str(e))
|
||||
SegmentService.delete_segment(segment, document, dataset)
|
||||
@@ -687,7 +687,7 @@ class ChildChunkAddApi(Resource):
|
||||
except ProviderTokenNotInitError as ex:
|
||||
raise ProviderNotInitializeError(ex.description)
|
||||
try:
|
||||
DatasetService.check_dataset_permission(dataset, current_user)
|
||||
DatasetService.check_dataset_permission(dataset, current_user, db.session)
|
||||
except services.errors.account.NoPermissionError as e:
|
||||
raise Forbidden(str(e))
|
||||
# validate args
|
||||
@@ -789,7 +789,7 @@ class ChildChunkAddApi(Resource):
|
||||
if not current_user.is_dataset_editor:
|
||||
raise Forbidden()
|
||||
try:
|
||||
DatasetService.check_dataset_permission(dataset, current_user)
|
||||
DatasetService.check_dataset_permission(dataset, current_user, db.session)
|
||||
except services.errors.account.NoPermissionError as e:
|
||||
raise Forbidden(str(e))
|
||||
# validate args
|
||||
@@ -862,7 +862,7 @@ class ChildChunkUpdateApi(Resource):
|
||||
if not current_user.is_dataset_editor:
|
||||
raise Forbidden()
|
||||
try:
|
||||
DatasetService.check_dataset_permission(dataset, current_user)
|
||||
DatasetService.check_dataset_permission(dataset, current_user, db.session)
|
||||
except services.errors.account.NoPermissionError as e:
|
||||
raise Forbidden(str(e))
|
||||
try:
|
||||
@@ -930,7 +930,7 @@ class ChildChunkUpdateApi(Resource):
|
||||
if not current_user.is_dataset_editor:
|
||||
raise Forbidden()
|
||||
try:
|
||||
DatasetService.check_dataset_permission(dataset, current_user)
|
||||
DatasetService.check_dataset_permission(dataset, current_user, db.session)
|
||||
except services.errors.account.NoPermissionError as e:
|
||||
raise Forbidden(str(e))
|
||||
# validate args
|
||||
|
||||
@@ -382,7 +382,7 @@ class ExternalKnowledgeHitTestingApi(Resource):
|
||||
raise NotFound("Dataset not found.")
|
||||
|
||||
try:
|
||||
DatasetService.check_dataset_permission(dataset, current_user)
|
||||
DatasetService.check_dataset_permission(dataset, current_user, db.session)
|
||||
except services.errors.account.NoPermissionError as e:
|
||||
raise Forbidden(str(e))
|
||||
|
||||
|
||||
@@ -90,7 +90,7 @@ class DatasetsHitTestingBase:
|
||||
raise NotFound("Dataset not found.")
|
||||
|
||||
try:
|
||||
DatasetService.check_dataset_permission(dataset, current_user)
|
||||
DatasetService.check_dataset_permission(dataset, current_user, db.session)
|
||||
except services.errors.account.NoPermissionError as e:
|
||||
raise Forbidden(str(e))
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ from controllers.console.wraps import (
|
||||
with_current_tenant_id,
|
||||
with_current_user,
|
||||
)
|
||||
from extensions.ext_database import db
|
||||
from fields.dataset_fields import (
|
||||
DatasetMetadataBuiltInFieldsResponse,
|
||||
DatasetMetadataListResponse,
|
||||
@@ -63,9 +64,11 @@ class DatasetMetadataCreateApi(Resource):
|
||||
dataset = DatasetService.get_dataset(dataset_id_str)
|
||||
if dataset is None:
|
||||
raise NotFound("Dataset not found.")
|
||||
DatasetService.check_dataset_permission(dataset, current_user)
|
||||
DatasetService.check_dataset_permission(dataset, current_user, db.session)
|
||||
|
||||
metadata = MetadataService.create_metadata(dataset_id_str, metadata_args, current_user, current_tenant_id)
|
||||
metadata = MetadataService.create_metadata(
|
||||
db.session(), dataset_id_str, metadata_args, current_user, current_tenant_id
|
||||
)
|
||||
return dump_response(DatasetMetadataResponse, metadata), 201
|
||||
|
||||
@setup_required
|
||||
@@ -81,7 +84,7 @@ class DatasetMetadataCreateApi(Resource):
|
||||
dataset = DatasetService.get_dataset(dataset_id_str)
|
||||
if dataset is None:
|
||||
raise NotFound("Dataset not found.")
|
||||
metadata = MetadataService.get_dataset_metadatas(dataset)
|
||||
metadata = MetadataService.get_dataset_metadatas(db.session(), dataset)
|
||||
return dump_response(DatasetMetadataListResponse, metadata), 200
|
||||
|
||||
|
||||
@@ -105,10 +108,10 @@ class DatasetMetadataApi(Resource):
|
||||
dataset = DatasetService.get_dataset(dataset_id_str)
|
||||
if dataset is None:
|
||||
raise NotFound("Dataset not found.")
|
||||
DatasetService.check_dataset_permission(dataset, current_user)
|
||||
DatasetService.check_dataset_permission(dataset, current_user, db.session)
|
||||
|
||||
metadata = MetadataService.update_metadata_name(
|
||||
dataset_id_str, metadata_id_str, name, current_user, current_tenant_id
|
||||
db.session(), dataset_id_str, metadata_id_str, name, current_user, current_tenant_id
|
||||
)
|
||||
return dump_response(DatasetMetadataResponse, metadata), 200
|
||||
|
||||
@@ -125,9 +128,9 @@ class DatasetMetadataApi(Resource):
|
||||
dataset = DatasetService.get_dataset(dataset_id_str)
|
||||
if dataset is None:
|
||||
raise NotFound("Dataset not found.")
|
||||
DatasetService.check_dataset_permission(dataset, current_user)
|
||||
DatasetService.check_dataset_permission(dataset, current_user, db.session)
|
||||
|
||||
MetadataService.delete_metadata(dataset_id_str, metadata_id_str)
|
||||
MetadataService.delete_metadata(db.session(), dataset_id_str, metadata_id_str)
|
||||
# Frontend callers only await success and invalidate metadata caches; no response body is consumed.
|
||||
return "", 204
|
||||
|
||||
@@ -162,13 +165,13 @@ class DatasetMetadataBuiltInFieldActionApi(Resource):
|
||||
dataset = DatasetService.get_dataset(dataset_id_str)
|
||||
if dataset is None:
|
||||
raise NotFound("Dataset not found.")
|
||||
DatasetService.check_dataset_permission(dataset, current_user)
|
||||
DatasetService.check_dataset_permission(dataset, current_user, db.session)
|
||||
|
||||
match action:
|
||||
case "enable":
|
||||
MetadataService.enable_built_in_field(dataset)
|
||||
MetadataService.enable_built_in_field(db.session(), dataset)
|
||||
case "disable":
|
||||
MetadataService.disable_built_in_field(dataset)
|
||||
MetadataService.disable_built_in_field(db.session(), dataset)
|
||||
# Frontend callers only await success and invalidate metadata caches; no response body is consumed.
|
||||
return "", 204
|
||||
|
||||
@@ -191,11 +194,11 @@ class DocumentMetadataEditApi(Resource):
|
||||
dataset = DatasetService.get_dataset(dataset_id_str)
|
||||
if dataset is None:
|
||||
raise NotFound("Dataset not found.")
|
||||
DatasetService.check_dataset_permission(dataset, current_user)
|
||||
DatasetService.check_dataset_permission(dataset, current_user, db.session)
|
||||
|
||||
metadata_args = MetadataOperationData.model_validate(console_ns.payload or {})
|
||||
|
||||
MetadataService.update_documents_metadata(dataset, metadata_args, current_user)
|
||||
MetadataService.update_documents_metadata(db.session(), dataset, metadata_args, current_user)
|
||||
|
||||
# Frontend callers only await success and invalidate caches; no response body is consumed.
|
||||
return "", 204
|
||||
|
||||
@@ -20,6 +20,7 @@ from controllers.console.app.error import (
|
||||
)
|
||||
from controllers.console.explore.wraps import InstalledAppResource
|
||||
from core.errors.error import ModelCurrentlyNotSupportError, ProviderTokenNotInitError, QuotaExceededError
|
||||
from extensions.ext_database import db
|
||||
from graphon.model_runtime.errors.invoke import InvokeError
|
||||
from models.model import InstalledApp
|
||||
from services.audio_service import AudioService
|
||||
@@ -99,7 +100,13 @@ class ChatTextApi(InstalledAppResource):
|
||||
text = payload.text
|
||||
voice = payload.voice
|
||||
|
||||
response = AudioService.transcript_tts(app_model=app_model, text=text, voice=voice, message_id=message_id)
|
||||
response = AudioService.transcript_tts(
|
||||
app_model=app_model,
|
||||
session=db.session,
|
||||
text=text,
|
||||
voice=voice,
|
||||
message_id=message_id,
|
||||
)
|
||||
return response
|
||||
except services.errors.app_model_config.AppModelConfigBrokenError:
|
||||
logger.exception("App model config broken.")
|
||||
|
||||
@@ -85,6 +85,7 @@ def _published_app_filter():
|
||||
class InstalledAppInfoResponse(ResponseModel):
|
||||
id: str
|
||||
name: str | None = None
|
||||
description: str | None = None
|
||||
mode: str | None = None
|
||||
icon_type: str | None = None
|
||||
icon: str | None = None
|
||||
@@ -123,6 +124,7 @@ class InstalledAppResponse(ResponseModel):
|
||||
return {
|
||||
"id": _safe_primitive(getattr(value, "id", "")) or "",
|
||||
"name": _safe_primitive(getattr(value, "name", None)),
|
||||
"description": _safe_primitive(getattr(value, "description", None)),
|
||||
"mode": _safe_primitive(getattr(value, "mode", None)),
|
||||
"icon_type": _safe_primitive(getattr(value, "icon_type", None)),
|
||||
"icon": _safe_primitive(getattr(value, "icon", None)),
|
||||
@@ -175,7 +177,7 @@ class InstalledAppsListApi(Resource):
|
||||
|
||||
if current_user.current_tenant is None:
|
||||
raise ValueError("current_user.current_tenant must not be None")
|
||||
current_user.role = TenantService.get_user_role(current_user, current_user.current_tenant)
|
||||
current_user.role = TenantService.get_user_role(current_user, current_user.current_tenant, session=db.session)
|
||||
installed_app_list: list[dict[str, Any]] = []
|
||||
for installed_app, app_model in installed_apps:
|
||||
installed_app_list.append(
|
||||
|
||||
@@ -27,7 +27,11 @@ from controllers.console.wraps import with_current_user
|
||||
from core.app.entities.app_invoke_entities import InvokeFrom
|
||||
from core.errors.error import ModelCurrentlyNotSupportError, ProviderTokenNotInitError, QuotaExceededError
|
||||
from fields.conversation_fields import ResultResponse
|
||||
from fields.message_fields import MessageInfiniteScrollPagination, MessageListItem, SuggestedQuestionsResponse
|
||||
from fields.message_fields import (
|
||||
ExploreMessageInfiniteScrollPagination,
|
||||
ExploreMessageListItem,
|
||||
SuggestedQuestionsResponse,
|
||||
)
|
||||
from graphon.model_runtime.errors.invoke import InvokeError
|
||||
from libs import helper
|
||||
from models import Account
|
||||
@@ -56,7 +60,7 @@ register_schema_models(console_ns, MessageListQuery, MessageFeedbackPayload, Mor
|
||||
register_response_schema_models(
|
||||
console_ns,
|
||||
GeneratedAppResponse,
|
||||
MessageInfiniteScrollPagination,
|
||||
ExploreMessageInfiniteScrollPagination,
|
||||
ResultResponse,
|
||||
SuggestedQuestionsResponse,
|
||||
)
|
||||
@@ -68,7 +72,7 @@ register_response_schema_models(
|
||||
)
|
||||
class MessageListApi(InstalledAppResource):
|
||||
@console_ns.doc(params=query_params_from_model(MessageListQuery))
|
||||
@console_ns.response(200, "Success", console_ns.models[MessageInfiniteScrollPagination.__name__])
|
||||
@console_ns.response(200, "Success", console_ns.models[ExploreMessageInfiniteScrollPagination.__name__])
|
||||
@with_current_user
|
||||
def get(self, current_user: Account, installed_app: InstalledApp):
|
||||
app_model = installed_app.app
|
||||
@@ -88,9 +92,9 @@ class MessageListApi(InstalledAppResource):
|
||||
str(args.first_id) if args.first_id else None,
|
||||
args.limit,
|
||||
)
|
||||
adapter = TypeAdapter(MessageListItem)
|
||||
adapter = TypeAdapter(ExploreMessageListItem)
|
||||
items = [adapter.validate_python(message, from_attributes=True) for message in pagination.data]
|
||||
return MessageInfiniteScrollPagination(
|
||||
return ExploreMessageInfiniteScrollPagination(
|
||||
limit=pagination.limit,
|
||||
has_more=pagination.has_more,
|
||||
data=items,
|
||||
|
||||
@@ -419,7 +419,13 @@ class TrialChatTextApi(TrialAppResource):
|
||||
app_id = app_model.id
|
||||
user_id = current_user.id
|
||||
|
||||
response = AudioService.transcript_tts(app_model=app_model, text=text, voice=voice, message_id=message_id)
|
||||
response = AudioService.transcript_tts(
|
||||
app_model=app_model,
|
||||
session=db.session,
|
||||
text=text,
|
||||
voice=voice,
|
||||
message_id=message_id,
|
||||
)
|
||||
RecommendedAppService.add_trial_app_record(db.session, app_id, user_id)
|
||||
return response
|
||||
except services.errors.app_model_config.AppModelConfigBrokenError:
|
||||
|
||||
@@ -50,7 +50,7 @@ def get_init_status() -> InitStatusResponse:
|
||||
@only_edition_self_hosted
|
||||
def validate_init_password(payload: InitValidatePayload) -> InitValidateResponse:
|
||||
"""Validate initialization password."""
|
||||
tenant_count = TenantService.get_tenant_count()
|
||||
tenant_count = TenantService.get_tenant_count(session=db.session)
|
||||
if tenant_count > 0:
|
||||
raise AlreadySetupError()
|
||||
|
||||
|
||||
@@ -79,7 +79,7 @@ def setup_system(payload: SetupRequestPayload) -> SetupResponse:
|
||||
if get_setup_status():
|
||||
raise AlreadySetupError()
|
||||
|
||||
tenant_count = TenantService.get_tenant_count()
|
||||
tenant_count = TenantService.get_tenant_count(session=db.session)
|
||||
if tenant_count > 0:
|
||||
raise AlreadySetupError()
|
||||
|
||||
@@ -94,6 +94,7 @@ def setup_system(payload: SetupRequestPayload) -> SetupResponse:
|
||||
password=payload.password,
|
||||
ip_address=extract_remote_ip(request),
|
||||
language=payload.language,
|
||||
session=db.session,
|
||||
)
|
||||
|
||||
return SetupResponse(result="success")
|
||||
|
||||
@@ -8,6 +8,7 @@ from pydantic import BaseModel, Field
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
from werkzeug.exceptions import BadRequest, InternalServerError, NotFound
|
||||
|
||||
from controllers.common.controller_schemas import WorkflowUpdatePayload
|
||||
from controllers.common.fields import GeneratedAppResponse, SimpleResultResponse
|
||||
from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models
|
||||
from controllers.console import console_ns
|
||||
@@ -80,6 +81,13 @@ class SnippetDraftConfigResponse(BaseModel):
|
||||
parallel_depth_limit: int
|
||||
|
||||
|
||||
class SnippetWorkflowPaginationResponse(BaseModel):
|
||||
items: list[SnippetWorkflowResponse]
|
||||
page: int
|
||||
limit: int
|
||||
has_more: bool
|
||||
|
||||
|
||||
register_schema_models(
|
||||
console_ns,
|
||||
SnippetDraftSyncPayload,
|
||||
@@ -89,6 +97,7 @@ register_schema_models(
|
||||
SnippetLoopNodeRunPayload,
|
||||
SnippetWorkflowListQuery,
|
||||
WorkflowRunQuery,
|
||||
WorkflowUpdatePayload,
|
||||
PublishWorkflowPayload,
|
||||
)
|
||||
register_response_schema_models(
|
||||
@@ -98,6 +107,7 @@ register_response_schema_models(
|
||||
SimpleResultResponse,
|
||||
SnippetDraftConfigResponse,
|
||||
SnippetWorkflowResponse,
|
||||
SnippetWorkflowPaginationResponse,
|
||||
WorkflowPublishResponse,
|
||||
WorkflowPaginationResponse,
|
||||
WorkflowRestoreResponse,
|
||||
@@ -157,7 +167,6 @@ class SnippetDraftWorkflowApi(Resource):
|
||||
@account_initialization_required
|
||||
@get_snippet
|
||||
@edit_permission_required
|
||||
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.SNIPPETS_MANAGE, resource_required=False)
|
||||
def get(self, snippet: CustomizedSnippet):
|
||||
"""Get draft workflow for snippet."""
|
||||
snippet_service = _snippet_service()
|
||||
@@ -226,7 +235,6 @@ class SnippetDraftConfigApi(Resource):
|
||||
@account_initialization_required
|
||||
@get_snippet
|
||||
@edit_permission_required
|
||||
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.SNIPPETS_MANAGE, resource_required=False)
|
||||
def get(self, snippet: CustomizedSnippet):
|
||||
"""Get snippet draft workflow configuration limits."""
|
||||
return {
|
||||
@@ -248,7 +256,6 @@ class SnippetPublishedWorkflowApi(Resource):
|
||||
@account_initialization_required
|
||||
@get_snippet
|
||||
@edit_permission_required
|
||||
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.SNIPPETS_MANAGE, resource_required=False)
|
||||
def get(self, snippet: CustomizedSnippet):
|
||||
"""Get published workflow for snippet."""
|
||||
if not snippet.is_published:
|
||||
@@ -313,7 +320,6 @@ class SnippetDefaultBlockConfigsApi(Resource):
|
||||
@account_initialization_required
|
||||
@get_snippet
|
||||
@edit_permission_required
|
||||
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.SNIPPETS_MANAGE, resource_required=False)
|
||||
def get(self, snippet: CustomizedSnippet):
|
||||
"""Get default block configurations for snippet workflow."""
|
||||
snippet_service = _snippet_service()
|
||||
@@ -329,14 +335,16 @@ class SnippetPublishedAllWorkflowApi(Resource):
|
||||
@console_ns.response(
|
||||
200,
|
||||
"Published workflows retrieved successfully",
|
||||
console_ns.models[WorkflowPaginationResponse.__name__],
|
||||
console_ns.models[SnippetWorkflowPaginationResponse.__name__],
|
||||
)
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@get_snippet
|
||||
@edit_permission_required
|
||||
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.SNIPPETS_MANAGE, resource_required=False)
|
||||
@rbac_permission_required(
|
||||
RBACResourceScope.WORKSPACE, RBACPermission.SNIPPETS_CREATE_AND_MODIFY, resource_required=False
|
||||
)
|
||||
def get(self, snippet: CustomizedSnippet):
|
||||
"""Get all published workflow versions for snippet."""
|
||||
args = SnippetWorkflowListQuery.model_validate(request.args.to_dict(flat=True))
|
||||
@@ -350,7 +358,7 @@ class SnippetPublishedAllWorkflowApi(Resource):
|
||||
limit=args.limit,
|
||||
)
|
||||
|
||||
return WorkflowPaginationResponse.model_validate(
|
||||
response = SnippetWorkflowPaginationResponse.model_validate(
|
||||
{
|
||||
"items": workflows,
|
||||
"page": args.page,
|
||||
@@ -359,6 +367,9 @@ class SnippetPublishedAllWorkflowApi(Resource):
|
||||
},
|
||||
from_attributes=True,
|
||||
).model_dump(mode="json")
|
||||
for item in response["items"]:
|
||||
item["input_fields"] = snippet.input_fields_list
|
||||
return response
|
||||
|
||||
|
||||
@console_ns.route("/snippets/<uuid:snippet_id>/workflows/<string:workflow_id>/restore")
|
||||
@@ -402,6 +413,49 @@ class SnippetDraftWorkflowRestoreApi(Resource):
|
||||
}
|
||||
|
||||
|
||||
@console_ns.route("/snippets/<uuid:snippet_id>/workflows/<string:workflow_id>")
|
||||
class SnippetWorkflowByIdApi(Resource):
|
||||
@console_ns.doc("update_snippet_workflow_by_id")
|
||||
@console_ns.doc(description="Update published snippet workflow attributes")
|
||||
@console_ns.doc(params={"snippet_id": "Snippet ID", "workflow_id": "Workflow ID"})
|
||||
@console_ns.expect(console_ns.models[WorkflowUpdatePayload.__name__])
|
||||
@console_ns.response(200, "Workflow updated successfully", console_ns.models[SnippetWorkflowResponse.__name__])
|
||||
@console_ns.response(400, "No valid fields to update")
|
||||
@console_ns.response(404, "Workflow not found")
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_current_user
|
||||
@get_snippet
|
||||
@edit_permission_required
|
||||
@rbac_permission_required(
|
||||
RBACResourceScope.WORKSPACE, RBACPermission.SNIPPETS_CREATE_AND_MODIFY, resource_required=False
|
||||
)
|
||||
def patch(self, current_user: Account, snippet: CustomizedSnippet, workflow_id: str):
|
||||
"""Update a published snippet workflow version's display metadata."""
|
||||
payload = WorkflowUpdatePayload.model_validate(console_ns.payload or {})
|
||||
update_data = payload.model_dump(exclude_unset=True)
|
||||
|
||||
if not update_data:
|
||||
return {"message": "No valid fields to update"}, 400
|
||||
|
||||
snippet_service = _snippet_service()
|
||||
with _snippet_session_maker().begin() as session:
|
||||
workflow = snippet_service.update_workflow(
|
||||
session=session,
|
||||
snippet=snippet,
|
||||
workflow_id=workflow_id,
|
||||
account=current_user,
|
||||
data=update_data,
|
||||
)
|
||||
if not workflow:
|
||||
raise NotFound("Workflow not found")
|
||||
|
||||
response = SnippetWorkflowResponse.model_validate(workflow, from_attributes=True).model_dump(mode="json")
|
||||
response["input_fields"] = snippet.input_fields_list
|
||||
return response
|
||||
|
||||
|
||||
@console_ns.route("/snippets/<uuid:snippet_id>/workflow-runs")
|
||||
class SnippetWorkflowRunsApi(Resource):
|
||||
@console_ns.doc("list_snippet_workflow_runs")
|
||||
@@ -503,9 +557,6 @@ class SnippetDraftNodeRunApi(Resource):
|
||||
@with_current_user
|
||||
@get_snippet
|
||||
@edit_permission_required
|
||||
@rbac_permission_required(
|
||||
RBACResourceScope.WORKSPACE, RBACPermission.SNIPPETS_CREATE_AND_MODIFY, resource_required=False
|
||||
)
|
||||
def post(self, current_user: Account, snippet: CustomizedSnippet, node_id: str):
|
||||
"""
|
||||
Run a single node in snippet draft workflow.
|
||||
@@ -594,9 +645,6 @@ class SnippetDraftRunIterationNodeApi(Resource):
|
||||
@with_current_user
|
||||
@get_snippet
|
||||
@edit_permission_required
|
||||
@rbac_permission_required(
|
||||
RBACResourceScope.WORKSPACE, RBACPermission.SNIPPETS_CREATE_AND_MODIFY, resource_required=False
|
||||
)
|
||||
def post(self, current_user: Account, snippet: CustomizedSnippet, node_id: str):
|
||||
"""
|
||||
Run a draft workflow iteration node for snippet.
|
||||
@@ -642,9 +690,6 @@ class SnippetDraftRunLoopNodeApi(Resource):
|
||||
@with_current_user
|
||||
@get_snippet
|
||||
@edit_permission_required
|
||||
@rbac_permission_required(
|
||||
RBACResourceScope.WORKSPACE, RBACPermission.SNIPPETS_CREATE_AND_MODIFY, resource_required=False
|
||||
)
|
||||
def post(self, current_user: Account, snippet: CustomizedSnippet, node_id: str):
|
||||
"""
|
||||
Run a draft workflow loop node for snippet.
|
||||
@@ -688,9 +733,6 @@ class SnippetDraftWorkflowRunApi(Resource):
|
||||
@with_current_user
|
||||
@get_snippet
|
||||
@edit_permission_required
|
||||
@rbac_permission_required(
|
||||
RBACResourceScope.WORKSPACE, RBACPermission.SNIPPETS_CREATE_AND_MODIFY, resource_required=False
|
||||
)
|
||||
def post(self, current_user: Account, snippet: CustomizedSnippet):
|
||||
"""
|
||||
Run draft workflow for snippet.
|
||||
@@ -729,9 +771,6 @@ class SnippetWorkflowTaskStopApi(Resource):
|
||||
@account_initialization_required
|
||||
@get_snippet
|
||||
@edit_permission_required
|
||||
@rbac_permission_required(
|
||||
RBACResourceScope.WORKSPACE, RBACPermission.SNIPPETS_CREATE_AND_MODIFY, resource_required=False
|
||||
)
|
||||
def post(self, snippet: CustomizedSnippet, task_id: str):
|
||||
"""
|
||||
Stop a running snippet workflow task.
|
||||
|
||||
@@ -34,11 +34,8 @@ from controllers.console.app.workflow_draft_variable import (
|
||||
)
|
||||
from controllers.console.snippets.snippet_workflow import get_snippet
|
||||
from controllers.console.wraps import (
|
||||
RBACPermission,
|
||||
RBACResourceScope,
|
||||
account_initialization_required,
|
||||
edit_permission_required,
|
||||
rbac_permission_required,
|
||||
setup_required,
|
||||
with_current_user,
|
||||
)
|
||||
@@ -105,7 +102,6 @@ class SnippetWorkflowVariableCollectionApi(Resource):
|
||||
)
|
||||
@_snippet_draft_var_prerequisite
|
||||
@marshal_with(workflow_draft_variable_list_without_value_model)
|
||||
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.SNIPPETS_MANAGE, resource_required=False)
|
||||
def get(self, current_user: Account, snippet: CustomizedSnippet) -> WorkflowDraftVariableList:
|
||||
args = WorkflowDraftVariableListQuery.model_validate(request.args.to_dict(flat=True)) # type: ignore
|
||||
|
||||
@@ -129,9 +125,6 @@ class SnippetWorkflowVariableCollectionApi(Resource):
|
||||
@console_ns.doc(description="Delete all draft workflow variables for the current user (snippet scope)")
|
||||
@console_ns.response(204, "Workflow variables deleted successfully")
|
||||
@_snippet_draft_var_prerequisite
|
||||
@rbac_permission_required(
|
||||
RBACResourceScope.WORKSPACE, RBACPermission.SNIPPETS_CREATE_AND_MODIFY, resource_required=False
|
||||
)
|
||||
def delete(self, current_user: Account, snippet: CustomizedSnippet) -> Response:
|
||||
draft_var_srv = WorkflowDraftVariableService(session=db.session())
|
||||
draft_var_srv.delete_user_workflow_variables(snippet.id, user_id=current_user.id)
|
||||
|
||||
@@ -4,6 +4,7 @@ from typing import cast
|
||||
|
||||
from flask import Request as FlaskRequest
|
||||
|
||||
from extensions.ext_database import db
|
||||
from extensions.ext_socketio import sio
|
||||
from libs.passport import PassportService
|
||||
from libs.token import extract_access_token
|
||||
@@ -43,7 +44,7 @@ def socket_connect(sid, environ, auth):
|
||||
return False
|
||||
|
||||
with sio.app.app_context():
|
||||
user = AccountService.load_logged_in_account(account_id=user_id)
|
||||
user = AccountService.load_logged_in_account(account_id=user_id, session=db.session)
|
||||
if not user:
|
||||
logging.warning("Socket connect rejected: user not found (user_id=%s, sid=%s)", user_id, sid)
|
||||
return False
|
||||
|
||||
@@ -4,12 +4,17 @@ 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 werkzeug.exceptions import Forbidden
|
||||
|
||||
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.wraps import (
|
||||
RBACPermission,
|
||||
RBACResourceScope,
|
||||
account_initialization_required,
|
||||
edit_permission_required,
|
||||
setup_required,
|
||||
@@ -18,9 +23,10 @@ from controllers.console.wraps import (
|
||||
)
|
||||
from extensions.ext_database import db
|
||||
from fields.base import ResponseModel
|
||||
from libs.login import login_required
|
||||
from libs.login import current_account_with_tenant, login_required
|
||||
from models import Account
|
||||
from models.enums import TagType
|
||||
from models.model import Tag
|
||||
from services.tag_service import (
|
||||
SaveTagPayload,
|
||||
TagBindingCreatePayload,
|
||||
@@ -91,6 +97,30 @@ register_schema_models(
|
||||
register_response_schema_models(console_ns, SimpleResultResponse)
|
||||
|
||||
|
||||
def _enforce_snippet_tag_rbac_if_needed(tag_type: TagType | str | None) -> 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,
|
||||
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:
|
||||
if not dify_config.RBAC_ENABLED:
|
||||
return
|
||||
|
||||
tag_type = db.session.scalar(select(Tag.type).where(Tag.id == tag_id).limit(1))
|
||||
_enforce_snippet_tag_rbac_if_needed(tag_type)
|
||||
|
||||
|
||||
@console_ns.route("/tags")
|
||||
class TagListApi(Resource):
|
||||
@setup_required
|
||||
@@ -122,6 +152,7 @@ class TagListApi(Resource):
|
||||
raise Forbidden()
|
||||
|
||||
payload = TagBasePayload.model_validate(console_ns.payload or {})
|
||||
_enforce_snippet_tag_rbac_if_needed(payload.type)
|
||||
tag = TagService.save_tags(SaveTagPayload(name=payload.name, type=payload.type), db.session)
|
||||
|
||||
response = TagResponse.model_validate(
|
||||
@@ -146,6 +177,7 @@ class TagUpdateDeleteApi(Resource):
|
||||
raise Forbidden()
|
||||
|
||||
payload = TagUpdateRequestPayload.model_validate(console_ns.payload or {})
|
||||
_enforce_snippet_tag_rbac_by_tag_id(tag_id_str)
|
||||
tag = TagService.update_tags(UpdateTagPayload(name=payload.name), tag_id_str, db.session)
|
||||
|
||||
binding_count = TagService.get_tag_binding_count(tag_id_str, db.session)
|
||||
@@ -164,6 +196,7 @@ class TagUpdateDeleteApi(Resource):
|
||||
def delete(self, 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)
|
||||
|
||||
return "", 204
|
||||
@@ -184,6 +217,7 @@ def _create_tag_bindings(current_user: Account) -> tuple[dict[str, str], int]:
|
||||
_require_tag_binding_edit_permission(current_user)
|
||||
|
||||
payload = TagBindingPayload.model_validate(console_ns.payload or {})
|
||||
_enforce_snippet_tag_rbac_if_needed(payload.type)
|
||||
TagService.save_tag_binding(
|
||||
TagBindingCreatePayload(
|
||||
tag_ids=payload.tag_ids,
|
||||
@@ -199,6 +233,7 @@ def _remove_tag_bindings(current_user: Account) -> tuple[dict[str, str], int]:
|
||||
_require_tag_binding_edit_permission(current_user)
|
||||
|
||||
payload = TagBindingRemovePayload.model_validate(console_ns.payload or {})
|
||||
_enforce_snippet_tag_rbac_if_needed(payload.type)
|
||||
TagService.delete_tag_binding(
|
||||
TagBindingDeletePayload(
|
||||
tag_ids=payload.tag_ids,
|
||||
|
||||
@@ -328,7 +328,7 @@ class AccountNameApi(Resource):
|
||||
def post(self, current_user: Account):
|
||||
payload = console_ns.payload or {}
|
||||
args = AccountNamePayload.model_validate(payload)
|
||||
updated_account = AccountService.update_account(current_user, name=args.name)
|
||||
updated_account = AccountService.update_account(current_user, session=db.session, name=args.name)
|
||||
|
||||
return _serialize_account(updated_account)
|
||||
|
||||
@@ -374,7 +374,7 @@ class AccountAvatarApi(Resource):
|
||||
payload = console_ns.payload or {}
|
||||
args = AccountAvatarPayload.model_validate(payload)
|
||||
|
||||
updated_account = AccountService.update_account(current_user, avatar=args.avatar)
|
||||
updated_account = AccountService.update_account(current_user, session=db.session, avatar=args.avatar)
|
||||
|
||||
return _serialize_account(updated_account)
|
||||
|
||||
@@ -391,7 +391,9 @@ class AccountInterfaceLanguageApi(Resource):
|
||||
payload = console_ns.payload or {}
|
||||
args = AccountInterfaceLanguagePayload.model_validate(payload)
|
||||
|
||||
updated_account = AccountService.update_account(current_user, interface_language=args.interface_language)
|
||||
updated_account = AccountService.update_account(
|
||||
current_user, session=db.session, interface_language=args.interface_language
|
||||
)
|
||||
|
||||
return _serialize_account(updated_account)
|
||||
|
||||
@@ -408,7 +410,9 @@ class AccountInterfaceThemeApi(Resource):
|
||||
payload = console_ns.payload or {}
|
||||
args = AccountInterfaceThemePayload.model_validate(payload)
|
||||
|
||||
updated_account = AccountService.update_account(current_user, interface_theme=args.interface_theme)
|
||||
updated_account = AccountService.update_account(
|
||||
current_user, session=db.session, interface_theme=args.interface_theme
|
||||
)
|
||||
|
||||
return _serialize_account(updated_account)
|
||||
|
||||
@@ -425,7 +429,7 @@ class AccountTimezoneApi(Resource):
|
||||
payload = console_ns.payload or {}
|
||||
args = AccountTimezonePayload.model_validate(payload)
|
||||
|
||||
updated_account = AccountService.update_account(current_user, timezone=args.timezone)
|
||||
updated_account = AccountService.update_account(current_user, session=db.session, timezone=args.timezone)
|
||||
|
||||
return _serialize_account(updated_account)
|
||||
|
||||
@@ -443,7 +447,8 @@ class AccountPasswordApi(Resource):
|
||||
args = AccountPasswordPayload.model_validate(payload)
|
||||
|
||||
try:
|
||||
AccountService.update_account_password(current_user, args.password, args.new_password)
|
||||
assert args.password is not None
|
||||
AccountService.update_account_password(current_user, args.password, args.new_password, session=db.session)
|
||||
except ServiceCurrentPasswordIncorrectError:
|
||||
raise CurrentPasswordIncorrectError()
|
||||
|
||||
@@ -731,7 +736,7 @@ class ChangeEmailResetApi(Resource):
|
||||
if AccountService.is_account_in_freeze(normalized_new_email):
|
||||
raise AccountInFreezeError()
|
||||
|
||||
if not AccountService.check_email_unique(normalized_new_email):
|
||||
if not AccountService.check_email_unique(normalized_new_email, session=db.session):
|
||||
raise EmailAlreadyInUseError()
|
||||
|
||||
reset_data = AccountService.get_change_email_data(args.token)
|
||||
@@ -755,7 +760,9 @@ class ChangeEmailResetApi(Resource):
|
||||
# legitimately verified token.
|
||||
AccountService.revoke_change_email_token(args.token)
|
||||
|
||||
updated_account = AccountService.update_account_email(current_user, email=normalized_new_email)
|
||||
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,
|
||||
@@ -775,6 +782,6 @@ class CheckEmailUnique(Resource):
|
||||
normalized_email = args.email.lower()
|
||||
if AccountService.is_account_in_freeze(normalized_email):
|
||||
raise AccountInFreezeError()
|
||||
if not AccountService.check_email_unique(normalized_email):
|
||||
if not AccountService.check_email_unique(normalized_email, session=db.session):
|
||||
raise EmailAlreadyInUseError()
|
||||
return {"result": "success"}
|
||||
|
||||
@@ -169,7 +169,7 @@ class EndpointCollectionApi(Resource):
|
||||
@setup_required
|
||||
@login_required
|
||||
@is_admin_or_owner_required
|
||||
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_MANAGE, resource_required=False)
|
||||
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_MODEL_CONFIG, resource_required=False)
|
||||
@account_initialization_required
|
||||
@with_current_user_id
|
||||
@with_current_tenant_id
|
||||
@@ -198,7 +198,7 @@ class DeprecatedEndpointCreateApi(Resource):
|
||||
@setup_required
|
||||
@login_required
|
||||
@is_admin_or_owner_required
|
||||
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_MANAGE, resource_required=False)
|
||||
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_MODEL_CONFIG, resource_required=False)
|
||||
@account_initialization_required
|
||||
@with_current_user_id
|
||||
@with_current_tenant_id
|
||||
@@ -290,7 +290,7 @@ class EndpointItemApi(Resource):
|
||||
@setup_required
|
||||
@login_required
|
||||
@is_admin_or_owner_required
|
||||
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_MANAGE, resource_required=False)
|
||||
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_MODEL_CONFIG, resource_required=False)
|
||||
@account_initialization_required
|
||||
@with_current_user_id
|
||||
@with_current_tenant_id
|
||||
@@ -310,7 +310,7 @@ class EndpointItemApi(Resource):
|
||||
@setup_required
|
||||
@login_required
|
||||
@is_admin_or_owner_required
|
||||
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_MANAGE, resource_required=False)
|
||||
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_MODEL_CONFIG, resource_required=False)
|
||||
@account_initialization_required
|
||||
@with_current_user_id
|
||||
@with_current_tenant_id
|
||||
@@ -340,7 +340,7 @@ class DeprecatedEndpointDeleteApi(Resource):
|
||||
@setup_required
|
||||
@login_required
|
||||
@is_admin_or_owner_required
|
||||
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_MANAGE, resource_required=False)
|
||||
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_MODEL_CONFIG, resource_required=False)
|
||||
@account_initialization_required
|
||||
@with_current_user_id
|
||||
@with_current_tenant_id
|
||||
@@ -371,7 +371,7 @@ class DeprecatedEndpointUpdateApi(Resource):
|
||||
@setup_required
|
||||
@login_required
|
||||
@is_admin_or_owner_required
|
||||
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_MANAGE, resource_required=False)
|
||||
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_MODEL_CONFIG, resource_required=False)
|
||||
@account_initialization_required
|
||||
@with_current_user_id
|
||||
@with_current_tenant_id
|
||||
@@ -394,7 +394,7 @@ class EndpointEnableApi(Resource):
|
||||
@setup_required
|
||||
@login_required
|
||||
@is_admin_or_owner_required
|
||||
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_MANAGE, resource_required=False)
|
||||
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_MODEL_CONFIG, resource_required=False)
|
||||
@account_initialization_required
|
||||
@with_current_user_id
|
||||
@with_current_tenant_id
|
||||
@@ -422,7 +422,7 @@ class EndpointDisableApi(Resource):
|
||||
@setup_required
|
||||
@login_required
|
||||
@is_admin_or_owner_required
|
||||
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_MANAGE, resource_required=False)
|
||||
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_MODEL_CONFIG, resource_required=False)
|
||||
@account_initialization_required
|
||||
@with_current_user_id
|
||||
@with_current_tenant_id
|
||||
|
||||
@@ -131,7 +131,7 @@ def _normalize_invitee_emails(emails: list[str]) -> list[str]:
|
||||
def _count_new_member_invites(tenant_id: str, emails: list[str]) -> int:
|
||||
new_member_count = 0
|
||||
for email in emails:
|
||||
account = AccountService.get_account_by_email_with_case_fallback(email)
|
||||
account = AccountService.get_account_by_email_with_case_fallback(db.session, email)
|
||||
if not account:
|
||||
new_member_count += 1
|
||||
continue
|
||||
@@ -186,7 +186,7 @@ class MemberListApi(Resource):
|
||||
current_user, _ = current_account_with_tenant()
|
||||
if not current_user.current_tenant:
|
||||
raise ValueError("No current tenant")
|
||||
members = TenantService.get_tenant_members(current_user.current_tenant)
|
||||
members = TenantService.get_tenant_members(current_user.current_tenant, session=db.session)
|
||||
if dify_config.RBAC_ENABLED:
|
||||
member_ids = [member.id for member in members]
|
||||
member_roles = enterprise_rbac_service.RBACService.MemberRoles.batch_get(
|
||||
@@ -273,6 +273,7 @@ class MemberInviteEmailApi(Resource):
|
||||
language=interface_language,
|
||||
role=invitee_role,
|
||||
inviter=inviter,
|
||||
session=db.session,
|
||||
)
|
||||
encoded_invitee_email = parse.quote(invitee_email)
|
||||
invitation_results.append(
|
||||
@@ -317,7 +318,9 @@ class MemberCancelInviteApi(Resource):
|
||||
abort(404)
|
||||
else:
|
||||
try:
|
||||
TenantService.remove_member_from_tenant(current_user.current_tenant, member, current_user)
|
||||
TenantService.remove_member_from_tenant(
|
||||
current_user.current_tenant, member, current_user, session=db.session
|
||||
)
|
||||
except services.errors.account.CannotOperateSelfError as e:
|
||||
return {"code": "cannot-operate-self", "message": str(e)}, 400
|
||||
except services.errors.account.NoPermissionError as e:
|
||||
@@ -360,7 +363,9 @@ class MemberUpdateRoleApi(Resource):
|
||||
|
||||
try:
|
||||
assert member is not None, "Member not found"
|
||||
TenantService.update_member_role(current_user.current_tenant, member, new_role, current_user)
|
||||
TenantService.update_member_role(
|
||||
current_user.current_tenant, member, new_role, current_user, session=db.session
|
||||
)
|
||||
except services.errors.account.CannotOperateSelfError as e:
|
||||
return {"code": "cannot-operate-self", "message": str(e)}, 400
|
||||
except services.errors.account.NoPermissionError as e:
|
||||
@@ -387,7 +392,7 @@ class DatasetOperatorMemberListApi(Resource):
|
||||
def get(self, current_user: Account):
|
||||
if not current_user.current_tenant:
|
||||
raise ValueError("No current tenant")
|
||||
members = TenantService.get_dataset_operator_members(current_user.current_tenant)
|
||||
members = TenantService.get_dataset_operator_members(current_user.current_tenant, session=db.session)
|
||||
member_models = TypeAdapter(list[AccountWithRole]).validate_python(members, from_attributes=True)
|
||||
response = AccountWithRoleList(accounts=member_models)
|
||||
return response.model_dump(mode="json"), 200
|
||||
@@ -413,7 +418,7 @@ class SendOwnerTransferEmailApi(Resource):
|
||||
# check if the current user is the owner of the workspace
|
||||
if not current_user.current_tenant:
|
||||
raise ValueError("No current tenant")
|
||||
if not TenantService.is_owner(current_user, current_user.current_tenant):
|
||||
if not TenantService.is_owner(current_user, current_user.current_tenant, session=db.session):
|
||||
raise NotOwnerError()
|
||||
|
||||
if args.language is not None and args.language == "zh-Hans":
|
||||
@@ -448,7 +453,7 @@ class OwnerTransferCheckApi(Resource):
|
||||
# check if the current user is the owner of the workspace
|
||||
if not current_user.current_tenant:
|
||||
raise ValueError("No current tenant")
|
||||
if not TenantService.is_owner(current_user, current_user.current_tenant):
|
||||
if not TenantService.is_owner(current_user, current_user.current_tenant, session=db.session):
|
||||
raise NotOwnerError()
|
||||
|
||||
user_email = current_user.email
|
||||
@@ -494,7 +499,7 @@ class OwnerTransfer(Resource):
|
||||
# check if the current user is the owner of the workspace
|
||||
if not current_user.current_tenant:
|
||||
raise ValueError("No current tenant")
|
||||
if not TenantService.is_owner(current_user, current_user.current_tenant):
|
||||
if not TenantService.is_owner(current_user, current_user.current_tenant, session=db.session):
|
||||
raise NotOwnerError()
|
||||
|
||||
if current_user.id == str(member_id):
|
||||
@@ -516,12 +521,14 @@ class OwnerTransfer(Resource):
|
||||
|
||||
if not current_user.current_tenant:
|
||||
raise ValueError("No current tenant")
|
||||
if not TenantService.is_member(member, current_user.current_tenant):
|
||||
if not TenantService.is_member(member, current_user.current_tenant, session=db.session):
|
||||
raise MemberNotInTenantError()
|
||||
|
||||
try:
|
||||
assert member is not None, "Member not found"
|
||||
TenantService.update_member_role(current_user.current_tenant, member, "owner", current_user)
|
||||
TenantService.update_member_role(
|
||||
current_user.current_tenant, member, "owner", current_user, session=db.session
|
||||
)
|
||||
|
||||
AccountService.send_new_owner_transfer_notify_email(
|
||||
account=member,
|
||||
|
||||
@@ -18,6 +18,7 @@ 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
|
||||
@@ -352,7 +353,7 @@ class ModelProviderPaymentCheckoutUrlApi(Resource):
|
||||
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)
|
||||
BillingService.is_tenant_owner_or_admin(db.session, current_user)
|
||||
data = BillingService.get_model_provider_payment_link(
|
||||
provider_name=provider,
|
||||
tenant_id=current_tenant_id,
|
||||
|
||||
@@ -793,7 +793,6 @@ class PluginFetchInstallTasksApi(Resource):
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_INSTALL, resource_required=False)
|
||||
@plugin_permission_required(install_required=True)
|
||||
@with_current_tenant_id
|
||||
def get(self, tenant_id: str):
|
||||
@@ -811,7 +810,6 @@ class PluginFetchInstallTaskApi(Resource):
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_INSTALL, resource_required=False)
|
||||
@plugin_permission_required(install_required=True)
|
||||
@with_current_tenant_id
|
||||
def get(self, tenant_id: str, task_id: str):
|
||||
@@ -827,7 +825,6 @@ class PluginDeleteInstallTaskApi(Resource):
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_INSTALL, resource_required=False)
|
||||
@plugin_permission_required(install_required=True)
|
||||
@with_current_tenant_id
|
||||
def post(self, tenant_id: str, task_id: str):
|
||||
@@ -843,7 +840,6 @@ class PluginDeleteAllInstallTaskItemsApi(Resource):
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_INSTALL, resource_required=False)
|
||||
@plugin_permission_required(install_required=True)
|
||||
@with_current_tenant_id
|
||||
def post(self, tenant_id: str):
|
||||
@@ -859,7 +855,6 @@ class PluginDeleteInstallTaskItemApi(Resource):
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_INSTALL, resource_required=False)
|
||||
@plugin_permission_required(install_required=True)
|
||||
@with_current_tenant_id
|
||||
def post(self, tenant_id: str, task_id: str, identifier: str):
|
||||
@@ -876,7 +871,7 @@ class PluginUpgradeFromMarketplaceApi(Resource):
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_INSTALL, resource_required=False)
|
||||
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_MODEL_CONFIG, resource_required=False)
|
||||
@plugin_permission_required(install_required=True)
|
||||
@with_current_tenant_id
|
||||
def post(self, tenant_id: str):
|
||||
@@ -899,7 +894,7 @@ class PluginUpgradeFromGithubApi(Resource):
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_INSTALL, resource_required=False)
|
||||
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_MODEL_CONFIG, resource_required=False)
|
||||
@plugin_permission_required(install_required=True)
|
||||
@with_current_tenant_id
|
||||
def post(self, tenant_id: str):
|
||||
@@ -927,7 +922,7 @@ class PluginUninstallApi(Resource):
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_INSTALL, resource_required=False)
|
||||
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_DELETE, resource_required=False)
|
||||
@plugin_permission_required(install_required=True)
|
||||
@with_current_tenant_id
|
||||
def post(self, tenant_id: str):
|
||||
@@ -995,7 +990,7 @@ class PluginFetchDynamicSelectOptionsApi(Resource):
|
||||
@setup_required
|
||||
@login_required
|
||||
@is_admin_or_owner_required
|
||||
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_MANAGE, resource_required=False)
|
||||
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_MODEL_CONFIG, resource_required=False)
|
||||
@account_initialization_required
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
|
||||
@@ -201,21 +201,23 @@ def _legacy_workspace_roles(
|
||||
This keeps the new `/rbac/roles` endpoint compatible with the original
|
||||
Dify role model when enterprise RBAC is disabled.
|
||||
"""
|
||||
|
||||
legacy_roles = [
|
||||
svc.RBACRole(
|
||||
id=role_name,
|
||||
tenant_id="",
|
||||
type=svc.RBACRoleType.WORKSPACE.value,
|
||||
category="global_system_default",
|
||||
name=role_name,
|
||||
description="",
|
||||
is_builtin=True,
|
||||
permission_keys=list(dict.fromkeys(_LEGACY_ROLE_PERMISSION_KEYS[role_name])),
|
||||
role_tag="owner" if role_name == "owner" else "",
|
||||
legacy_roles = []
|
||||
for role_name in ("owner", "admin", "editor", "normal", "dataset_operator"):
|
||||
if not dify_config.DATASET_OPERATOR_ENABLED and role_name == "dataset_operator":
|
||||
continue
|
||||
legacy_roles.append(
|
||||
svc.RBACRole(
|
||||
id=role_name,
|
||||
tenant_id="",
|
||||
type=svc.RBACRoleType.WORKSPACE.value,
|
||||
category="global_system_default",
|
||||
name=role_name,
|
||||
description="",
|
||||
is_builtin=True,
|
||||
permission_keys=list(dict.fromkeys(_LEGACY_ROLE_PERMISSION_KEYS[role_name])),
|
||||
role_tag="owner" if role_name == "owner" else "",
|
||||
)
|
||||
)
|
||||
for role_name in ("owner", "admin", "editor", "normal", "dataset_operator")
|
||||
]
|
||||
|
||||
if not include_owner:
|
||||
legacy_roles = [r for r in legacy_roles if r.name != "owner"]
|
||||
|
||||
@@ -455,9 +455,6 @@ class CustomizedSnippetUseCountIncrementApi(Resource):
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@edit_permission_required
|
||||
@rbac_permission_required(
|
||||
RBACResourceScope.WORKSPACE, RBACPermission.SNIPPETS_CREATE_AND_MODIFY, resource_required=False
|
||||
)
|
||||
@with_current_tenant_id
|
||||
def post(self, current_tenant_id: str, snippet_id: str):
|
||||
"""Increment snippet use count when it is inserted into a workflow."""
|
||||
|
||||
@@ -325,10 +325,10 @@ class TenantApi(Resource):
|
||||
raise ValueError("No current tenant")
|
||||
|
||||
if tenant.status == TenantStatus.ARCHIVE:
|
||||
tenants = TenantService.get_join_tenants(current_user)
|
||||
tenants = TenantService.get_join_tenants(current_user, session=db.session)
|
||||
# if there is any tenant, switch to the first one
|
||||
if len(tenants) > 0:
|
||||
TenantService.switch_tenant(current_user, tenants[0].id)
|
||||
TenantService.switch_tenant(current_user, tenants[0].id, session=db.session)
|
||||
tenant = tenants[0]
|
||||
# else, raise Unauthorized
|
||||
else:
|
||||
@@ -351,7 +351,7 @@ class SwitchWorkspaceApi(Resource):
|
||||
|
||||
# check if tenant_id is valid, 403 if not
|
||||
try:
|
||||
TenantService.switch_tenant(current_user, args.tenant_id)
|
||||
TenantService.switch_tenant(current_user, args.tenant_id, session=db.session)
|
||||
except Exception:
|
||||
raise AccountNotLinkTenantError("Account not link tenant")
|
||||
|
||||
|
||||
@@ -14,11 +14,12 @@ api = ExternalApi(
|
||||
|
||||
files_ns = Namespace("files", description="File operations", path="/")
|
||||
|
||||
from . import image_preview, tool_files, upload
|
||||
from . import agent_drive_archive, image_preview, tool_files, upload
|
||||
|
||||
api.add_namespace(files_ns)
|
||||
|
||||
__all__ = [
|
||||
"agent_drive_archive",
|
||||
"api",
|
||||
"bp",
|
||||
"files_ns",
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
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 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,
|
||||
)
|
||||
except AgentDriveError as exc:
|
||||
raise NotFound(exc.message) from exc
|
||||
|
||||
response = Response(payload, mimetype=mime_type, direct_passthrough=True, headers={})
|
||||
response.headers["Content-Length"] = str(len(payload))
|
||||
if args.as_attachment and filename:
|
||||
encoded_filename = quote(filename)
|
||||
response.headers["Content-Disposition"] = f"attachment; filename*=UTF-8''{encoded_filename}"
|
||||
enforce_download_for_html(response, mime_type=mime_type, filename=filename, extension="")
|
||||
return response
|
||||
@@ -1,9 +1,10 @@
|
||||
"""Inner API endpoint for tenant-scoped knowledge retrieval.
|
||||
"""Plugin inner API endpoint for tenant-scoped knowledge retrieval.
|
||||
|
||||
This controller is a thin HTTP wrapper around
|
||||
``services.knowledge_retrieval_inner_service.InnerKnowledgeRetrievalService``.
|
||||
It intentionally keeps authorization simple: shared inner API key plus
|
||||
tenant-scoped app/dataset validation in the service layer.
|
||||
It uses the plugin inner API key because dify-agent calls this endpoint through
|
||||
the same trusted Dify API bridge as other agent/plugin inner calls; tenant-scoped
|
||||
app/dataset validation remains in the service layer.
|
||||
"""
|
||||
|
||||
from flask_restx import Resource
|
||||
@@ -11,8 +12,9 @@ from pydantic import ValidationError
|
||||
|
||||
from controllers.common.schema import register_response_schema_models, register_schema_models
|
||||
from controllers.inner_api import inner_api_ns
|
||||
from controllers.inner_api.wraps import inner_api_only
|
||||
from controllers.inner_api.wraps import plugin_inner_api_only
|
||||
from core.workflow.nodes.knowledge_retrieval import exc as retrieval_exc
|
||||
from extensions.ext_database import db
|
||||
from libs.exception import BaseHTTPException
|
||||
from services.entities.knowledge_retrieval_inner import InnerKnowledgeRetrieveRequest, InnerKnowledgeRetrieveResponse
|
||||
from services.errors.knowledge_retrieval import ExternalKnowledgeRetrievalError, InnerKnowledgeRetrievalServiceError
|
||||
@@ -48,7 +50,7 @@ register_response_schema_models(inner_api_ns, InnerKnowledgeRetrieveResponse)
|
||||
class InnerKnowledgeRetrieveApi(Resource):
|
||||
"""Retrieve knowledge from one or more datasets within the caller tenant."""
|
||||
|
||||
@inner_api_only
|
||||
@plugin_inner_api_only
|
||||
@inner_api_ns.doc("inner_knowledge_retrieve")
|
||||
@inner_api_ns.doc(description="Retrieve knowledge for trusted internal callers")
|
||||
@inner_api_ns.expect(inner_api_ns.models[InnerKnowledgeRetrieveRequest.__name__])
|
||||
@@ -60,9 +62,8 @@ class InnerKnowledgeRetrieveApi(Resource):
|
||||
@inner_api_ns.doc(
|
||||
responses={
|
||||
400: "Invalid request body",
|
||||
401: "Unauthorized - invalid inner API key",
|
||||
403: "Caller tenant does not own the requested resource",
|
||||
404: "App or dataset not found",
|
||||
404: "Invalid plugin inner API key, app not found, or dataset not found",
|
||||
422: "Invalid retrieval configuration",
|
||||
429: "Knowledge retrieval rate limited",
|
||||
502: "External knowledge retrieval failed",
|
||||
@@ -81,7 +82,7 @@ class InnerKnowledgeRetrieveApi(Resource):
|
||||
) from exc
|
||||
|
||||
try:
|
||||
response = InnerKnowledgeRetrievalService().retrieve(payload)
|
||||
response = InnerKnowledgeRetrievalService().retrieve(payload, session=db.session)
|
||||
except InnerKnowledgeRetrievalServiceError as exc:
|
||||
raise InnerKnowledgeRetrievalHttpError(
|
||||
error_code=exc.error_code,
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
"""Inner API for the agent drive (agent 网盘) control plane — ENG-591.
|
||||
"""Inner API for the agent drive (agent 网盘) control plane.
|
||||
|
||||
Two endpoints, 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.
|
||||
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
|
||||
@@ -13,6 +15,7 @@ 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 services.agent_drive_service import (
|
||||
AgentDriveError,
|
||||
@@ -56,6 +59,24 @@ class AgentDriveManifestApi(Resource):
|
||||
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)
|
||||
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
|
||||
@@ -69,9 +90,10 @@ class AgentDriveCommitApi(Resource):
|
||||
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=body.user_id,
|
||||
user_id=user.id,
|
||||
agent_id=agent_id,
|
||||
items=body.items,
|
||||
)
|
||||
|
||||
@@ -47,7 +47,7 @@ class EnterpriseWorkspace(Resource):
|
||||
if account is None:
|
||||
return {"message": "owner account not found."}, 404
|
||||
|
||||
tenant = TenantService.create_tenant(args.name, is_from_dashboard=True)
|
||||
tenant = TenantService.create_tenant(args.name, is_from_dashboard=True, session=db.session)
|
||||
TenantService.create_tenant_member(tenant, account, db.session, role="owner")
|
||||
|
||||
tenant_was_created.send(tenant)
|
||||
@@ -84,7 +84,7 @@ class EnterpriseWorkspaceNoOwnerEmail(Resource):
|
||||
def post(self):
|
||||
args = WorkspaceOwnerlessPayload.model_validate(inner_api_ns.payload or {})
|
||||
|
||||
tenant = TenantService.create_tenant(args.name, is_from_dashboard=True)
|
||||
tenant = TenantService.create_tenant(args.name, is_from_dashboard=True, session=db.session)
|
||||
|
||||
tenant_was_created.send(tenant)
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ openapi_ns = Namespace("openapi", description="User-scoped operations", path="/"
|
||||
|
||||
# Register response/query models BEFORE importing controller modules so that
|
||||
# @openapi_ns.response / @openapi_ns.expect decorators can resolve model names.
|
||||
from controllers.common.fields import EventStreamResponse
|
||||
from controllers.common.fields import EventStreamResponse, SimpleResultResponse
|
||||
from controllers.common.schema import register_enum_models, register_response_schema_models, register_schema_models
|
||||
from controllers.openapi._models import (
|
||||
AccountPayload,
|
||||
@@ -95,6 +95,7 @@ register_response_schema_models(
|
||||
openapi_ns,
|
||||
ErrorBody,
|
||||
EventStreamResponse,
|
||||
SimpleResultResponse,
|
||||
UsageInfo,
|
||||
MessageMetadata,
|
||||
AppListRow,
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from collections.abc import Callable, Iterator
|
||||
from collections.abc import Callable, Generator
|
||||
from contextlib import contextmanager
|
||||
from typing import Any
|
||||
|
||||
@@ -61,7 +61,7 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _translate_service_errors() -> Iterator[None]:
|
||||
def _translate_service_errors() -> Generator[None, None, None]:
|
||||
try:
|
||||
yield
|
||||
except WorkflowNotFoundError as ex:
|
||||
@@ -166,6 +166,7 @@ class AppRunApi(Resource):
|
||||
surface="apps",
|
||||
)
|
||||
|
||||
# response-contract:ignore compact_generate_response
|
||||
return helper.compact_generate_response(stream_obj)
|
||||
|
||||
|
||||
|
||||
@@ -25,12 +25,13 @@ from controllers.openapi._models import (
|
||||
AppListRow,
|
||||
)
|
||||
from controllers.openapi.auth.composition import auth_router
|
||||
from controllers.openapi.auth.data import AuthData, RBACRequirement
|
||||
from controllers.openapi.auth.data import AuthData, CallerKind, RBACRequirement
|
||||
from controllers.service_api.app.error import AppUnavailableError
|
||||
from core.app.app_config.common.parameters_mapping import get_parameters_from_feature_dict
|
||||
from extensions.ext_database import db
|
||||
from libs.oauth_bearer import Scope, TokenType
|
||||
from models import App
|
||||
from models.enums import AppStatus
|
||||
from models.model import AppMode
|
||||
from services.account_service import TenantService
|
||||
from services.app_service import AppListParams, AppService
|
||||
@@ -166,7 +167,9 @@ class AppListApi(Resource):
|
||||
# an empty set or list means the caller has no accessible apps.
|
||||
# End-users bypass RBAC here — their access is controlled by scope upstream.
|
||||
apply_rbac_filter = (
|
||||
dify_config.RBAC_ENABLED and auth_data.caller_kind != "end_user" and auth_data.account_id is not None
|
||||
dify_config.RBAC_ENABLED
|
||||
and auth_data.caller_kind != CallerKind.END_USER
|
||||
and auth_data.account_id is not None
|
||||
)
|
||||
access_filter = AppAccessFilter.unrestricted()
|
||||
if apply_rbac_filter:
|
||||
@@ -203,7 +206,7 @@ class AppListApi(Resource):
|
||||
limit=query.limit,
|
||||
mode=query.mode.value if query.mode else "all", # type:ignore
|
||||
name=query.name,
|
||||
status="normal",
|
||||
status=AppStatus.NORMAL,
|
||||
# Visibility gate pushed into the query — pagination.total stays
|
||||
# consistent across pages because invisible rows never count.
|
||||
openapi_visible=True,
|
||||
|
||||
@@ -25,6 +25,7 @@ from controllers.openapi.auth.data import AuthData, Edition
|
||||
from extensions.ext_database import db
|
||||
from libs.oauth_bearer import Scope, TokenType
|
||||
from models import App
|
||||
from models.enums import AppStatus
|
||||
from services.account_service import TenantService
|
||||
from services.app_service import AppService
|
||||
from services.enterprise.app_permitted_service import list_permitted_apps
|
||||
@@ -62,7 +63,7 @@ class PermittedExternalAppsListApi(Resource):
|
||||
items: list[AppListRow] = []
|
||||
for app_id in page_result.app_ids:
|
||||
app = apps_by_id.get(app_id)
|
||||
if not app or app.status != "normal":
|
||||
if not app or app.status != AppStatus.NORMAL:
|
||||
continue
|
||||
tenant = tenants_by_id.get(str(app.tenant_id))
|
||||
items.append(
|
||||
|
||||
@@ -2,7 +2,6 @@ from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from enum import StrEnum
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from werkzeug.exceptions import InternalServerError
|
||||
@@ -21,6 +20,11 @@ class Edition(StrEnum):
|
||||
SAAS = "saas"
|
||||
|
||||
|
||||
class CallerKind(StrEnum):
|
||||
ACCOUNT = "account"
|
||||
END_USER = "end_user"
|
||||
|
||||
|
||||
def current_edition() -> Edition:
|
||||
if dify_config.EDITION == "CLOUD":
|
||||
return Edition.SAAS
|
||||
@@ -78,9 +82,9 @@ class AuthData(BaseModel):
|
||||
tenant_role: TenantAccountRole | None = None
|
||||
|
||||
caller: Account | EndUser | None = None
|
||||
caller_kind: Literal["account", "end_user"] | None = None
|
||||
caller_kind: CallerKind | None = None
|
||||
|
||||
def require_app_context(self) -> tuple[App, Account | EndUser, Literal["account", "end_user"]]:
|
||||
def require_app_context(self) -> tuple[App, Account | EndUser, CallerKind]:
|
||||
if self.app is None or self.caller is None or self.caller_kind is None:
|
||||
raise InternalServerError("pipeline_invariant_violated: app context missing")
|
||||
return self.app, self.caller, self.caller_kind
|
||||
|
||||
@@ -5,10 +5,10 @@ import uuid
|
||||
from flask import request
|
||||
from werkzeug.exceptions import Forbidden, InternalServerError, NotFound, Unauthorized
|
||||
|
||||
from controllers.openapi.auth.data import AuthData
|
||||
from controllers.openapi.auth.data import AuthData, CallerKind
|
||||
from extensions.ext_database import db
|
||||
from models.account import TenantStatus
|
||||
from models.enums import EndUserType
|
||||
from models.account import AccountStatus, TenantStatus
|
||||
from models.enums import AppStatus, EndUserType
|
||||
from services.account_service import AccountService, TenantService
|
||||
from services.app_service import AppService
|
||||
from services.end_user_service import EndUserService
|
||||
@@ -24,7 +24,7 @@ def load_app(data: AuthData) -> None:
|
||||
except ValueError:
|
||||
raise NotFound("app not found")
|
||||
app = AppService.get_app_by_id(db.session, app_id)
|
||||
if not app or app.status != "normal":
|
||||
if not app or app.status != AppStatus.NORMAL:
|
||||
raise NotFound("app not found")
|
||||
data.app = app
|
||||
|
||||
@@ -65,7 +65,7 @@ def load_account(data: AuthData) -> None:
|
||||
if data.tenant:
|
||||
account.current_tenant = data.tenant
|
||||
data.caller = account
|
||||
data.caller_kind = "account"
|
||||
data.caller_kind = CallerKind.ACCOUNT
|
||||
|
||||
|
||||
def load_workspace_role(data: AuthData) -> None:
|
||||
@@ -73,7 +73,7 @@ def load_workspace_role(data: AuthData) -> None:
|
||||
return
|
||||
if data.tenant is None or data.account_id is None:
|
||||
return
|
||||
if data.caller is not None and getattr(data.caller, "status", None) != "active":
|
||||
if data.caller is not None and getattr(data.caller, "status", None) != AccountStatus.ACTIVE:
|
||||
return
|
||||
role = TenantService.get_account_role_in_tenant(db.session, str(data.account_id), str(data.tenant.id))
|
||||
if role is None:
|
||||
@@ -91,7 +91,7 @@ def resolve_external_user(data: AuthData) -> None:
|
||||
user_id=data.external_identity.email,
|
||||
)
|
||||
data.caller = end_user
|
||||
data.caller_kind = "end_user"
|
||||
data.caller_kind = CallerKind.END_USER
|
||||
|
||||
|
||||
def load_app_access_mode(data: AuthData) -> None:
|
||||
|
||||
@@ -5,7 +5,7 @@ from werkzeug.exceptions import Forbidden, NotFound, UnprocessableEntity
|
||||
|
||||
from configs import dify_config
|
||||
from controllers.common.wraps import enforce_rbac_access
|
||||
from controllers.openapi.auth.data import AuthData
|
||||
from controllers.openapi.auth.data import AuthData, CallerKind
|
||||
from extensions.ext_database import db
|
||||
from libs.oauth_bearer import Scope, TokenType
|
||||
from services.account_service import AccountService, TenantService
|
||||
@@ -58,7 +58,7 @@ def check_rbac_permission(data: AuthData) -> None:
|
||||
if not dify_config.RBAC_ENABLED:
|
||||
return
|
||||
# Only account callers are subject to RBAC; end_user access is scope-controlled.
|
||||
if data.caller_kind != "account":
|
||||
if data.caller_kind != CallerKind.ACCOUNT:
|
||||
return
|
||||
if data.account_id is None or data.tenant is None:
|
||||
raise Forbidden("rbac context missing")
|
||||
|
||||
@@ -22,7 +22,7 @@ from controllers.openapi._contract import accepts, returns
|
||||
from controllers.openapi._errors import HumanInputFormNotFound, RecipientSurfaceMismatch
|
||||
from controllers.openapi._models import FormSubmitResponse, HumanInputFormDefinitionResponse
|
||||
from controllers.openapi.auth.composition import auth_router
|
||||
from controllers.openapi.auth.data import AuthData, RBACRequirement
|
||||
from controllers.openapi.auth.data import AuthData, CallerKind, RBACRequirement
|
||||
from core.workflow.human_input_policy import (
|
||||
HumanInputSurface,
|
||||
is_recipient_type_allowed_for_surface,
|
||||
@@ -98,7 +98,7 @@ class OpenApiWorkflowHumanInputFormApi(Resource):
|
||||
|
||||
submission_user_id: str | None = None
|
||||
submission_end_user_id: str | None = None
|
||||
if caller_kind == "account":
|
||||
if caller_kind == CallerKind.ACCOUNT:
|
||||
submission_user_id = caller.id
|
||||
else:
|
||||
submission_end_user_id = caller.id
|
||||
|
||||
@@ -22,7 +22,7 @@ from controllers.common.schema import query_params_from_model
|
||||
from controllers.common.wraps import RBACPermission, RBACResourceScope
|
||||
from controllers.openapi import openapi_ns
|
||||
from controllers.openapi.auth.composition import auth_router
|
||||
from controllers.openapi.auth.data import AuthData, RBACRequirement
|
||||
from controllers.openapi.auth.data import AuthData, CallerKind, RBACRequirement
|
||||
from core.app.apps.advanced_chat.app_generator import AdvancedChatAppGenerator
|
||||
from core.app.apps.base_app_generator import BaseAppGenerator
|
||||
from core.app.apps.common.workflow_response_converter import WorkflowResponseConverter
|
||||
@@ -70,7 +70,7 @@ class OpenApiWorkflowEventsApi(Resource):
|
||||
if workflow_run.app_id != app_model.id:
|
||||
raise NotFound("Workflow run not found")
|
||||
|
||||
if caller_kind == "account":
|
||||
if caller_kind == CallerKind.ACCOUNT:
|
||||
if workflow_run.created_by_role != CreatorUserRole.ACCOUNT or workflow_run.created_by != caller.id:
|
||||
raise NotFound("Workflow run not found")
|
||||
else:
|
||||
|
||||
@@ -128,7 +128,7 @@ class WorkspaceSwitchApi(Resource):
|
||||
account = _load_account(auth_data.account_id)
|
||||
|
||||
try:
|
||||
TenantService.switch_tenant(account, workspace_id)
|
||||
TenantService.switch_tenant(account, workspace_id, session=db.session)
|
||||
except AccountNotLinkTenantError:
|
||||
raise NotFound("workspace not found")
|
||||
|
||||
@@ -152,7 +152,7 @@ class WorkspaceMembersApi(Resource):
|
||||
@accepts(query=MemberListQuery)
|
||||
def get(self, workspace_id: str, *, auth_data: AuthData, query: MemberListQuery):
|
||||
tenant = _load_tenant(workspace_id)
|
||||
members = TenantService.get_tenant_members(tenant)
|
||||
members = TenantService.get_tenant_members(tenant, session=db.session)
|
||||
total = len(members)
|
||||
start = (query.page - 1) * query.limit
|
||||
page_items = members[start : start + query.limit]
|
||||
@@ -184,6 +184,7 @@ class WorkspaceMembersApi(Resource):
|
||||
language=None,
|
||||
role=body.role,
|
||||
inviter=inviter,
|
||||
session=db.session,
|
||||
)
|
||||
except AccountAlreadyInTenantError as exc:
|
||||
raise BadRequest(str(exc))
|
||||
@@ -193,7 +194,7 @@ class WorkspaceMembersApi(Resource):
|
||||
raise BadRequest(str(exc))
|
||||
|
||||
normalized_email = body.email.lower()
|
||||
member = AccountService.get_account_by_email_with_case_fallback(normalized_email)
|
||||
member = AccountService.get_account_by_email_with_case_fallback(db.session, normalized_email)
|
||||
if member is None:
|
||||
# invite_new_member just created or fetched this account.
|
||||
raise RuntimeError("invited member missing from DB after invite")
|
||||
@@ -232,7 +233,7 @@ class WorkspaceMemberApi(Resource):
|
||||
raise NotFound("member not found")
|
||||
|
||||
try:
|
||||
TenantService.remove_member_from_tenant(tenant, member, operator)
|
||||
TenantService.remove_member_from_tenant(tenant, member, operator, session=db.session)
|
||||
except CannotOperateSelfError as exc:
|
||||
raise BadRequest(str(exc))
|
||||
except NoPermissionError as exc:
|
||||
@@ -266,7 +267,7 @@ class WorkspaceMemberRoleApi(Resource):
|
||||
raise NotFound("member not found")
|
||||
|
||||
try:
|
||||
TenantService.update_member_role(tenant, member, body.role, operator)
|
||||
TenantService.update_member_role(tenant, member, body.role, operator, session=db.session)
|
||||
except CannotOperateSelfError as exc:
|
||||
raise BadRequest(str(exc))
|
||||
except NoPermissionError as exc:
|
||||
|
||||
@@ -10,6 +10,7 @@ from controllers.common.schema import query_params_from_model, register_response
|
||||
from controllers.console.wraps import edit_permission_required
|
||||
from controllers.service_api import service_api_ns
|
||||
from controllers.service_api.wraps import validate_app_token
|
||||
from extensions.ext_database import db
|
||||
from extensions.ext_redis import redis_client
|
||||
from fields.annotation_fields import Annotation, AnnotationList
|
||||
from fields.base import ResponseModel
|
||||
@@ -281,7 +282,9 @@ class AnnotationUpdateDeleteApi(Resource):
|
||||
"""Update an existing annotation."""
|
||||
payload = AnnotationCreatePayload.model_validate(service_api_ns.payload or {})
|
||||
update_args: UpdateAnnotationArgs = {"question": payload.question, "answer": payload.answer}
|
||||
annotation = AppAnnotationService.update_app_annotation_directly(update_args, app_model.id, str(annotation_id))
|
||||
annotation = AppAnnotationService.update_app_annotation_directly(
|
||||
update_args, app_model.id, str(annotation_id), db.session
|
||||
)
|
||||
response = Annotation.model_validate(annotation, from_attributes=True)
|
||||
return response.model_dump(mode="json")
|
||||
|
||||
@@ -310,5 +313,5 @@ class AnnotationUpdateDeleteApi(Resource):
|
||||
@edit_permission_required
|
||||
def delete(self, app_model: App, annotation_id: UUID):
|
||||
"""Delete an annotation."""
|
||||
AppAnnotationService.delete_app_annotation(app_model.id, str(annotation_id))
|
||||
AppAnnotationService.delete_app_annotation(app_model.id, str(annotation_id), db.session)
|
||||
return "", 204
|
||||
|
||||
@@ -23,6 +23,7 @@ from controllers.service_api.app.error import (
|
||||
from controllers.service_api.schema import binary_response, expect_with_user, multipart_file_params
|
||||
from controllers.service_api.wraps import FetchUserArg, WhereisUserArg, validate_app_token
|
||||
from core.errors.error import ModelCurrentlyNotSupportError, ProviderTokenNotInitError, QuotaExceededError
|
||||
from extensions.ext_database import db
|
||||
from graphon.model_runtime.errors.invoke import InvokeError
|
||||
from models.model import App, EndUser
|
||||
from services.audio_service import AudioService
|
||||
@@ -177,7 +178,12 @@ class TextApi(Resource):
|
||||
text = payload.text
|
||||
voice = payload.voice
|
||||
response = AudioService.transcript_tts(
|
||||
app_model=app_model, text=text, voice=voice, end_user=end_user.external_user_id, message_id=message_id
|
||||
app_model=app_model,
|
||||
session=db.session,
|
||||
text=text,
|
||||
voice=voice,
|
||||
end_user=end_user.external_user_id,
|
||||
message_id=message_id,
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
@@ -565,7 +565,7 @@ class DatasetApi(DatasetApiResource):
|
||||
if dataset is None:
|
||||
raise NotFound("Dataset not found.")
|
||||
try:
|
||||
DatasetService.check_dataset_permission(dataset, current_user)
|
||||
DatasetService.check_dataset_permission(dataset, current_user, db.session)
|
||||
except services.errors.account.NoPermissionError as e:
|
||||
raise Forbidden(str(e))
|
||||
data = _dump_service_dataset_detail(dataset)
|
||||
@@ -819,7 +819,7 @@ class DocumentStatusApi(DatasetApiResource):
|
||||
|
||||
# Check user's permission
|
||||
try:
|
||||
DatasetService.check_dataset_permission(dataset, current_user)
|
||||
DatasetService.check_dataset_permission(dataset, current_user, db.session)
|
||||
except services.errors.account.NoPermissionError as e:
|
||||
raise Forbidden(str(e))
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ from controllers.common.controller_schemas import MetadataUpdatePayload
|
||||
from controllers.common.schema import register_response_schema_models, register_schema_model, register_schema_models
|
||||
from controllers.service_api import service_api_ns
|
||||
from controllers.service_api.wraps import DatasetApiResource, cloud_edition_billing_rate_limit_check
|
||||
from extensions.ext_database import db
|
||||
from fields.dataset_fields import (
|
||||
DatasetMetadataActionResponse,
|
||||
DatasetMetadataBuiltInFieldsResponse,
|
||||
@@ -83,9 +84,9 @@ class DatasetMetadataCreateServiceApi(DatasetApiResource):
|
||||
dataset = DatasetService.get_dataset(dataset_id_str)
|
||||
if dataset is None:
|
||||
raise NotFound("Dataset not found.")
|
||||
DatasetService.check_dataset_permission(dataset, current_user)
|
||||
DatasetService.check_dataset_permission(dataset, current_user, db.session)
|
||||
|
||||
metadata = MetadataService.create_metadata(dataset_id_str, metadata_args)
|
||||
metadata = MetadataService.create_metadata(db.session(), dataset_id_str, metadata_args)
|
||||
return dump_response(DatasetMetadataResponse, metadata), 201
|
||||
|
||||
@service_api_ns.doc(
|
||||
@@ -118,7 +119,7 @@ class DatasetMetadataCreateServiceApi(DatasetApiResource):
|
||||
dataset = DatasetService.get_dataset(dataset_id_str)
|
||||
if dataset is None:
|
||||
raise NotFound("Dataset not found.")
|
||||
metadata = MetadataService.get_dataset_metadatas(dataset)
|
||||
metadata = MetadataService.get_dataset_metadatas(db.session(), dataset)
|
||||
return dump_response(DatasetMetadataListResponse, metadata), 200
|
||||
|
||||
|
||||
@@ -156,9 +157,9 @@ class DatasetMetadataServiceApi(DatasetApiResource):
|
||||
dataset = DatasetService.get_dataset(dataset_id_str)
|
||||
if dataset is None:
|
||||
raise NotFound("Dataset not found.")
|
||||
DatasetService.check_dataset_permission(dataset, current_user)
|
||||
DatasetService.check_dataset_permission(dataset, current_user, db.session)
|
||||
|
||||
metadata = MetadataService.update_metadata_name(dataset_id_str, metadata_id_str, payload.name)
|
||||
metadata = MetadataService.update_metadata_name(db.session(), dataset_id_str, metadata_id_str, payload.name)
|
||||
return dump_response(DatasetMetadataResponse, metadata), 200
|
||||
|
||||
@service_api_ns.doc(
|
||||
@@ -191,9 +192,9 @@ class DatasetMetadataServiceApi(DatasetApiResource):
|
||||
dataset = DatasetService.get_dataset(dataset_id_str)
|
||||
if dataset is None:
|
||||
raise NotFound("Dataset not found.")
|
||||
DatasetService.check_dataset_permission(dataset, current_user)
|
||||
DatasetService.check_dataset_permission(dataset, current_user, db.session)
|
||||
|
||||
MetadataService.delete_metadata(dataset_id_str, metadata_id_str)
|
||||
MetadataService.delete_metadata(db.session(), dataset_id_str, metadata_id_str)
|
||||
return "", 204
|
||||
|
||||
|
||||
@@ -259,13 +260,13 @@ class DatasetMetadataBuiltInFieldActionServiceApi(DatasetApiResource):
|
||||
dataset = DatasetService.get_dataset(dataset_id_str)
|
||||
if dataset is None:
|
||||
raise NotFound("Dataset not found.")
|
||||
DatasetService.check_dataset_permission(dataset, current_user)
|
||||
DatasetService.check_dataset_permission(dataset, current_user, db.session)
|
||||
|
||||
match action:
|
||||
case "enable":
|
||||
MetadataService.enable_built_in_field(dataset)
|
||||
MetadataService.enable_built_in_field(db.session(), dataset)
|
||||
case "disable":
|
||||
MetadataService.disable_built_in_field(dataset)
|
||||
MetadataService.disable_built_in_field(db.session(), dataset)
|
||||
return dump_response(DatasetMetadataActionResponse, {"result": "success"}), 200
|
||||
|
||||
|
||||
@@ -305,10 +306,10 @@ class DocumentMetadataEditServiceApi(DatasetApiResource):
|
||||
dataset = DatasetService.get_dataset(dataset_id_str)
|
||||
if dataset is None:
|
||||
raise NotFound("Dataset not found.")
|
||||
DatasetService.check_dataset_permission(dataset, current_user)
|
||||
DatasetService.check_dataset_permission(dataset, current_user, db.session)
|
||||
|
||||
metadata_args = MetadataOperationData.model_validate(service_api_ns.payload or {})
|
||||
|
||||
MetadataService.update_documents_metadata(dataset, metadata_args)
|
||||
MetadataService.update_documents_metadata(db.session(), dataset, metadata_args)
|
||||
|
||||
return dump_response(DatasetMetadataActionResponse, {"result": "success"}), 200
|
||||
|
||||
@@ -12,6 +12,7 @@ from flask_restx import Resource
|
||||
from flask_restx.utils import merge
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from werkzeug.exceptions import Forbidden, NotFound, Unauthorized
|
||||
|
||||
from configs import dify_config
|
||||
@@ -269,8 +270,8 @@ def cloud_edition_billing_rate_limit_check[**P, R](
|
||||
subscription_plan=knowledge_rate_limit.subscription_plan,
|
||||
operation="knowledge",
|
||||
)
|
||||
db.session.add(rate_limit_log)
|
||||
db.session.commit()
|
||||
with sessionmaker(bind=db.engine, expire_on_commit=False).begin() as session:
|
||||
session.add(rate_limit_log)
|
||||
raise Forbidden(
|
||||
"Sorry, you have reached the knowledge base request rate limit of your subscription."
|
||||
)
|
||||
|
||||
@@ -22,6 +22,7 @@ from controllers.web.error import (
|
||||
)
|
||||
from controllers.web.wraps import WebApiResource
|
||||
from core.errors.error import ModelCurrentlyNotSupportError, ProviderTokenNotInitError, QuotaExceededError
|
||||
from extensions.ext_database import db
|
||||
from graphon.model_runtime.errors.invoke import InvokeError
|
||||
from libs.helper import uuid_value
|
||||
from models.model import App, EndUser
|
||||
@@ -130,7 +131,12 @@ class TextApi(WebApiResource):
|
||||
text = payload.text
|
||||
voice = payload.voice
|
||||
response = AudioService.transcript_tts(
|
||||
app_model=app_model, text=text, voice=voice, end_user=end_user.external_user_id, message_id=message_id
|
||||
app_model=app_model,
|
||||
session=db.session,
|
||||
text=text,
|
||||
voice=voice,
|
||||
end_user=end_user.external_user_id,
|
||||
message_id=message_id,
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
@@ -32,6 +32,7 @@ from libs.helper import uuid_value
|
||||
from models.model import App, AppMode, EndUser
|
||||
from services.app_generate_service import AppGenerateService
|
||||
from services.app_task_service import AppTaskService
|
||||
from services.conversation_service import ConversationService
|
||||
from services.errors.llm import InvokeRateLimitError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -202,6 +203,12 @@ class ChatApi(WebApiResource):
|
||||
args["auto_generate_name"] = False
|
||||
|
||||
try:
|
||||
# Eagerly validate conversation to avoid hanging on invalid conversation_id
|
||||
if payload.conversation_id:
|
||||
ConversationService.get_conversation(
|
||||
app_model=app_model, conversation_id=payload.conversation_id, user=end_user
|
||||
)
|
||||
|
||||
response = AppGenerateService.generate(
|
||||
app_model=app_model, user=end_user, args=args, invoke_from=InvokeFrom.WEB_APP, streaming=streaming
|
||||
)
|
||||
|
||||
@@ -69,7 +69,7 @@ class ForgotPasswordSendEmailApi(Resource):
|
||||
else:
|
||||
language = "en-US"
|
||||
|
||||
account = AccountService.get_account_by_email_with_case_fallback(request_email)
|
||||
account = AccountService.get_account_by_email_with_case_fallback(db.session, request_email)
|
||||
if account is None:
|
||||
raise AuthenticationFailedError()
|
||||
else:
|
||||
@@ -168,7 +168,7 @@ class ForgotPasswordResetApi(Resource):
|
||||
|
||||
email = reset_data.get("email", "")
|
||||
|
||||
account = AccountService.get_account_by_email_with_case_fallback(email)
|
||||
account = AccountService.get_account_by_email_with_case_fallback(db.session, email)
|
||||
|
||||
if account:
|
||||
account = db.session.merge(account)
|
||||
|
||||
@@ -14,7 +14,7 @@ from fields.base import ResponseModel
|
||||
from libs.helper import AppIconUrlField
|
||||
from models.account import TenantStatus
|
||||
from models.model import App, EndUser, Site
|
||||
from services.feature_service import FeatureService
|
||||
from services.feature_service import FeatureModel, FeatureService
|
||||
|
||||
|
||||
class AppSiteModelConfigResponse(ResponseModel):
|
||||
@@ -38,6 +38,7 @@ class AppSiteResponse(ResponseModel):
|
||||
description: str | None = None
|
||||
copyright: str | None = None
|
||||
privacy_policy: str | None = None
|
||||
input_placeholder: str | None = None
|
||||
custom_disclaimer: str | None = None
|
||||
default_language: str | None = None
|
||||
prompt_public: bool | None = None
|
||||
@@ -84,6 +85,7 @@ class AppSiteApi(WebApiResource):
|
||||
"description": fields.String,
|
||||
"copyright": fields.String,
|
||||
"privacy_policy": fields.String,
|
||||
"input_placeholder": fields.String,
|
||||
"custom_disclaimer": fields.String,
|
||||
"default_language": fields.String,
|
||||
"prompt_public": fields.Boolean,
|
||||
@@ -127,9 +129,15 @@ class AppSiteApi(WebApiResource):
|
||||
if app_model.tenant and app_model.tenant.status == TenantStatus.ARCHIVE:
|
||||
raise Forbidden()
|
||||
|
||||
can_replace_logo = FeatureService.get_features(app_model.tenant_id, exclude_vector_space=True).can_replace_logo
|
||||
features = FeatureService.get_features(app_model.tenant_id, exclude_vector_space=True)
|
||||
|
||||
return AppSiteInfo(app_model.tenant, app_model, site, end_user.id, can_replace_logo)
|
||||
return AppSiteInfo(
|
||||
app_model.tenant,
|
||||
app_model,
|
||||
serialize_runtime_site(site, features),
|
||||
end_user.id,
|
||||
features.can_replace_logo,
|
||||
)
|
||||
|
||||
|
||||
class AppSiteInfo:
|
||||
@@ -164,7 +172,23 @@ def serialize_site(site: Site) -> dict[str, Any]:
|
||||
return cast(dict[str, Any], marshal(site, AppSiteApi.site_fields))
|
||||
|
||||
|
||||
def serialize_runtime_site(site: Site, features: FeatureModel) -> dict[str, Any]:
|
||||
site_payload = serialize_site(site)
|
||||
if not features.billing.enabled or features.webapp_copyright_enabled:
|
||||
return site_payload
|
||||
|
||||
site_payload["copyright"] = None
|
||||
site_payload["input_placeholder"] = None
|
||||
return site_payload
|
||||
|
||||
|
||||
def serialize_app_site_payload(app_model: App, site: Site, end_user_id: str | None) -> dict[str, Any]:
|
||||
can_replace_logo = FeatureService.get_features(app_model.tenant_id, exclude_vector_space=True).can_replace_logo
|
||||
app_site_info = AppSiteInfo(app_model.tenant, app_model, site, end_user_id, can_replace_logo)
|
||||
features = FeatureService.get_features(app_model.tenant_id, exclude_vector_space=True)
|
||||
app_site_info = AppSiteInfo(
|
||||
app_model.tenant,
|
||||
app_model,
|
||||
serialize_runtime_site(site, features),
|
||||
end_user_id,
|
||||
features.can_replace_logo,
|
||||
)
|
||||
return cast(dict[str, Any], marshal(app_site_info, AppSiteApi.app_fields))
|
||||
|
||||
@@ -41,6 +41,7 @@ from core.app.entities.queue_entities import (
|
||||
QueueNodeStartedEvent,
|
||||
QueueNodeSucceededEvent,
|
||||
QueuePingEvent,
|
||||
QueueReasoningChunkEvent,
|
||||
QueueRetrieverResourcesEvent,
|
||||
QueueStopEvent,
|
||||
QueueTextChunkEvent,
|
||||
@@ -62,6 +63,7 @@ from core.app.entities.task_entities import (
|
||||
MessageAudioStreamResponse,
|
||||
MessageEndStreamResponse,
|
||||
PingStreamResponse,
|
||||
ReasoningChunkStreamResponse,
|
||||
StreamResponse,
|
||||
WorkflowPauseStreamResponse,
|
||||
WorkflowTaskState,
|
||||
@@ -473,6 +475,17 @@ class AdvancedChatAppGenerateTaskPipeline(GraphRuntimeStateSupport):
|
||||
self._workflow_response_converter.fetch_files_from_node_outputs(event.outputs or {})
|
||||
)
|
||||
|
||||
# Collect terminal reasoning (separated mode) per LLM node id for persistence. This is the
|
||||
# authoritative source (outputs.reasoning_content), decoupled from the live delta stream.
|
||||
# Accumulate across iteration/loop passes (same node_id) to match the live stream, which
|
||||
# appends every pass under the same key — overwriting would keep only the last pass.
|
||||
if event.node_type == BuiltinNodeTypes.LLM:
|
||||
reasoning_content = (event.outputs or {}).get("reasoning_content")
|
||||
if isinstance(reasoning_content, str) and reasoning_content:
|
||||
self._task_state.metadata.reasoning[event.node_id] = (
|
||||
self._task_state.metadata.reasoning.get(event.node_id, "") + reasoning_content
|
||||
)
|
||||
|
||||
node_finish_resp = self._workflow_response_converter.workflow_node_finish_to_stream_response(
|
||||
event=event,
|
||||
task_id=self._application_generate_entity.task_id,
|
||||
@@ -535,6 +548,27 @@ class AdvancedChatAppGenerateTaskPipeline(GraphRuntimeStateSupport):
|
||||
answer=delta_text, message_id=self._message_id, from_variable_selector=event.from_variable_selector
|
||||
)
|
||||
|
||||
def _handle_reasoning_chunk_event(
|
||||
self, event: QueueReasoningChunkEvent, **kwargs
|
||||
) -> Generator[StreamResponse, None, None]:
|
||||
"""Handle out-of-band reasoning chunk events.
|
||||
|
||||
Pure emit: reasoning is streamed on its own channel and never written to the
|
||||
answer. The terminal marker (is_final) may carry an empty reasoning string, in
|
||||
which case it is still forwarded as the "thinking finished" signal.
|
||||
"""
|
||||
if not event.reasoning and not event.is_final:
|
||||
return
|
||||
yield ReasoningChunkStreamResponse(
|
||||
task_id=self._application_generate_entity.task_id,
|
||||
data=ReasoningChunkStreamResponse.Data(
|
||||
message_id=self._message_id,
|
||||
reasoning=event.reasoning,
|
||||
node_id=event.from_node_id,
|
||||
is_final=event.is_final,
|
||||
),
|
||||
)
|
||||
|
||||
def _handle_iteration_start_event(
|
||||
self, event: QueueIterationStartEvent, **kwargs
|
||||
) -> Generator[StreamResponse, None, None]:
|
||||
@@ -872,6 +906,7 @@ class AdvancedChatAppGenerateTaskPipeline(GraphRuntimeStateSupport):
|
||||
QueuePingEvent: self._handle_ping_event,
|
||||
QueueErrorEvent: self._handle_error_event,
|
||||
QueueTextChunkEvent: self._handle_text_chunk_event,
|
||||
QueueReasoningChunkEvent: self._handle_reasoning_chunk_event,
|
||||
# Workflow events
|
||||
QueueWorkflowStartedEvent: self._handle_workflow_started_event,
|
||||
QueueWorkflowSucceededEvent: self._handle_workflow_succeeded_event,
|
||||
|
||||
@@ -16,7 +16,7 @@ from collections.abc import Generator, Mapping
|
||||
from typing import Any
|
||||
|
||||
from flask import Flask, current_app
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import and_, or_, select
|
||||
|
||||
from clients.agent_backend import AgentBackendRunEventAdapter
|
||||
from clients.agent_backend.factory import create_agent_backend_run_client
|
||||
@@ -42,7 +42,15 @@ from core.app.llm.model_access import build_dify_model_access
|
||||
from core.ops.ops_trace_manager import TraceQueueManager
|
||||
from extensions.ext_database import db
|
||||
from models import Account, App, EndUser, Message
|
||||
from models.agent import Agent, AgentConfigSnapshot, AgentScope, AgentSource, AgentStatus
|
||||
from models.agent import (
|
||||
Agent,
|
||||
AgentConfigDraft,
|
||||
AgentConfigDraftType,
|
||||
AgentConfigSnapshot,
|
||||
AgentScope,
|
||||
AgentSource,
|
||||
AgentStatus,
|
||||
)
|
||||
from models.agent_config_entities import AgentSoulConfig
|
||||
from services.conversation_service import ConversationService
|
||||
|
||||
@@ -73,10 +81,15 @@ class AgentAppGenerator(MessageBasedAppGenerator):
|
||||
inputs = args["inputs"]
|
||||
|
||||
# Resolve the bound roster Agent + its current Agent Soul snapshot.
|
||||
agent, snapshot, agent_soul = self._resolve_agent(app_model)
|
||||
agent, agent_config_id, agent_soul = self._resolve_agent(
|
||||
app_model,
|
||||
invoke_from=invoke_from,
|
||||
draft_type=args.get("draft_type"),
|
||||
user=user,
|
||||
)
|
||||
runtime_session_snapshot_id = self._runtime_session_snapshot_id(
|
||||
invoke_from=invoke_from,
|
||||
snapshot_id=snapshot.id,
|
||||
snapshot_id=agent_config_id,
|
||||
)
|
||||
|
||||
conversation = None
|
||||
@@ -123,7 +136,7 @@ class AgentAppGenerator(MessageBasedAppGenerator):
|
||||
call_depth=0,
|
||||
trace_manager=trace_manager,
|
||||
agent_id=agent.id,
|
||||
agent_config_snapshot_id=snapshot.id,
|
||||
agent_config_snapshot_id=agent_config_id,
|
||||
agent_runtime_session_snapshot_id=runtime_session_snapshot_id,
|
||||
)
|
||||
|
||||
@@ -179,7 +192,12 @@ class AgentAppGenerator(MessageBasedAppGenerator):
|
||||
persisted to the conversation. Live streaming to a reconnected client is
|
||||
out of scope here — the message is persisted and can be re-fetched.
|
||||
"""
|
||||
agent, snapshot, agent_soul = self._resolve_agent(app_model)
|
||||
agent, agent_config_id, agent_soul = self._resolve_agent(
|
||||
app_model,
|
||||
invoke_from=invoke_from,
|
||||
draft_type="draft",
|
||||
user=user,
|
||||
)
|
||||
conversation = ConversationService.get_conversation(
|
||||
app_model=app_model, conversation_id=conversation_id, user=user
|
||||
)
|
||||
@@ -226,7 +244,7 @@ class AgentAppGenerator(MessageBasedAppGenerator):
|
||||
call_depth=0,
|
||||
trace_manager=trace_manager,
|
||||
agent_id=agent.id,
|
||||
agent_config_snapshot_id=snapshot.id,
|
||||
agent_config_snapshot_id=agent_config_id,
|
||||
)
|
||||
|
||||
conversation, message = self._init_generate_records(application_generate_entity, conversation)
|
||||
@@ -421,50 +439,135 @@ class AgentAppGenerator(MessageBasedAppGenerator):
|
||||
|
||||
return False, query
|
||||
|
||||
def _resolve_agent(self, app_model: App) -> tuple[Agent, AgentConfigSnapshot, AgentSoulConfig]:
|
||||
def _resolve_agent(
|
||||
self,
|
||||
app_model: App,
|
||||
*,
|
||||
invoke_from: InvokeFrom,
|
||||
draft_type: Any,
|
||||
user: Account | EndUser,
|
||||
) -> tuple[Agent, str, AgentSoulConfig]:
|
||||
agent = db.session.scalar(
|
||||
select(Agent).where(
|
||||
Agent.app_id == app_model.id,
|
||||
Agent.scope == AgentScope.ROSTER,
|
||||
Agent.source == AgentSource.AGENT_APP,
|
||||
select(Agent)
|
||||
.where(
|
||||
Agent.tenant_id == app_model.tenant_id,
|
||||
Agent.status == AgentStatus.ACTIVE,
|
||||
or_(
|
||||
and_(
|
||||
Agent.app_id == app_model.id,
|
||||
Agent.scope == AgentScope.ROSTER,
|
||||
Agent.source == AgentSource.AGENT_APP,
|
||||
),
|
||||
Agent.backing_app_id == app_model.id,
|
||||
),
|
||||
)
|
||||
.order_by(Agent.created_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
if agent is None:
|
||||
raise AgentAppGeneratorError("Agent App has no bound Agent")
|
||||
return self._resolve_agent_by_id(
|
||||
tenant_id=app_model.tenant_id, agent_id=agent.id, snapshot_id=agent.active_config_snapshot_id
|
||||
if invoke_from == InvokeFrom.DEBUGGER:
|
||||
draft = self._resolve_debug_draft(
|
||||
tenant_id=app_model.tenant_id,
|
||||
agent=agent,
|
||||
draft_type=draft_type,
|
||||
account_id=user.id if isinstance(user, Account) else None,
|
||||
)
|
||||
agent_soul = AgentSoulConfig.model_validate(draft.config_snapshot_dict)
|
||||
return agent, draft.id, agent_soul
|
||||
_, snapshot, agent_soul = self._resolve_agent_by_id(
|
||||
tenant_id=app_model.tenant_id,
|
||||
agent_id=agent.id,
|
||||
snapshot_id=agent.active_config_snapshot_id,
|
||||
)
|
||||
return agent, snapshot.id, agent_soul
|
||||
|
||||
@staticmethod
|
||||
def _runtime_session_snapshot_id(*, invoke_from: InvokeFrom, snapshot_id: str) -> str | None:
|
||||
"""Return the session scope snapshot id for Agent App runtime state.
|
||||
|
||||
Console preview/debug chat is an editing workspace: saving Agent Soul
|
||||
creates replacement snapshots, but the user expects the same preview
|
||||
conversation to keep context while trying prompt changes. Use a stable
|
||||
NULL snapshot scope for debugger runs so each turn can use the latest
|
||||
Agent Soul while reusing the conversation history. Published/web/API
|
||||
runs keep snapshot-scoped sessions for reproducible runtime state.
|
||||
Console preview/debug chat uses a stable Agent draft row id; build mode
|
||||
uses the current user's build-draft row id. Published/web/API runs use
|
||||
immutable published snapshot ids. This keeps runtime session continuity
|
||||
inside one editable surface without mixing draft/build/published state.
|
||||
"""
|
||||
if invoke_from == InvokeFrom.DEBUGGER:
|
||||
return None
|
||||
return snapshot_id
|
||||
|
||||
@staticmethod
|
||||
def _resolve_debug_draft(
|
||||
*, tenant_id: str, agent: Agent, draft_type: Any, account_id: str | None
|
||||
) -> AgentConfigDraft:
|
||||
effective_draft_type = (
|
||||
AgentConfigDraftType.DEBUG_BUILD
|
||||
if draft_type == AgentConfigDraftType.DEBUG_BUILD.value
|
||||
else AgentConfigDraftType.DRAFT
|
||||
)
|
||||
stmt = select(AgentConfigDraft).where(
|
||||
AgentConfigDraft.tenant_id == tenant_id,
|
||||
AgentConfigDraft.agent_id == agent.id,
|
||||
AgentConfigDraft.draft_type == effective_draft_type,
|
||||
)
|
||||
if effective_draft_type == AgentConfigDraftType.DEBUG_BUILD:
|
||||
if not account_id:
|
||||
raise AgentAppGeneratorError("Build draft requires an account user")
|
||||
stmt = stmt.where(AgentConfigDraft.account_id == account_id)
|
||||
else:
|
||||
stmt = stmt.where(AgentConfigDraft.account_id.is_(None))
|
||||
draft = db.session.scalar(stmt.order_by(AgentConfigDraft.updated_at.desc()).limit(1))
|
||||
if draft is not None:
|
||||
return draft
|
||||
if effective_draft_type == AgentConfigDraftType.DEBUG_BUILD:
|
||||
raise AgentAppGeneratorError("Agent build draft not found")
|
||||
_, snapshot, agent_soul = AgentAppGenerator._resolve_agent_by_id(
|
||||
tenant_id=tenant_id,
|
||||
agent_id=agent.id,
|
||||
snapshot_id=agent.active_config_snapshot_id,
|
||||
)
|
||||
draft = AgentConfigDraft(
|
||||
tenant_id=tenant_id,
|
||||
agent_id=agent.id,
|
||||
draft_type=AgentConfigDraftType.DRAFT,
|
||||
account_id=None,
|
||||
draft_owner_key="",
|
||||
base_snapshot_id=snapshot.id,
|
||||
config_snapshot=agent_soul,
|
||||
created_by=agent.created_by,
|
||||
updated_by=agent.updated_by,
|
||||
)
|
||||
db.session.add(draft)
|
||||
db.session.flush()
|
||||
return draft
|
||||
|
||||
@staticmethod
|
||||
def _resolve_agent_by_id(
|
||||
*, tenant_id: str, agent_id: str, snapshot_id: str | None
|
||||
) -> tuple[Agent, AgentConfigSnapshot, AgentSoulConfig]:
|
||||
) -> tuple[Agent, AgentConfigSnapshot | AgentConfigDraft, AgentSoulConfig]:
|
||||
agent = db.session.scalar(select(Agent).where(Agent.id == agent_id, Agent.tenant_id == tenant_id))
|
||||
if agent is None:
|
||||
raise AgentAppGeneratorError("Agent not found")
|
||||
if not snapshot_id:
|
||||
raise AgentAppGeneratorError("Agent has no published version")
|
||||
snapshot = db.session.scalar(select(AgentConfigSnapshot).where(AgentConfigSnapshot.id == snapshot_id))
|
||||
if snapshot is None:
|
||||
snapshot = db.session.scalar(
|
||||
select(AgentConfigSnapshot).where(
|
||||
AgentConfigSnapshot.tenant_id == tenant_id,
|
||||
AgentConfigSnapshot.agent_id == agent_id,
|
||||
AgentConfigSnapshot.id == snapshot_id,
|
||||
)
|
||||
)
|
||||
if snapshot is not None:
|
||||
agent_soul = AgentSoulConfig.model_validate(snapshot.config_snapshot_dict)
|
||||
return agent, snapshot, agent_soul
|
||||
draft = db.session.scalar(
|
||||
select(AgentConfigDraft).where(
|
||||
AgentConfigDraft.tenant_id == tenant_id,
|
||||
AgentConfigDraft.agent_id == agent_id,
|
||||
AgentConfigDraft.id == snapshot_id,
|
||||
)
|
||||
)
|
||||
if draft is None:
|
||||
raise AgentAppGeneratorError("Agent published version not found")
|
||||
agent_soul = AgentSoulConfig.model_validate(snapshot.config_snapshot_dict)
|
||||
return agent, snapshot, agent_soul
|
||||
agent_soul = AgentSoulConfig.model_validate(draft.config_snapshot_dict)
|
||||
return agent, draft, agent_soul
|
||||
|
||||
|
||||
__all__ = ["AgentAppGenerator", "AgentAppGeneratorError"]
|
||||
|
||||
@@ -37,6 +37,7 @@ from core.workflow.nodes.agent_v2.plugin_tools_builder import (
|
||||
from core.workflow.nodes.agent_v2.runtime_request_builder import (
|
||||
append_runtime_warnings,
|
||||
build_ask_human_layer_config,
|
||||
build_drive_aware_soul_mention_resolver,
|
||||
build_drive_layer_config,
|
||||
build_knowledge_layer_config,
|
||||
build_shell_layer_config,
|
||||
@@ -123,9 +124,19 @@ class AgentAppRuntimeRequestBuilder:
|
||||
}
|
||||
|
||||
drive_config = None
|
||||
soul_prompt_resolver = build_soul_mention_resolver(agent_soul)
|
||||
if dify_config.AGENT_DRIVE_MANIFEST_ENABLED:
|
||||
drive_config, drive_warnings = build_drive_layer_config(agent_soul, agent_id=context.agent_id)
|
||||
drive_config, drive_warnings = build_drive_layer_config(
|
||||
agent_soul,
|
||||
tenant_id=context.dify_context.tenant_id,
|
||||
agent_id=context.agent_id,
|
||||
)
|
||||
append_runtime_warnings(metadata, drive_warnings)
|
||||
soul_prompt_resolver = build_drive_aware_soul_mention_resolver(
|
||||
agent_soul,
|
||||
tenant_id=context.dify_context.tenant_id,
|
||||
agent_id=context.agent_id,
|
||||
)
|
||||
knowledge_config = build_knowledge_layer_config(agent_soul)
|
||||
|
||||
request = self._request_builder.build_for_agent_app(
|
||||
@@ -154,9 +165,7 @@ class AgentAppRuntimeRequestBuilder:
|
||||
),
|
||||
# ENG-616: expand slash-menu mention tokens to canonical names so
|
||||
# no frontend-internal {{#…#}} marker ever reaches the model.
|
||||
agent_soul_prompt=expand_prompt_mentions(
|
||||
agent_soul.prompt.system_prompt, build_soul_mention_resolver(agent_soul)
|
||||
).strip()
|
||||
agent_soul_prompt=expand_prompt_mentions(agent_soul.prompt.system_prompt, soul_prompt_resolver).strip()
|
||||
or None,
|
||||
user_prompt=context.user_query,
|
||||
tools=tools_layer,
|
||||
@@ -188,7 +197,7 @@ class AgentAppRuntimeRequestBuilder:
|
||||
def _plugin_daemon_plugin_id(*, plugin_id: str, model_provider: str) -> str:
|
||||
"""Return the transport plugin id expected by plugin-daemon headers."""
|
||||
if plugin_id.count("/") == 1:
|
||||
return plugin_id
|
||||
return plugin_id.split(":", 1)[0].split("@", 1)[0]
|
||||
if plugin_id:
|
||||
return ModelProviderID(plugin_id).plugin_id
|
||||
return ModelProviderID(model_provider).plugin_id
|
||||
|
||||
@@ -31,6 +31,7 @@ from core.app.entities.queue_entities import (
|
||||
QueueNodeStartedEvent,
|
||||
QueueNodeSucceededEvent,
|
||||
QueuePingEvent,
|
||||
QueueReasoningChunkEvent,
|
||||
QueueStopEvent,
|
||||
QueueTextChunkEvent,
|
||||
QueueWorkflowFailedEvent,
|
||||
@@ -47,6 +48,7 @@ from core.app.entities.task_entities import (
|
||||
MessageAudioEndStreamResponse,
|
||||
MessageAudioStreamResponse,
|
||||
PingStreamResponse,
|
||||
ReasoningChunkStreamResponse,
|
||||
StreamResponse,
|
||||
TextChunkStreamResponse,
|
||||
WorkflowAppBlockingResponse,
|
||||
@@ -571,6 +573,22 @@ class WorkflowAppGenerateTaskPipeline(GraphRuntimeStateSupport):
|
||||
|
||||
yield self._text_chunk_to_stream_response(delta_text, from_variable_selector=event.from_variable_selector)
|
||||
|
||||
def _handle_reasoning_chunk_event(
|
||||
self, event: QueueReasoningChunkEvent, **kwargs
|
||||
) -> Generator[StreamResponse, None, None]:
|
||||
"""Handle reasoning chunk events."""
|
||||
# is_final with empty reasoning is still forwarded as the "thinking finished" signal
|
||||
if not event.reasoning and not event.is_final:
|
||||
return
|
||||
yield ReasoningChunkStreamResponse(
|
||||
task_id=self._application_generate_entity.task_id,
|
||||
data=ReasoningChunkStreamResponse.Data(
|
||||
reasoning=event.reasoning,
|
||||
node_id=event.from_node_id,
|
||||
is_final=event.is_final,
|
||||
),
|
||||
)
|
||||
|
||||
def _handle_agent_log_event(self, event: QueueAgentLogEvent, **kwargs) -> Generator[StreamResponse, None, None]:
|
||||
"""Handle agent log events."""
|
||||
yield self._workflow_response_converter.handle_agent_log(
|
||||
@@ -600,6 +618,7 @@ class WorkflowAppGenerateTaskPipeline(GraphRuntimeStateSupport):
|
||||
QueuePingEvent: self._handle_ping_event,
|
||||
QueueErrorEvent: self._handle_error_event,
|
||||
QueueTextChunkEvent: self._handle_text_chunk_event,
|
||||
QueueReasoningChunkEvent: self._handle_reasoning_chunk_event,
|
||||
# Workflow events
|
||||
QueueWorkflowStartedEvent: self._handle_workflow_started_event,
|
||||
QueueWorkflowSucceededEvent: self._handle_workflow_succeeded_event,
|
||||
|
||||
@@ -24,6 +24,7 @@ from core.app.entities.queue_entities import (
|
||||
QueueNodeRetryEvent,
|
||||
QueueNodeStartedEvent,
|
||||
QueueNodeSucceededEvent,
|
||||
QueueReasoningChunkEvent,
|
||||
QueueRetrieverResourcesEvent,
|
||||
QueueTextChunkEvent,
|
||||
QueueWorkflowFailedEvent,
|
||||
@@ -74,6 +75,7 @@ from graphon.graph_events import (
|
||||
NodeRunLoopNextEvent,
|
||||
NodeRunLoopStartedEvent,
|
||||
NodeRunLoopSucceededEvent,
|
||||
NodeRunReasoningChunkEvent,
|
||||
NodeRunRetrieverResourceEvent,
|
||||
NodeRunRetryEvent,
|
||||
NodeRunStartedEvent,
|
||||
@@ -576,6 +578,16 @@ class WorkflowBasedAppRunner:
|
||||
in_loop_id=event.in_loop_id,
|
||||
)
|
||||
)
|
||||
case NodeRunReasoningChunkEvent():
|
||||
self._publish_event(
|
||||
QueueReasoningChunkEvent(
|
||||
reasoning=event.chunk,
|
||||
from_node_id=event.node_id,
|
||||
is_final=event.is_final,
|
||||
in_iteration_id=event.in_iteration_id,
|
||||
in_loop_id=event.in_loop_id,
|
||||
)
|
||||
)
|
||||
case NodeRunRetrieverResourceEvent():
|
||||
self._publish_event(
|
||||
QueueRetrieverResourcesEvent(
|
||||
|
||||
@@ -40,6 +40,7 @@ class QueueEvent(StrEnum):
|
||||
NODE_FAILED = "node_failed"
|
||||
NODE_EXCEPTION = "node_exception"
|
||||
RETRIEVER_RESOURCES = "retriever_resources"
|
||||
REASONING_CHUNK = "reasoning_chunk"
|
||||
ANNOTATION_REPLY = "annotation_reply"
|
||||
AGENT_THOUGHT = "agent_thought"
|
||||
MESSAGE_FILE = "message_file"
|
||||
@@ -197,6 +198,26 @@ class QueueTextChunkEvent(AppQueueEvent):
|
||||
"""loop id if node is in loop"""
|
||||
|
||||
|
||||
class QueueReasoningChunkEvent(AppQueueEvent):
|
||||
"""
|
||||
QueueReasoningChunkEvent entity
|
||||
|
||||
Out-of-band reasoning (chain-of-thought) delta from an LLM node in "separated"
|
||||
mode. It never touches the answer; it is emitted on a dedicated channel.
|
||||
"""
|
||||
|
||||
event: QueueEvent = QueueEvent.REASONING_CHUNK
|
||||
reasoning: str
|
||||
from_node_id: str | None = None
|
||||
"""id of the LLM node that produced this reasoning"""
|
||||
is_final: bool = False
|
||||
"""marks the terminal reasoning chunk for the node run (may carry empty reasoning)"""
|
||||
in_iteration_id: str | None = None
|
||||
"""iteration id if node is in iteration"""
|
||||
in_loop_id: str | None = None
|
||||
"""loop id if node is in loop"""
|
||||
|
||||
|
||||
class QueueAgentMessageEvent(AppQueueEvent):
|
||||
"""
|
||||
QueueMessageEvent entity
|
||||
|
||||
@@ -27,6 +27,9 @@ class TaskStateMetadata(BaseModel):
|
||||
annotation_reply: AnnotationReply | None = None
|
||||
retriever_resources: Sequence[RetrievalSourceMetadata] = Field(default_factory=list)
|
||||
usage: LLMUsage | None = None
|
||||
reasoning: dict[str, str] = Field(default_factory=dict)
|
||||
"""reasoning_content per LLM node id (separated mode), accumulated across iteration/loop
|
||||
passes for that node; persisted to message_metadata"""
|
||||
|
||||
|
||||
class TaskState(BaseModel):
|
||||
@@ -85,6 +88,7 @@ class StreamEvent(StrEnum):
|
||||
LOOP_COMPLETED = "loop_completed"
|
||||
TEXT_CHUNK = "text_chunk"
|
||||
TEXT_REPLACE = "text_replace"
|
||||
REASONING_CHUNK = "reasoning_chunk"
|
||||
AGENT_LOG = "agent_log"
|
||||
HUMAN_INPUT_REQUIRED = "human_input_required"
|
||||
HUMAN_INPUT_FORM_FILLED = "human_input_form_filled"
|
||||
@@ -726,6 +730,29 @@ class TextChunkStreamResponse(StreamResponse):
|
||||
data: Data
|
||||
|
||||
|
||||
class ReasoningChunkStreamResponse(StreamResponse):
|
||||
"""
|
||||
ReasoningChunkStreamResponse entity
|
||||
|
||||
Out-of-band reasoning (chain-of-thought) delta, parallel to text_chunk. Only
|
||||
emitted in "separated" mode; the answer/message stream stays free of <think>.
|
||||
"""
|
||||
|
||||
class Data(BaseModel):
|
||||
"""
|
||||
Data entity
|
||||
"""
|
||||
|
||||
# chat apps set this; workflow runs have no message
|
||||
message_id: str | None = None
|
||||
reasoning: str
|
||||
node_id: str | None = None
|
||||
is_final: bool = False
|
||||
|
||||
event: StreamEvent = StreamEvent.REASONING_CHUNK
|
||||
data: Data
|
||||
|
||||
|
||||
class TextReplaceStreamResponse(StreamResponse):
|
||||
"""
|
||||
TextReplaceStreamResponse entity
|
||||
|
||||
@@ -22,7 +22,10 @@ from core.entities.provider_entities import (
|
||||
SystemConfigurationStatus,
|
||||
)
|
||||
from core.helper import encrypter
|
||||
from core.helper.model_provider_cache import ProviderCredentialsCache, ProviderCredentialsCacheType
|
||||
from core.helper.model_provider_cache import (
|
||||
ProviderCredentialsCache,
|
||||
ProviderCredentialsCacheType,
|
||||
)
|
||||
from core.plugin.impl.model_runtime_factory import create_model_type_instance, create_plugin_model_assembly
|
||||
from graphon.model_runtime.entities.model_entities import AIModelEntity, FetchFrom, ModelType
|
||||
from graphon.model_runtime.entities.provider_entities import (
|
||||
@@ -473,6 +476,39 @@ class ProviderConfiguration(BaseModel):
|
||||
provider_names.append(model_provider_id.provider_name)
|
||||
return provider_names
|
||||
|
||||
def _invalidate_provider_configuration_cache(
|
||||
self,
|
||||
*,
|
||||
provider_models: bool = False,
|
||||
preferred_model_providers: bool = False,
|
||||
provider_model_settings: bool = False,
|
||||
provider_model_credentials: bool = False,
|
||||
provider_credentials: bool = False,
|
||||
provider_load_balancing_configs: bool = False,
|
||||
) -> None:
|
||||
"""Invalidate tenant-scoped provider snapshots after committing configuration writes."""
|
||||
from core.provider_manager import ProviderConfigurationCacheSource, ProviderManager
|
||||
|
||||
sources: list[ProviderConfigurationCacheSource] = []
|
||||
if provider_models:
|
||||
sources.append(ProviderConfigurationCacheSource.PROVIDER_MODELS)
|
||||
if preferred_model_providers:
|
||||
sources.append(ProviderConfigurationCacheSource.PREFERRED_MODEL_PROVIDERS)
|
||||
if provider_model_settings:
|
||||
sources.append(ProviderConfigurationCacheSource.PROVIDER_MODEL_SETTINGS)
|
||||
if provider_model_credentials:
|
||||
sources.append(ProviderConfigurationCacheSource.PROVIDER_MODEL_CREDENTIALS)
|
||||
if provider_credentials:
|
||||
sources.append(ProviderConfigurationCacheSource.PROVIDER_CREDENTIALS)
|
||||
if provider_load_balancing_configs:
|
||||
sources.append(ProviderConfigurationCacheSource.PROVIDER_LOAD_BALANCING_CONFIGS)
|
||||
|
||||
if not sources:
|
||||
logger.warning("No provider configuration cache source selected for invalidation")
|
||||
return
|
||||
|
||||
ProviderManager.invalidate_configurations_cache(self.tenant_id, sources=sources)
|
||||
|
||||
def create_provider_credential(self, credentials: dict[str, Any], credential_name: str | None):
|
||||
"""
|
||||
Add custom provider credentials.
|
||||
@@ -489,6 +525,7 @@ class ProviderConfiguration(BaseModel):
|
||||
|
||||
credentials = self.validate_provider_credentials(credentials=credentials)
|
||||
|
||||
preferred_model_providers_changed = False
|
||||
with Session(db.engine) as session:
|
||||
provider_record = self._get_provider_record(session)
|
||||
try:
|
||||
@@ -518,7 +555,9 @@ class ProviderConfiguration(BaseModel):
|
||||
)
|
||||
provider_model_credentials_cache.delete()
|
||||
|
||||
self.switch_preferred_provider_type(provider_type=ProviderType.CUSTOM, session=session)
|
||||
preferred_model_providers_changed = self.switch_preferred_provider_type(
|
||||
provider_type=ProviderType.CUSTOM, session=session
|
||||
)
|
||||
else:
|
||||
provider_record.is_valid = True
|
||||
|
||||
@@ -533,12 +572,18 @@ class ProviderConfiguration(BaseModel):
|
||||
)
|
||||
provider_model_credentials_cache.delete()
|
||||
|
||||
self.switch_preferred_provider_type(provider_type=ProviderType.CUSTOM, session=session)
|
||||
preferred_model_providers_changed = self.switch_preferred_provider_type(
|
||||
provider_type=ProviderType.CUSTOM, session=session
|
||||
)
|
||||
|
||||
session.commit()
|
||||
except Exception:
|
||||
session.rollback()
|
||||
raise
|
||||
self._invalidate_provider_configuration_cache(
|
||||
preferred_model_providers=preferred_model_providers_changed,
|
||||
provider_credentials=True,
|
||||
)
|
||||
|
||||
def update_provider_credential(
|
||||
self,
|
||||
@@ -562,6 +607,7 @@ class ProviderConfiguration(BaseModel):
|
||||
|
||||
credentials = self.validate_provider_credentials(credentials=credentials, credential_id=credential_id)
|
||||
|
||||
load_balancing_configs_changed = False
|
||||
with Session(db.engine) as session:
|
||||
provider_record = self._get_provider_record(session)
|
||||
stmt = select(ProviderCredential).where(
|
||||
@@ -588,7 +634,7 @@ class ProviderConfiguration(BaseModel):
|
||||
)
|
||||
provider_model_credentials_cache.delete()
|
||||
|
||||
self._update_load_balancing_configs_with_credential(
|
||||
load_balancing_configs_changed = self._update_load_balancing_configs_with_credential(
|
||||
credential_id=credential_id,
|
||||
credential_record=credential_record,
|
||||
credential_source=CredentialSourceType.PROVIDER,
|
||||
@@ -597,6 +643,10 @@ class ProviderConfiguration(BaseModel):
|
||||
except Exception:
|
||||
session.rollback()
|
||||
raise
|
||||
self._invalidate_provider_configuration_cache(
|
||||
provider_credentials=True,
|
||||
provider_load_balancing_configs=load_balancing_configs_changed,
|
||||
)
|
||||
|
||||
def _update_load_balancing_configs_with_credential(
|
||||
self,
|
||||
@@ -604,7 +654,7 @@ class ProviderConfiguration(BaseModel):
|
||||
credential_record: ProviderCredential | ProviderModelCredential,
|
||||
credential_source: str,
|
||||
session: Session,
|
||||
):
|
||||
) -> bool:
|
||||
"""
|
||||
Update load balancing configurations that reference the given credential_id.
|
||||
|
||||
@@ -625,7 +675,7 @@ class ProviderConfiguration(BaseModel):
|
||||
load_balancing_configs = session.execute(stmt).scalars().all()
|
||||
|
||||
if not load_balancing_configs:
|
||||
return
|
||||
return False
|
||||
|
||||
# Update each load balancing config with the new credentials
|
||||
for lb_config in load_balancing_configs:
|
||||
@@ -643,6 +693,7 @@ class ProviderConfiguration(BaseModel):
|
||||
lb_credentials_cache.delete()
|
||||
|
||||
session.commit()
|
||||
return True
|
||||
|
||||
def delete_provider_credential(self, credential_id: str):
|
||||
"""
|
||||
@@ -651,6 +702,8 @@ class ProviderConfiguration(BaseModel):
|
||||
:param credential_id: credential id
|
||||
:return:
|
||||
"""
|
||||
preferred_model_providers_changed = False
|
||||
load_balancing_configs_changed = False
|
||||
with Session(db.engine) as session:
|
||||
stmt = select(ProviderCredential).where(
|
||||
ProviderCredential.id == credential_id,
|
||||
@@ -671,6 +724,7 @@ class ProviderConfiguration(BaseModel):
|
||||
LoadBalancingModelConfig.credential_source_type == CredentialSourceType.PROVIDER,
|
||||
)
|
||||
lb_configs_using_credential = session.execute(lb_stmt).scalars().all()
|
||||
load_balancing_configs_changed = bool(lb_configs_using_credential)
|
||||
try:
|
||||
for lb_config in lb_configs_using_credential:
|
||||
lb_credentials_cache = ProviderCredentialsCache(
|
||||
@@ -703,7 +757,9 @@ class ProviderConfiguration(BaseModel):
|
||||
cache_type=ProviderCredentialsCacheType.PROVIDER,
|
||||
)
|
||||
provider_model_credentials_cache.delete()
|
||||
self.switch_preferred_provider_type(provider_type=ProviderType.SYSTEM, session=session)
|
||||
preferred_model_providers_changed = self.switch_preferred_provider_type(
|
||||
provider_type=ProviderType.SYSTEM, session=session
|
||||
)
|
||||
elif provider_record and provider_record.credential_id == credential_id:
|
||||
provider_record.credential_id = None
|
||||
provider_record.updated_at = naive_utc_now()
|
||||
@@ -714,12 +770,19 @@ class ProviderConfiguration(BaseModel):
|
||||
cache_type=ProviderCredentialsCacheType.PROVIDER,
|
||||
)
|
||||
provider_model_credentials_cache.delete()
|
||||
self.switch_preferred_provider_type(provider_type=ProviderType.SYSTEM, session=session)
|
||||
preferred_model_providers_changed = self.switch_preferred_provider_type(
|
||||
provider_type=ProviderType.SYSTEM, session=session
|
||||
)
|
||||
|
||||
session.commit()
|
||||
except Exception:
|
||||
session.rollback()
|
||||
raise
|
||||
self._invalidate_provider_configuration_cache(
|
||||
preferred_model_providers=preferred_model_providers_changed,
|
||||
provider_credentials=True,
|
||||
provider_load_balancing_configs=load_balancing_configs_changed,
|
||||
)
|
||||
|
||||
def switch_active_provider_credential(self, credential_id: str):
|
||||
"""
|
||||
@@ -728,6 +791,7 @@ class ProviderConfiguration(BaseModel):
|
||||
:param credential_id: credential id
|
||||
:return:
|
||||
"""
|
||||
preferred_model_providers_changed = False
|
||||
with Session(db.engine) as session:
|
||||
stmt = select(ProviderCredential).where(
|
||||
ProviderCredential.id == credential_id,
|
||||
@@ -753,10 +817,14 @@ class ProviderConfiguration(BaseModel):
|
||||
cache_type=ProviderCredentialsCacheType.PROVIDER,
|
||||
)
|
||||
provider_model_credentials_cache.delete()
|
||||
self.switch_preferred_provider_type(ProviderType.CUSTOM, session=session)
|
||||
preferred_model_providers_changed = self.switch_preferred_provider_type(
|
||||
ProviderType.CUSTOM, session=session
|
||||
)
|
||||
except Exception:
|
||||
session.rollback()
|
||||
raise
|
||||
if preferred_model_providers_changed:
|
||||
self._invalidate_provider_configuration_cache(preferred_model_providers=True)
|
||||
|
||||
def _get_custom_model_record(
|
||||
self,
|
||||
@@ -1017,6 +1085,10 @@ class ProviderConfiguration(BaseModel):
|
||||
except Exception:
|
||||
session.rollback()
|
||||
raise
|
||||
self._invalidate_provider_configuration_cache(
|
||||
provider_models=True,
|
||||
provider_model_credentials=True,
|
||||
)
|
||||
|
||||
def update_custom_model_credential(
|
||||
self,
|
||||
@@ -1053,6 +1125,7 @@ class ProviderConfiguration(BaseModel):
|
||||
credential_id=credential_id,
|
||||
)
|
||||
|
||||
load_balancing_configs_changed = False
|
||||
with Session(db.engine) as session:
|
||||
provider_model_record = self._get_custom_model_record(model_type=model_type, model=model, session=session)
|
||||
|
||||
@@ -1082,7 +1155,7 @@ class ProviderConfiguration(BaseModel):
|
||||
)
|
||||
provider_model_credentials_cache.delete()
|
||||
|
||||
self._update_load_balancing_configs_with_credential(
|
||||
load_balancing_configs_changed = self._update_load_balancing_configs_with_credential(
|
||||
credential_id=credential_id,
|
||||
credential_record=credential_record,
|
||||
credential_source=CredentialSourceType.CUSTOM_MODEL,
|
||||
@@ -1091,6 +1164,11 @@ class ProviderConfiguration(BaseModel):
|
||||
except Exception:
|
||||
session.rollback()
|
||||
raise
|
||||
self._invalidate_provider_configuration_cache(
|
||||
provider_models=True,
|
||||
provider_model_credentials=True,
|
||||
provider_load_balancing_configs=load_balancing_configs_changed,
|
||||
)
|
||||
|
||||
def delete_custom_model_credential(self, model_type: ModelType, model: str, credential_id: str):
|
||||
"""
|
||||
@@ -1099,6 +1177,7 @@ class ProviderConfiguration(BaseModel):
|
||||
:param credential_id: credential id
|
||||
:return:
|
||||
"""
|
||||
load_balancing_configs_changed = False
|
||||
with Session(db.engine) as session:
|
||||
stmt = select(ProviderModelCredential).where(
|
||||
ProviderModelCredential.id == credential_id,
|
||||
@@ -1118,6 +1197,7 @@ class ProviderConfiguration(BaseModel):
|
||||
LoadBalancingModelConfig.credential_source_type == CredentialSourceType.CUSTOM_MODEL,
|
||||
)
|
||||
lb_configs_using_credential = session.execute(lb_stmt).scalars().all()
|
||||
load_balancing_configs_changed = bool(lb_configs_using_credential)
|
||||
|
||||
try:
|
||||
for lb_config in lb_configs_using_credential:
|
||||
@@ -1161,6 +1241,11 @@ class ProviderConfiguration(BaseModel):
|
||||
except Exception:
|
||||
session.rollback()
|
||||
raise
|
||||
self._invalidate_provider_configuration_cache(
|
||||
provider_models=True,
|
||||
provider_model_credentials=True,
|
||||
provider_load_balancing_configs=load_balancing_configs_changed,
|
||||
)
|
||||
|
||||
def add_model_credential_to_model(self, model_type: ModelType, model: str, credential_id: str):
|
||||
"""
|
||||
@@ -1213,6 +1298,7 @@ class ProviderConfiguration(BaseModel):
|
||||
|
||||
session.add(provider_model_record)
|
||||
session.commit()
|
||||
self._invalidate_provider_configuration_cache(provider_models=True)
|
||||
|
||||
def switch_custom_model_credential(self, model_type: ModelType, model: str, credential_id: str):
|
||||
"""
|
||||
@@ -1251,6 +1337,7 @@ class ProviderConfiguration(BaseModel):
|
||||
cache_type=ProviderCredentialsCacheType.MODEL,
|
||||
)
|
||||
provider_model_credentials_cache.delete()
|
||||
self._invalidate_provider_configuration_cache(provider_models=True)
|
||||
|
||||
def delete_custom_model(self, model_type: ModelType, model: str):
|
||||
"""
|
||||
@@ -1259,6 +1346,7 @@ class ProviderConfiguration(BaseModel):
|
||||
:param model: model name
|
||||
:return:
|
||||
"""
|
||||
provider_models_changed = False
|
||||
with Session(db.engine) as session:
|
||||
# get provider model
|
||||
provider_model_record = self._get_custom_model_record(model_type=model_type, model=model, session=session)
|
||||
@@ -1267,6 +1355,7 @@ class ProviderConfiguration(BaseModel):
|
||||
if provider_model_record:
|
||||
session.delete(provider_model_record)
|
||||
session.commit()
|
||||
provider_models_changed = True
|
||||
|
||||
provider_model_credentials_cache = ProviderCredentialsCache(
|
||||
tenant_id=self.tenant_id,
|
||||
@@ -1275,6 +1364,8 @@ class ProviderConfiguration(BaseModel):
|
||||
)
|
||||
|
||||
provider_model_credentials_cache.delete()
|
||||
if provider_models_changed:
|
||||
self._invalidate_provider_configuration_cache(provider_models=True)
|
||||
|
||||
def _get_provider_model_setting(
|
||||
self, model_type: ModelType, model: str, session: Session
|
||||
@@ -1314,6 +1405,7 @@ class ProviderConfiguration(BaseModel):
|
||||
)
|
||||
session.add(model_setting)
|
||||
session.commit()
|
||||
self._invalidate_provider_configuration_cache(provider_model_settings=True)
|
||||
|
||||
return model_setting
|
||||
|
||||
@@ -1340,6 +1432,7 @@ class ProviderConfiguration(BaseModel):
|
||||
)
|
||||
session.add(model_setting)
|
||||
session.commit()
|
||||
self._invalidate_provider_configuration_cache(provider_model_settings=True)
|
||||
|
||||
return model_setting
|
||||
|
||||
@@ -1392,6 +1485,7 @@ class ProviderConfiguration(BaseModel):
|
||||
)
|
||||
session.add(model_setting)
|
||||
session.commit()
|
||||
self._invalidate_provider_configuration_cache(provider_model_settings=True)
|
||||
|
||||
return model_setting
|
||||
|
||||
@@ -1419,6 +1513,7 @@ class ProviderConfiguration(BaseModel):
|
||||
)
|
||||
session.add(model_setting)
|
||||
session.commit()
|
||||
self._invalidate_provider_configuration_cache(provider_model_settings=True)
|
||||
|
||||
return model_setting
|
||||
|
||||
@@ -1454,19 +1549,19 @@ class ProviderConfiguration(BaseModel):
|
||||
credentials=credentials or {},
|
||||
)
|
||||
|
||||
def switch_preferred_provider_type(self, provider_type: ProviderType, session: Session | None = None):
|
||||
def switch_preferred_provider_type(self, provider_type: ProviderType, session: Session | None = None) -> bool:
|
||||
"""
|
||||
Switch preferred provider type.
|
||||
:param provider_type:
|
||||
:return:
|
||||
"""
|
||||
if provider_type == self.preferred_provider_type:
|
||||
return
|
||||
return False
|
||||
|
||||
if provider_type == ProviderType.SYSTEM and not self.system_configuration.enabled:
|
||||
return
|
||||
return False
|
||||
|
||||
def _switch(s: Session):
|
||||
def _switch(s: Session) -> bool:
|
||||
stmt = select(TenantPreferredModelProvider).where(
|
||||
TenantPreferredModelProvider.tenant_id == self.tenant_id,
|
||||
TenantPreferredModelProvider.provider_name.in_(self._get_provider_names()),
|
||||
@@ -1483,12 +1578,16 @@ class ProviderConfiguration(BaseModel):
|
||||
)
|
||||
s.add(preferred_model_provider)
|
||||
s.commit()
|
||||
return True
|
||||
|
||||
if session:
|
||||
return _switch(session)
|
||||
else:
|
||||
with Session(db.engine) as session:
|
||||
return _switch(session)
|
||||
changed = _switch(session)
|
||||
if changed:
|
||||
self._invalidate_provider_configuration_cache(preferred_model_providers=True)
|
||||
return changed
|
||||
|
||||
def extract_secret_variables(self, credential_form_schemas: list[CredentialFormSchema]) -> list[str]:
|
||||
"""
|
||||
|
||||
@@ -240,7 +240,8 @@ class HostingConfiguration:
|
||||
if len(quotas) > 0:
|
||||
credentials = {
|
||||
"dashscope_api_key": dify_config.HOSTED_TONGYI_API_KEY,
|
||||
"use_international_endpoint": dify_config.HOSTED_TONGYI_USE_INTERNATIONAL_ENDPOINT,
|
||||
# SNP-494: keep temporary compatibility with tongyi plugin string credential checks.
|
||||
"use_international_endpoint": str(dify_config.HOSTED_TONGYI_USE_INTERNATIONAL_ENDPOINT).lower(),
|
||||
}
|
||||
|
||||
return HostingProvider(enabled=True, credentials=credentials, quota_unit=quota_unit, quotas=quotas)
|
||||
|
||||
@@ -2,7 +2,7 @@ import json
|
||||
import logging
|
||||
import re
|
||||
from collections.abc import Sequence
|
||||
from typing import Any, NotRequired, Protocol, TypedDict, cast
|
||||
from typing import Any, Literal, NotRequired, Protocol, TypedDict, cast
|
||||
|
||||
import json_repair
|
||||
from sqlalchemy import select
|
||||
@@ -69,6 +69,53 @@ def _normalize_completion_params(completion_params: dict[str, object]) -> tuple[
|
||||
return normalized_parameters, stop
|
||||
|
||||
|
||||
# ── Workflow instruction-suggestion tuning ────────────────────────────────
|
||||
# Suggestions are a soft, pre-model-pick enhancement: short, buildable example
|
||||
# instructions proposed from the tenant's DEFAULT model. Every failure path
|
||||
# degrades to an empty list, never an error.
|
||||
_SUGGESTION_MIN_COUNT = 1
|
||||
_SUGGESTION_MAX_COUNT = 6
|
||||
_SUGGESTION_MAX_TOKENS = 512
|
||||
_SUGGESTION_TEMPERATURE = 0.8
|
||||
# Bound the grounding context so the prompt stays small regardless of how many
|
||||
# knowledge bases / tools the tenant has installed.
|
||||
_SUGGESTION_KB_LIMIT = 10
|
||||
_SUGGESTION_TOOL_SAMPLE_LINES = 20
|
||||
|
||||
_SUGGESTION_SYSTEM_PROMPT = (
|
||||
"You help a user start building a Dify app by proposing example build instructions. "
|
||||
"Each suggestion must be a SHORT (at most 8 words), concrete, and BUILDABLE instruction "
|
||||
"describing an app to generate for the given app type. Make the suggestions diverse — cover "
|
||||
"different use cases. When the listed knowledge bases or installed tools fit a suggestion, "
|
||||
"prefer them, but NEVER invent tools or knowledge bases that are not listed. "
|
||||
"Reply with ONLY a JSON array of strings and nothing else."
|
||||
)
|
||||
|
||||
|
||||
def _parse_string_list(text: str) -> list[str]:
|
||||
"""Extract a JSON array of strings from a (possibly noisy) LLM response.
|
||||
|
||||
Slices the first ``[...]`` span so surrounding prose / markdown fences are
|
||||
tolerated, parses it with ``json`` and falls back to ``json_repair``, then
|
||||
keeps only ``str`` items. Returns ``[]`` on any failure so callers can
|
||||
treat parsing as best-effort.
|
||||
"""
|
||||
match = re.search(r"\[.*\]", text.strip(), re.DOTALL)
|
||||
if not match:
|
||||
return []
|
||||
raw = match.group(0)
|
||||
try:
|
||||
parsed = json.loads(raw)
|
||||
except Exception:
|
||||
try:
|
||||
parsed = json_repair.loads(raw)
|
||||
except Exception:
|
||||
return []
|
||||
if not isinstance(parsed, list):
|
||||
return []
|
||||
return [item for item in parsed if isinstance(item, str)]
|
||||
|
||||
|
||||
class WorkflowServiceInterface(Protocol):
|
||||
def get_draft_workflow(self, app_model: App, workflow_id: str | None = None) -> Workflow | None:
|
||||
pass
|
||||
@@ -237,6 +284,170 @@ class LLMGenerator:
|
||||
|
||||
return questions
|
||||
|
||||
@classmethod
|
||||
def generate_workflow_instruction_suggestions(
|
||||
cls,
|
||||
tenant_id: str,
|
||||
*,
|
||||
mode: Literal["workflow", "advanced-chat"],
|
||||
language: str | None = None,
|
||||
count: int = 4,
|
||||
) -> list[str]:
|
||||
"""Propose short, buildable example instructions for the workflow generator.
|
||||
|
||||
Runs BEFORE the user picks a model, so it uses the tenant's DEFAULT LLM
|
||||
only. Suggestions are a soft enhancement, never a blocker: every failure
|
||||
path (no default model, KB / tool lookup error, LLM error, unparseable
|
||||
output) is swallowed and surfaced as an empty list — a valid result the
|
||||
caller renders as "no suggestions". This method NEVER raises.
|
||||
"""
|
||||
count = max(_SUGGESTION_MIN_COUNT, min(count, _SUGGESTION_MAX_COUNT))
|
||||
|
||||
try:
|
||||
model_instance = ModelManager.for_tenant(tenant_id=tenant_id).get_default_model_instance(
|
||||
tenant_id=tenant_id,
|
||||
model_type=ModelType.LLM,
|
||||
)
|
||||
except Exception:
|
||||
logger.info("Workflow instruction suggestions: no default model for tenant %s", tenant_id)
|
||||
return []
|
||||
|
||||
context_block = cls._build_suggestion_context(tenant_id)
|
||||
app_type_label = (
|
||||
"Workflow — single-shot automation" if mode == "workflow" else "Chatflow — conversational multi-turn"
|
||||
)
|
||||
|
||||
user_lines = [
|
||||
f"App type: {app_type_label}",
|
||||
context_block,
|
||||
f"Return exactly {count} distinct ideas as a JSON array of strings.",
|
||||
]
|
||||
if language:
|
||||
user_lines.append(f"Write every idea in this language: {language}.")
|
||||
user_prompt = "\n".join(line for line in user_lines if line)
|
||||
|
||||
prompt_messages: list[PromptMessage] = [
|
||||
SystemPromptMessage(content=_SUGGESTION_SYSTEM_PROMPT),
|
||||
UserPromptMessage(content=user_prompt),
|
||||
]
|
||||
|
||||
try:
|
||||
response: LLMResult = model_instance.invoke_llm(
|
||||
prompt_messages=prompt_messages,
|
||||
model_parameters={"max_tokens": _SUGGESTION_MAX_TOKENS, "temperature": _SUGGESTION_TEMPERATURE},
|
||||
stream=False,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Workflow instruction suggestions: LLM invocation failed")
|
||||
return []
|
||||
|
||||
raw_suggestions = _parse_string_list(response.message.get_text_content() or "")
|
||||
|
||||
# Strip whitespace + surrounding quotes, drop empties, dedupe
|
||||
# case-insensitively (preserving first-seen casing), cap to ``count``.
|
||||
cleaned: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for item in raw_suggestions:
|
||||
idea = item.strip().strip("\"'").strip()
|
||||
if not idea:
|
||||
continue
|
||||
key = idea.casefold()
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
cleaned.append(idea)
|
||||
if len(cleaned) >= count:
|
||||
break
|
||||
return cleaned
|
||||
|
||||
@staticmethod
|
||||
def _build_suggestion_context(tenant_id: str) -> str:
|
||||
"""Assemble an optional grounding block naming the tenant's KBs and tools.
|
||||
|
||||
Best-effort: each section is isolated in its own try/except so a failure
|
||||
enumerating one (DB hiccup, plugin daemon down) never blocks the other
|
||||
or the suggestion call itself. Returns "" when nothing is available.
|
||||
"""
|
||||
sections: list[str] = []
|
||||
|
||||
try:
|
||||
from models.dataset import Dataset
|
||||
|
||||
names = db.session.scalars(
|
||||
select(Dataset.name)
|
||||
.where(Dataset.tenant_id == tenant_id)
|
||||
.order_by(Dataset.created_at.desc())
|
||||
.limit(_SUGGESTION_KB_LIMIT)
|
||||
).all()
|
||||
kb_names = [name for name in names if name]
|
||||
if kb_names:
|
||||
sections.append("Knowledge bases:\n" + "\n".join(f"- {name}" for name in kb_names))
|
||||
except Exception:
|
||||
logger.info("Workflow instruction suggestions: failed to load knowledge bases", exc_info=True)
|
||||
|
||||
try:
|
||||
from core.workflow.generator.tool_catalogue import build_tool_catalogue, format_tool_catalogue
|
||||
|
||||
tool_text = format_tool_catalogue(build_tool_catalogue(tenant_id))
|
||||
if tool_text:
|
||||
sample = "\n".join(tool_text.splitlines()[:_SUGGESTION_TOOL_SAMPLE_LINES])
|
||||
sections.append("Installed tools:\n" + sample)
|
||||
except Exception:
|
||||
logger.info("Workflow instruction suggestions: failed to load tool catalogue", exc_info=True)
|
||||
|
||||
if not sections:
|
||||
return ""
|
||||
return "\n\n".join(sections) + "\n\n"
|
||||
|
||||
@classmethod
|
||||
def classify_workflow_mode(
|
||||
cls,
|
||||
tenant_id: str,
|
||||
instruction: str,
|
||||
model_config: ModelConfig,
|
||||
) -> Literal["workflow", "advanced-chat"]:
|
||||
"""Classify a free-text instruction into a concrete app mode.
|
||||
|
||||
One tiny LLM call using the model the user already picked (so no extra
|
||||
provider setup is needed). Parsed leniently; defaults to
|
||||
``advanced-chat`` on anything unexpected or any error, so a
|
||||
``mode="auto"`` request never blocks generation. NEVER raises.
|
||||
"""
|
||||
default_mode: Literal["workflow", "advanced-chat"] = "advanced-chat"
|
||||
try:
|
||||
model_instance = ModelManager.for_tenant(tenant_id=tenant_id).get_model_instance(
|
||||
tenant_id=tenant_id,
|
||||
model_type=ModelType.LLM,
|
||||
provider=model_config.provider,
|
||||
model=model_config.name,
|
||||
)
|
||||
prompt_messages: list[PromptMessage] = [
|
||||
UserPromptMessage(
|
||||
content=(
|
||||
"Reply with exactly one word: 'workflow' (one-shot automation, no chat) "
|
||||
"or 'advanced-chat' (conversational multi-turn). "
|
||||
f"Instruction: {instruction.strip()}"
|
||||
)
|
||||
),
|
||||
]
|
||||
response: LLMResult = model_instance.invoke_llm(
|
||||
prompt_messages=prompt_messages,
|
||||
model_parameters={"max_tokens": 4, "temperature": 0},
|
||||
stream=False,
|
||||
)
|
||||
text = (response.message.get_text_content() or "").strip().lower()
|
||||
except Exception:
|
||||
logger.info("Workflow mode classification failed; defaulting to %s", default_mode, exc_info=True)
|
||||
return default_mode
|
||||
|
||||
# Lenient parse: an affirmative "workflow" wins; everything else
|
||||
# (including a truncated / empty / garbled reply) falls back to the
|
||||
# conversational default. "advanced-chat" needs no positive match
|
||||
# because it IS the default.
|
||||
if "workflow" in text:
|
||||
return "workflow"
|
||||
return default_mode
|
||||
|
||||
@classmethod
|
||||
def generate_rule_config(cls, tenant_id: str, args: RuleGeneratePayload):
|
||||
output_parser = RuleConfigGeneratorOutputParser()
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import logging
|
||||
import re
|
||||
from collections.abc import Callable
|
||||
from contextlib import AbstractContextManager, ExitStack
|
||||
from types import TracebackType
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from flask import has_request_context, request
|
||||
|
||||
from core.mcp.client.sse_client import sse_client
|
||||
from core.mcp.client.streamable_client import streamablehttp_client
|
||||
from core.mcp.error import MCPConnectionError
|
||||
@@ -23,10 +26,22 @@ class MCPClient:
|
||||
sse_read_timeout: float | None = None,
|
||||
):
|
||||
self.server_url = server_url
|
||||
self.headers = headers or {}
|
||||
self.headers = headers.copy() if headers else {}
|
||||
self.timeout = timeout
|
||||
self.sse_read_timeout = sse_read_timeout
|
||||
|
||||
# Substitute placeholders with incoming request headers if in a request context
|
||||
if has_request_context() and self.headers:
|
||||
pattern = re.compile(r"\{\{\s*request\.headers?\.(.+?)\s*\}\}", re.IGNORECASE)
|
||||
for key, value in list(self.headers.items()):
|
||||
if isinstance(value, str):
|
||||
|
||||
def replace_func(match):
|
||||
header_name = match.group(1)
|
||||
return request.headers.get(header_name, "")
|
||||
|
||||
self.headers[key] = pattern.sub(replace_func, value)
|
||||
|
||||
# Initialize session and client objects
|
||||
self._session: ClientSession | None = None
|
||||
self._exit_stack = ExitStack()
|
||||
|
||||
+620
-55
@@ -1,10 +1,14 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import json
|
||||
import logging
|
||||
from collections import defaultdict
|
||||
from collections.abc import Sequence
|
||||
from collections.abc import Callable, Sequence
|
||||
from dataclasses import asdict, dataclass
|
||||
from enum import StrEnum
|
||||
from json import JSONDecodeError
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from typing import TYPE_CHECKING, Any, Protocol, Self
|
||||
|
||||
from pydantic import TypeAdapter
|
||||
from sqlalchemy import select
|
||||
@@ -41,6 +45,7 @@ from graphon.model_runtime.entities.provider_entities import (
|
||||
ProviderEntity,
|
||||
)
|
||||
from graphon.model_runtime.model_providers.model_provider_factory import ModelProviderFactory
|
||||
from models.enums import CredentialSourceType
|
||||
from models.provider import (
|
||||
LoadBalancingModelConfig,
|
||||
Provider,
|
||||
@@ -59,7 +64,494 @@ if TYPE_CHECKING:
|
||||
from graphon.model_runtime.protocols.runtime import ModelRuntime
|
||||
from models.account import Account
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_credentials_adapter: TypeAdapter[dict[str, Any]] = TypeAdapter(dict[str, Any])
|
||||
_PROVIDER_CONFIGURATION_CACHE_TTL_SECONDS = 300
|
||||
_PROVIDER_CONFIGURATION_CACHE_VERSION_TTL_SECONDS = 360
|
||||
_PROVIDER_CONFIGURATION_CACHE_VERSION_KEY = "provider_configurations:tenant:{tenant_id}:source:{source}:version"
|
||||
_PROVIDER_CONFIGURATION_CACHE_SOURCE_KEY = "provider_configurations:tenant:{tenant_id}:source:{source}:v:{version}"
|
||||
|
||||
|
||||
class ProviderConfigurationCacheSource(StrEnum):
|
||||
PROVIDER_MODELS = "provider_models"
|
||||
PREFERRED_MODEL_PROVIDERS = "preferred_model_providers"
|
||||
PROVIDER_MODEL_SETTINGS = "provider_model_settings"
|
||||
PROVIDER_MODEL_CREDENTIALS = "provider_model_credentials"
|
||||
PROVIDER_CREDENTIALS = "provider_credentials"
|
||||
PROVIDER_LOAD_BALANCING_CONFIGS = "provider_load_balancing_configs"
|
||||
|
||||
|
||||
_PROVIDER_CONFIGURATION_SOURCES = tuple(ProviderConfigurationCacheSource)
|
||||
|
||||
|
||||
class _CacheEntry(Protocol):
|
||||
@classmethod
|
||||
def from_cache_row(cls, row: dict[str, Any]) -> Self: ...
|
||||
|
||||
def to_cache_row(self) -> dict[str, Any]: ...
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _ProviderConfigurationCacheSourceSpec[T: _CacheEntry]:
|
||||
name: ProviderConfigurationCacheSource
|
||||
entry_cls: type[T]
|
||||
load_records: Callable[[str], list[T]]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _ProviderModelCacheEntry:
|
||||
id: str
|
||||
provider_name: str
|
||||
model_name: str
|
||||
model_type: ModelType
|
||||
credential_id: str | None
|
||||
credential_name: str | None
|
||||
encrypted_config: str | None
|
||||
|
||||
@classmethod
|
||||
def from_record(cls, record: ProviderModel) -> _ProviderModelCacheEntry:
|
||||
credential = record.__dict__.get("credential")
|
||||
return cls(
|
||||
id=record.id,
|
||||
provider_name=record.provider_name,
|
||||
model_name=record.model_name,
|
||||
model_type=record.model_type,
|
||||
credential_id=record.credential_id,
|
||||
credential_name=credential.credential_name if credential else None,
|
||||
encrypted_config=credential.encrypted_config if credential else None,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_cache_row(cls, row: dict[str, Any]) -> _ProviderModelCacheEntry:
|
||||
return cls(
|
||||
id=row["id"],
|
||||
provider_name=row["provider_name"],
|
||||
model_name=row["model_name"],
|
||||
model_type=ModelType(row["model_type"]),
|
||||
credential_id=row.get("credential_id"),
|
||||
credential_name=row.get("credential_name"),
|
||||
encrypted_config=row.get("encrypted_config"),
|
||||
)
|
||||
|
||||
def to_cache_row(self) -> dict[str, Any]:
|
||||
row = asdict(self)
|
||||
row["model_type"] = self.model_type.value
|
||||
return row
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _TenantPreferredModelProviderCacheEntry:
|
||||
provider_name: str
|
||||
preferred_provider_type: ProviderType
|
||||
|
||||
@classmethod
|
||||
def from_record(cls, record: TenantPreferredModelProvider) -> _TenantPreferredModelProviderCacheEntry:
|
||||
return cls(
|
||||
provider_name=record.provider_name,
|
||||
preferred_provider_type=record.preferred_provider_type,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_cache_row(cls, row: dict[str, Any]) -> _TenantPreferredModelProviderCacheEntry:
|
||||
return cls(
|
||||
provider_name=row["provider_name"],
|
||||
preferred_provider_type=ProviderType(row["preferred_provider_type"]),
|
||||
)
|
||||
|
||||
def to_cache_row(self) -> dict[str, Any]:
|
||||
return {
|
||||
"provider_name": self.provider_name,
|
||||
"preferred_provider_type": self.preferred_provider_type.value,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _ProviderModelSettingCacheEntry:
|
||||
provider_name: str
|
||||
model_name: str
|
||||
model_type: ModelType
|
||||
enabled: bool
|
||||
load_balancing_enabled: bool
|
||||
|
||||
@classmethod
|
||||
def from_record(cls, record: ProviderModelSetting) -> _ProviderModelSettingCacheEntry:
|
||||
return cls(
|
||||
provider_name=record.provider_name,
|
||||
model_name=record.model_name,
|
||||
model_type=record.model_type,
|
||||
enabled=record.enabled,
|
||||
load_balancing_enabled=record.load_balancing_enabled,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_cache_row(cls, row: dict[str, Any]) -> _ProviderModelSettingCacheEntry:
|
||||
return cls(
|
||||
provider_name=row["provider_name"],
|
||||
model_name=row["model_name"],
|
||||
model_type=ModelType(row["model_type"]),
|
||||
enabled=row["enabled"],
|
||||
load_balancing_enabled=row["load_balancing_enabled"],
|
||||
)
|
||||
|
||||
def to_cache_row(self) -> dict[str, Any]:
|
||||
return {
|
||||
"provider_name": self.provider_name,
|
||||
"model_name": self.model_name,
|
||||
"model_type": self.model_type.value,
|
||||
"enabled": self.enabled,
|
||||
"load_balancing_enabled": self.load_balancing_enabled,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _ProviderModelCredentialCacheEntry:
|
||||
id: str
|
||||
provider_name: str
|
||||
model_name: str
|
||||
model_type: ModelType
|
||||
credential_name: str
|
||||
|
||||
@classmethod
|
||||
def from_record(cls, record: ProviderModelCredential) -> _ProviderModelCredentialCacheEntry:
|
||||
return cls(
|
||||
id=record.id,
|
||||
provider_name=record.provider_name,
|
||||
model_name=record.model_name,
|
||||
model_type=record.model_type,
|
||||
credential_name=record.credential_name,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_cache_row(cls, row: dict[str, Any]) -> _ProviderModelCredentialCacheEntry:
|
||||
return cls(
|
||||
id=row["id"],
|
||||
provider_name=row["provider_name"],
|
||||
model_name=row["model_name"],
|
||||
model_type=ModelType(row["model_type"]),
|
||||
credential_name=row["credential_name"],
|
||||
)
|
||||
|
||||
def to_cache_row(self) -> dict[str, Any]:
|
||||
return {
|
||||
"id": self.id,
|
||||
"provider_name": self.provider_name,
|
||||
"model_name": self.model_name,
|
||||
"model_type": self.model_type.value,
|
||||
"credential_name": self.credential_name,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _ProviderCredentialCacheEntry:
|
||||
id: str
|
||||
provider_name: str
|
||||
credential_name: str
|
||||
|
||||
@classmethod
|
||||
def from_record(cls, record: ProviderCredential) -> _ProviderCredentialCacheEntry:
|
||||
return cls(
|
||||
id=record.id,
|
||||
provider_name=record.provider_name,
|
||||
credential_name=record.credential_name,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_cache_row(cls, row: dict[str, Any]) -> _ProviderCredentialCacheEntry:
|
||||
return cls(
|
||||
id=row["id"],
|
||||
provider_name=row["provider_name"],
|
||||
credential_name=row["credential_name"],
|
||||
)
|
||||
|
||||
def to_cache_row(self) -> dict[str, Any]:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _LoadBalancingModelConfigCacheEntry:
|
||||
id: str
|
||||
tenant_id: str
|
||||
provider_name: str
|
||||
model_name: str
|
||||
model_type: ModelType
|
||||
name: str
|
||||
encrypted_config: str | None
|
||||
credential_id: str | None
|
||||
credential_source_type: CredentialSourceType | None
|
||||
enabled: bool
|
||||
|
||||
@classmethod
|
||||
def from_record(cls, record: LoadBalancingModelConfig) -> _LoadBalancingModelConfigCacheEntry:
|
||||
return cls(
|
||||
id=record.id,
|
||||
tenant_id=record.tenant_id,
|
||||
provider_name=record.provider_name,
|
||||
model_name=record.model_name,
|
||||
model_type=record.model_type,
|
||||
name=record.name,
|
||||
encrypted_config=record.encrypted_config,
|
||||
credential_id=record.credential_id,
|
||||
credential_source_type=record.credential_source_type,
|
||||
enabled=record.enabled,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_cache_row(cls, row: dict[str, Any]) -> _LoadBalancingModelConfigCacheEntry:
|
||||
return cls(
|
||||
id=row["id"],
|
||||
tenant_id=row["tenant_id"],
|
||||
provider_name=row["provider_name"],
|
||||
model_name=row["model_name"],
|
||||
model_type=ModelType(row["model_type"]),
|
||||
name=row["name"],
|
||||
encrypted_config=row.get("encrypted_config"),
|
||||
credential_id=row.get("credential_id"),
|
||||
credential_source_type=CredentialSourceType(row["credential_source_type"])
|
||||
if row.get("credential_source_type")
|
||||
else None,
|
||||
enabled=row["enabled"],
|
||||
)
|
||||
|
||||
def to_cache_row(self) -> dict[str, Any]:
|
||||
return {
|
||||
"id": self.id,
|
||||
"tenant_id": self.tenant_id,
|
||||
"provider_name": self.provider_name,
|
||||
"model_name": self.model_name,
|
||||
"model_type": self.model_type.value,
|
||||
"name": self.name,
|
||||
"encrypted_config": self.encrypted_config,
|
||||
"credential_id": self.credential_id,
|
||||
"credential_source_type": self.credential_source_type.value if self.credential_source_type else None,
|
||||
"enabled": self.enabled,
|
||||
}
|
||||
|
||||
|
||||
class _ProviderConfigurationSourceCache:
|
||||
"""Redis-backed cache for tenant provider DB cache entries.
|
||||
|
||||
The assembled ``ProviderConfigurations`` object is intentionally not cached
|
||||
here because it carries request-scoped runtime bindings. Cache only the DB
|
||||
rows that are stable enough to reuse across processes, then let each
|
||||
``ProviderManager`` assemble and bind fresh runtime-aware entities.
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def get_records[T: _CacheEntry](
|
||||
cls,
|
||||
*,
|
||||
tenant_id: str,
|
||||
source: ProviderConfigurationCacheSource,
|
||||
entry_cls: type[T],
|
||||
) -> tuple[list[T] | None, str | None]:
|
||||
version: str | None = None
|
||||
try:
|
||||
version = cls._get_version(tenant_id=tenant_id, source=source)
|
||||
cache_key = cls._source_key(tenant_id=tenant_id, source=source, version=version)
|
||||
cached_records = redis_client.get(cache_key)
|
||||
if cached_records is None:
|
||||
return None, version
|
||||
|
||||
cached_text = cached_records.decode("utf-8") if isinstance(cached_records, bytes) else cached_records
|
||||
rows = json.loads(cached_text)
|
||||
if not isinstance(rows, list):
|
||||
return None, version
|
||||
|
||||
return [entry_cls.from_cache_row(row) for row in rows if isinstance(row, dict)], version
|
||||
except Exception:
|
||||
logger.warning("Failed to read provider configuration source cache", exc_info=True)
|
||||
return None, version
|
||||
|
||||
@classmethod
|
||||
def set_records(
|
||||
cls,
|
||||
*,
|
||||
tenant_id: str,
|
||||
source: ProviderConfigurationCacheSource,
|
||||
records: Sequence[_CacheEntry],
|
||||
expected_version: str | None = None,
|
||||
) -> None:
|
||||
try:
|
||||
version = cls._get_version(tenant_id=tenant_id, source=source)
|
||||
if expected_version is not None and version != expected_version:
|
||||
return
|
||||
cache_key = cls._source_key(tenant_id=tenant_id, source=source, version=version)
|
||||
rows = [record.to_cache_row() for record in records]
|
||||
redis_client.setex(cache_key, _PROVIDER_CONFIGURATION_CACHE_TTL_SECONDS, json.dumps(rows))
|
||||
except Exception:
|
||||
logger.warning("Failed to write provider configuration source cache", exc_info=True)
|
||||
|
||||
@classmethod
|
||||
def invalidate_tenant(
|
||||
cls,
|
||||
tenant_id: str,
|
||||
sources: Sequence[ProviderConfigurationCacheSource] | None = None,
|
||||
) -> None:
|
||||
try:
|
||||
if sources is None:
|
||||
sources = _PROVIDER_CONFIGURATION_SOURCES
|
||||
for source in sources:
|
||||
version_key = _PROVIDER_CONFIGURATION_CACHE_VERSION_KEY.format(tenant_id=tenant_id, source=source.value)
|
||||
redis_client.incr(version_key)
|
||||
redis_client.expire(version_key, _PROVIDER_CONFIGURATION_CACHE_VERSION_TTL_SECONDS)
|
||||
except Exception:
|
||||
logger.warning("Failed to invalidate provider configuration source cache", exc_info=True)
|
||||
|
||||
@classmethod
|
||||
def _get_version(cls, *, tenant_id: str, source: ProviderConfigurationCacheSource) -> str:
|
||||
version_key = _PROVIDER_CONFIGURATION_CACHE_VERSION_KEY.format(tenant_id=tenant_id, source=source.value)
|
||||
version = redis_client.get(version_key)
|
||||
if version is None:
|
||||
redis_client.set(version_key, "0", ex=_PROVIDER_CONFIGURATION_CACHE_VERSION_TTL_SECONDS)
|
||||
return "0"
|
||||
redis_client.expire(version_key, _PROVIDER_CONFIGURATION_CACHE_VERSION_TTL_SECONDS)
|
||||
return version.decode("utf-8") if isinstance(version, bytes) else str(version)
|
||||
|
||||
@staticmethod
|
||||
def _source_key(*, tenant_id: str, source: ProviderConfigurationCacheSource, version: str) -> str:
|
||||
return _PROVIDER_CONFIGURATION_CACHE_SOURCE_KEY.format(
|
||||
tenant_id=tenant_id,
|
||||
source=source.value,
|
||||
version=version,
|
||||
)
|
||||
|
||||
|
||||
def _get_cached_or_load_records[T: _CacheEntry](
|
||||
*,
|
||||
tenant_id: str,
|
||||
cache_source: _ProviderConfigurationCacheSourceSpec[T],
|
||||
) -> list[T]:
|
||||
cached_records, cache_version = _ProviderConfigurationSourceCache.get_records(
|
||||
tenant_id=tenant_id,
|
||||
source=cache_source.name,
|
||||
entry_cls=cache_source.entry_cls,
|
||||
)
|
||||
if cached_records is not None:
|
||||
return cached_records
|
||||
|
||||
records = cache_source.load_records(tenant_id)
|
||||
_ProviderConfigurationSourceCache.set_records(
|
||||
tenant_id=tenant_id,
|
||||
source=cache_source.name,
|
||||
records=records,
|
||||
expected_version=cache_version,
|
||||
)
|
||||
return records
|
||||
|
||||
|
||||
def _attach_active_credentials(
|
||||
*,
|
||||
session: Any,
|
||||
records: Sequence[Provider | ProviderModel],
|
||||
credential_model_cls: type[ProviderCredential | ProviderModelCredential],
|
||||
) -> None:
|
||||
credential_ids = [record.credential_id for record in records if getattr(record, "credential_id", None)]
|
||||
if not credential_ids:
|
||||
return
|
||||
|
||||
credentials = session.scalars(select(credential_model_cls).where(credential_model_cls.id.in_(credential_ids))).all()
|
||||
credential_by_id = {credential.id: credential for credential in credentials}
|
||||
for record in records:
|
||||
if getattr(record, "credential_id", None):
|
||||
record.__dict__["credential"] = credential_by_id.get(record.credential_id)
|
||||
|
||||
|
||||
def _load_provider_model_cache_entries(tenant_id: str) -> list[_ProviderModelCacheEntry]:
|
||||
with session_factory.create_session() as session:
|
||||
stmt = select(ProviderModel).where(ProviderModel.tenant_id == tenant_id, ProviderModel.is_valid == True)
|
||||
provider_models = list(session.scalars(stmt))
|
||||
_attach_active_credentials(
|
||||
session=session,
|
||||
records=provider_models,
|
||||
credential_model_cls=ProviderModelCredential,
|
||||
)
|
||||
return [_ProviderModelCacheEntry.from_record(provider_model) for provider_model in provider_models]
|
||||
|
||||
|
||||
def _load_preferred_model_provider_cache_entries(tenant_id: str) -> list[_TenantPreferredModelProviderCacheEntry]:
|
||||
with session_factory.create_session() as session:
|
||||
stmt = select(TenantPreferredModelProvider).where(TenantPreferredModelProvider.tenant_id == tenant_id)
|
||||
return [
|
||||
_TenantPreferredModelProviderCacheEntry.from_record(preferred_model_provider)
|
||||
for preferred_model_provider in session.scalars(stmt)
|
||||
]
|
||||
|
||||
|
||||
def _load_provider_model_setting_cache_entries(tenant_id: str) -> list[_ProviderModelSettingCacheEntry]:
|
||||
with session_factory.create_session() as session:
|
||||
stmt = select(ProviderModelSetting).where(ProviderModelSetting.tenant_id == tenant_id)
|
||||
return [
|
||||
_ProviderModelSettingCacheEntry.from_record(provider_model_setting)
|
||||
for provider_model_setting in session.scalars(stmt)
|
||||
]
|
||||
|
||||
|
||||
def _load_provider_model_credential_cache_entries(tenant_id: str) -> list[_ProviderModelCredentialCacheEntry]:
|
||||
with session_factory.create_session() as session:
|
||||
stmt = (
|
||||
select(ProviderModelCredential)
|
||||
.where(ProviderModelCredential.tenant_id == tenant_id)
|
||||
.order_by(ProviderModelCredential.created_at.desc())
|
||||
)
|
||||
return [
|
||||
_ProviderModelCredentialCacheEntry.from_record(provider_model_credential)
|
||||
for provider_model_credential in session.scalars(stmt)
|
||||
]
|
||||
|
||||
|
||||
def _load_provider_credential_cache_entries(tenant_id: str) -> list[_ProviderCredentialCacheEntry]:
|
||||
with session_factory.create_session() as session:
|
||||
stmt = (
|
||||
select(ProviderCredential)
|
||||
.where(ProviderCredential.tenant_id == tenant_id)
|
||||
.order_by(ProviderCredential.created_at.desc())
|
||||
)
|
||||
return [
|
||||
_ProviderCredentialCacheEntry.from_record(provider_credential)
|
||||
for provider_credential in session.scalars(stmt)
|
||||
]
|
||||
|
||||
|
||||
def _load_provider_load_balancing_config_cache_entries(tenant_id: str) -> list[_LoadBalancingModelConfigCacheEntry]:
|
||||
with session_factory.create_session() as session:
|
||||
stmt = select(LoadBalancingModelConfig).where(LoadBalancingModelConfig.tenant_id == tenant_id)
|
||||
return [
|
||||
_LoadBalancingModelConfigCacheEntry.from_record(load_balancing_model_config)
|
||||
for load_balancing_model_config in session.scalars(stmt)
|
||||
]
|
||||
|
||||
|
||||
_PROVIDER_MODELS_CACHE_SOURCE = _ProviderConfigurationCacheSourceSpec(
|
||||
name=ProviderConfigurationCacheSource.PROVIDER_MODELS,
|
||||
entry_cls=_ProviderModelCacheEntry,
|
||||
load_records=_load_provider_model_cache_entries,
|
||||
)
|
||||
_PREFERRED_MODEL_PROVIDERS_CACHE_SOURCE = _ProviderConfigurationCacheSourceSpec(
|
||||
name=ProviderConfigurationCacheSource.PREFERRED_MODEL_PROVIDERS,
|
||||
entry_cls=_TenantPreferredModelProviderCacheEntry,
|
||||
load_records=_load_preferred_model_provider_cache_entries,
|
||||
)
|
||||
_PROVIDER_MODEL_SETTINGS_CACHE_SOURCE = _ProviderConfigurationCacheSourceSpec(
|
||||
name=ProviderConfigurationCacheSource.PROVIDER_MODEL_SETTINGS,
|
||||
entry_cls=_ProviderModelSettingCacheEntry,
|
||||
load_records=_load_provider_model_setting_cache_entries,
|
||||
)
|
||||
_PROVIDER_MODEL_CREDENTIALS_CACHE_SOURCE = _ProviderConfigurationCacheSourceSpec(
|
||||
name=ProviderConfigurationCacheSource.PROVIDER_MODEL_CREDENTIALS,
|
||||
entry_cls=_ProviderModelCredentialCacheEntry,
|
||||
load_records=_load_provider_model_credential_cache_entries,
|
||||
)
|
||||
_PROVIDER_CREDENTIALS_CACHE_SOURCE = _ProviderConfigurationCacheSourceSpec(
|
||||
name=ProviderConfigurationCacheSource.PROVIDER_CREDENTIALS,
|
||||
entry_cls=_ProviderCredentialCacheEntry,
|
||||
load_records=_load_provider_credential_cache_entries,
|
||||
)
|
||||
_PROVIDER_LOAD_BALANCING_CONFIGS_CACHE_SOURCE = _ProviderConfigurationCacheSourceSpec(
|
||||
name=ProviderConfigurationCacheSource.PROVIDER_LOAD_BALANCING_CONFIGS,
|
||||
entry_cls=_LoadBalancingModelConfigCacheEntry,
|
||||
load_records=_load_provider_load_balancing_config_cache_entries,
|
||||
)
|
||||
|
||||
|
||||
class ProviderManager:
|
||||
@@ -98,6 +590,14 @@ class ProviderManager:
|
||||
|
||||
self._configurations_cache.pop(tenant_id, None)
|
||||
|
||||
@staticmethod
|
||||
def invalidate_configurations_cache(
|
||||
tenant_id: str,
|
||||
sources: Sequence[ProviderConfigurationCacheSource] | None = None,
|
||||
) -> None:
|
||||
"""Invalidate cross-process provider configuration source cache for a tenant."""
|
||||
_ProviderConfigurationSourceCache.invalidate_tenant(tenant_id, sources=sources)
|
||||
|
||||
def get_configurations(self, tenant_id: str) -> ProviderConfigurations:
|
||||
"""
|
||||
Get model provider configurations.
|
||||
@@ -192,6 +692,9 @@ class ProviderManager:
|
||||
# Get All provider model credentials
|
||||
provider_name_to_provider_model_credentials_dict = self._get_all_provider_model_credentials(tenant_id)
|
||||
|
||||
# Get All provider credentials
|
||||
provider_name_to_provider_credentials_dict = self._get_all_provider_credentials(tenant_id)
|
||||
|
||||
provider_configurations = ProviderConfigurations(tenant_id=tenant_id)
|
||||
|
||||
# Construct ProviderConfiguration objects for each provider
|
||||
@@ -224,7 +727,12 @@ class ProviderManager:
|
||||
|
||||
# Convert to custom configuration
|
||||
custom_configuration = self._to_custom_configuration(
|
||||
tenant_id, provider_entity, provider_records, provider_model_records, provider_model_credentials
|
||||
tenant_id,
|
||||
provider_entity,
|
||||
provider_records,
|
||||
provider_model_records,
|
||||
provider_model_credentials,
|
||||
provider_name_to_provider_credentials_dict,
|
||||
)
|
||||
|
||||
# Convert to system configuration
|
||||
@@ -448,84 +956,115 @@ class ProviderManager:
|
||||
provider_name_to_provider_records_dict = defaultdict(list)
|
||||
with session_factory.create_session() as session:
|
||||
stmt = select(Provider).where(Provider.tenant_id == tenant_id, Provider.is_valid == True)
|
||||
providers = session.scalars(stmt)
|
||||
providers = list(session.scalars(stmt))
|
||||
_attach_active_credentials(
|
||||
session=session,
|
||||
records=providers,
|
||||
credential_model_cls=ProviderCredential,
|
||||
)
|
||||
for provider in providers:
|
||||
# Use provider name with prefix after the data migration
|
||||
provider_name_to_provider_records_dict[str(ModelProviderID(provider.provider_name))].append(provider)
|
||||
return provider_name_to_provider_records_dict
|
||||
|
||||
@staticmethod
|
||||
def _get_all_provider_models(tenant_id: str) -> dict[str, list[ProviderModel]]:
|
||||
def _get_all_provider_models(tenant_id: str) -> dict[str, list[_ProviderModelCacheEntry]]:
|
||||
"""
|
||||
Get all provider model records of the workspace.
|
||||
|
||||
:param tenant_id: workspace id
|
||||
:return:
|
||||
"""
|
||||
provider_models = _get_cached_or_load_records(
|
||||
tenant_id=tenant_id,
|
||||
cache_source=_PROVIDER_MODELS_CACHE_SOURCE,
|
||||
)
|
||||
|
||||
provider_name_to_provider_model_records_dict = defaultdict(list)
|
||||
with session_factory.create_session() as session:
|
||||
stmt = select(ProviderModel).where(ProviderModel.tenant_id == tenant_id, ProviderModel.is_valid == True)
|
||||
provider_models = session.scalars(stmt)
|
||||
for provider_model in provider_models:
|
||||
provider_name_to_provider_model_records_dict[provider_model.provider_name].append(provider_model)
|
||||
for provider_model in provider_models:
|
||||
provider_name_to_provider_model_records_dict[provider_model.provider_name].append(provider_model)
|
||||
return provider_name_to_provider_model_records_dict
|
||||
|
||||
@staticmethod
|
||||
def _get_all_preferred_model_providers(tenant_id: str) -> dict[str, TenantPreferredModelProvider]:
|
||||
def _get_all_preferred_model_providers(tenant_id: str) -> dict[str, _TenantPreferredModelProviderCacheEntry]:
|
||||
"""
|
||||
Get All preferred provider types of the workspace.
|
||||
|
||||
:param tenant_id: workspace id
|
||||
:return:
|
||||
"""
|
||||
provider_name_to_preferred_provider_type_records_dict = {}
|
||||
with session_factory.create_session() as session:
|
||||
stmt = select(TenantPreferredModelProvider).where(TenantPreferredModelProvider.tenant_id == tenant_id)
|
||||
preferred_provider_types = session.scalars(stmt)
|
||||
provider_name_to_preferred_provider_type_records_dict = {
|
||||
preferred_provider_type.provider_name: preferred_provider_type
|
||||
for preferred_provider_type in preferred_provider_types
|
||||
}
|
||||
return provider_name_to_preferred_provider_type_records_dict
|
||||
preferred_provider_types = _get_cached_or_load_records(
|
||||
tenant_id=tenant_id,
|
||||
cache_source=_PREFERRED_MODEL_PROVIDERS_CACHE_SOURCE,
|
||||
)
|
||||
|
||||
return {
|
||||
preferred_provider_type.provider_name: preferred_provider_type
|
||||
for preferred_provider_type in preferred_provider_types
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _get_all_provider_model_settings(tenant_id: str) -> dict[str, list[ProviderModelSetting]]:
|
||||
def _get_all_provider_model_settings(tenant_id: str) -> dict[str, list[_ProviderModelSettingCacheEntry]]:
|
||||
"""
|
||||
Get All provider model settings of the workspace.
|
||||
|
||||
:param tenant_id: workspace id
|
||||
:return:
|
||||
"""
|
||||
provider_model_settings = _get_cached_or_load_records(
|
||||
tenant_id=tenant_id,
|
||||
cache_source=_PROVIDER_MODEL_SETTINGS_CACHE_SOURCE,
|
||||
)
|
||||
|
||||
provider_name_to_provider_model_settings_dict = defaultdict(list)
|
||||
with session_factory.create_session() as session:
|
||||
stmt = select(ProviderModelSetting).where(ProviderModelSetting.tenant_id == tenant_id)
|
||||
provider_model_settings = session.scalars(stmt)
|
||||
for provider_model_setting in provider_model_settings:
|
||||
provider_name_to_provider_model_settings_dict[provider_model_setting.provider_name].append(
|
||||
provider_model_setting
|
||||
)
|
||||
for provider_model_setting in provider_model_settings:
|
||||
provider_name_to_provider_model_settings_dict[provider_model_setting.provider_name].append(
|
||||
provider_model_setting
|
||||
)
|
||||
return provider_name_to_provider_model_settings_dict
|
||||
|
||||
@staticmethod
|
||||
def _get_all_provider_model_credentials(tenant_id: str) -> dict[str, list[ProviderModelCredential]]:
|
||||
def _get_all_provider_model_credentials(tenant_id: str) -> dict[str, list[_ProviderModelCredentialCacheEntry]]:
|
||||
"""
|
||||
Get All provider model credentials of the workspace.
|
||||
|
||||
:param tenant_id: workspace id
|
||||
:return:
|
||||
"""
|
||||
provider_model_credentials = _get_cached_or_load_records(
|
||||
tenant_id=tenant_id,
|
||||
cache_source=_PROVIDER_MODEL_CREDENTIALS_CACHE_SOURCE,
|
||||
)
|
||||
|
||||
provider_name_to_provider_model_credentials_dict = defaultdict(list)
|
||||
with session_factory.create_session() as session:
|
||||
stmt = select(ProviderModelCredential).where(ProviderModelCredential.tenant_id == tenant_id)
|
||||
provider_model_credentials = session.scalars(stmt)
|
||||
for provider_model_credential in provider_model_credentials:
|
||||
provider_name_to_provider_model_credentials_dict[provider_model_credential.provider_name].append(
|
||||
provider_model_credential
|
||||
)
|
||||
for provider_model_credential in provider_model_credentials:
|
||||
provider_name_to_provider_model_credentials_dict[provider_model_credential.provider_name].append(
|
||||
provider_model_credential
|
||||
)
|
||||
return provider_name_to_provider_model_credentials_dict
|
||||
|
||||
@staticmethod
|
||||
def _get_all_provider_load_balancing_configs(tenant_id: str) -> dict[str, list[LoadBalancingModelConfig]]:
|
||||
def _get_all_provider_credentials(tenant_id: str) -> dict[str, list[_ProviderCredentialCacheEntry]]:
|
||||
"""
|
||||
Get All provider credentials of the workspace.
|
||||
|
||||
:param tenant_id: workspace id
|
||||
:return:
|
||||
"""
|
||||
provider_credentials = _get_cached_or_load_records(
|
||||
tenant_id=tenant_id,
|
||||
cache_source=_PROVIDER_CREDENTIALS_CACHE_SOURCE,
|
||||
)
|
||||
|
||||
provider_name_to_provider_credentials_dict = defaultdict(list)
|
||||
for provider_credential in provider_credentials:
|
||||
provider_name_to_provider_credentials_dict[provider_credential.provider_name].append(provider_credential)
|
||||
return provider_name_to_provider_credentials_dict
|
||||
|
||||
@staticmethod
|
||||
def _get_all_provider_load_balancing_configs(
|
||||
tenant_id: str,
|
||||
) -> dict[str, list[_LoadBalancingModelConfigCacheEntry]]:
|
||||
"""
|
||||
Get All provider load balancing configs of the workspace.
|
||||
|
||||
@@ -546,14 +1085,16 @@ class ProviderManager:
|
||||
if not model_load_balancing_enabled:
|
||||
return {}
|
||||
|
||||
provider_load_balancing_configs = _get_cached_or_load_records(
|
||||
tenant_id=tenant_id,
|
||||
cache_source=_PROVIDER_LOAD_BALANCING_CONFIGS_CACHE_SOURCE,
|
||||
)
|
||||
|
||||
provider_name_to_provider_load_balancing_model_configs_dict = defaultdict(list)
|
||||
with session_factory.create_session() as session:
|
||||
stmt = select(LoadBalancingModelConfig).where(LoadBalancingModelConfig.tenant_id == tenant_id)
|
||||
provider_load_balancing_configs = session.scalars(stmt)
|
||||
for provider_load_balancing_config in provider_load_balancing_configs:
|
||||
provider_name_to_provider_load_balancing_model_configs_dict[
|
||||
provider_load_balancing_config.provider_name
|
||||
].append(provider_load_balancing_config)
|
||||
for provider_load_balancing_config in provider_load_balancing_configs:
|
||||
provider_name_to_provider_load_balancing_model_configs_dict[
|
||||
provider_load_balancing_config.provider_name
|
||||
].append(provider_load_balancing_config)
|
||||
|
||||
return provider_name_to_provider_load_balancing_model_configs_dict
|
||||
|
||||
@@ -722,8 +1263,9 @@ class ProviderManager:
|
||||
tenant_id: str,
|
||||
provider_entity: ProviderEntity,
|
||||
provider_records: list[Provider],
|
||||
provider_model_records: list[ProviderModel],
|
||||
provider_model_credentials: list[ProviderModelCredential],
|
||||
provider_model_records: list[_ProviderModelCacheEntry],
|
||||
provider_model_credentials: list[_ProviderModelCredentialCacheEntry],
|
||||
provider_credentials_by_name: dict[str, list[_ProviderCredentialCacheEntry]],
|
||||
) -> CustomConfiguration:
|
||||
"""
|
||||
Convert to custom configuration.
|
||||
@@ -736,7 +1278,10 @@ class ProviderManager:
|
||||
"""
|
||||
# Get custom provider configuration
|
||||
custom_provider_configuration = self._get_custom_provider_configuration(
|
||||
tenant_id, provider_entity, provider_records
|
||||
tenant_id,
|
||||
provider_entity,
|
||||
provider_records,
|
||||
provider_credentials_by_name,
|
||||
)
|
||||
|
||||
# Get custom models which have not been added to the model list yet
|
||||
@@ -758,7 +1303,11 @@ class ProviderManager:
|
||||
)
|
||||
|
||||
def _get_custom_provider_configuration(
|
||||
self, tenant_id: str, provider_entity: ProviderEntity, provider_records: list[Provider]
|
||||
self,
|
||||
tenant_id: str,
|
||||
provider_entity: ProviderEntity,
|
||||
provider_records: list[Provider],
|
||||
provider_credentials_by_name: dict[str, list[_ProviderCredentialCacheEntry]],
|
||||
) -> CustomProviderConfiguration | None:
|
||||
"""Get custom provider configuration."""
|
||||
# Find custom provider record (non-system)
|
||||
@@ -790,13 +1339,29 @@ class ProviderManager:
|
||||
credentials=provider_credentials,
|
||||
current_credential_name=custom_provider_record.credential_name,
|
||||
current_credential_id=custom_provider_record.credential_id,
|
||||
available_credentials=self.get_provider_available_credentials(
|
||||
tenant_id, custom_provider_record.provider_name
|
||||
available_credentials=self._get_provider_available_credentials_from_records(
|
||||
custom_provider_record.provider_name,
|
||||
provider_credentials_by_name,
|
||||
),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _get_provider_available_credentials_from_records(
|
||||
provider_name: str,
|
||||
provider_credentials_by_name: dict[str, list[_ProviderCredentialCacheEntry]],
|
||||
) -> list[CredentialConfiguration]:
|
||||
available_credentials: list[CredentialConfiguration] = []
|
||||
for candidate_provider_name in ProviderManager._get_provider_names(provider_name):
|
||||
available_credentials.extend(
|
||||
CredentialConfiguration(credential_id=credential.id, credential_name=credential.credential_name)
|
||||
for credential in provider_credentials_by_name.get(candidate_provider_name, [])
|
||||
)
|
||||
return available_credentials
|
||||
|
||||
def _get_can_added_models(
|
||||
self, provider_model_records: list[ProviderModel], all_model_credentials: Sequence[ProviderModelCredential]
|
||||
self,
|
||||
provider_model_records: list[_ProviderModelCacheEntry],
|
||||
all_model_credentials: Sequence[_ProviderModelCredentialCacheEntry],
|
||||
) -> list[dict]:
|
||||
"""Get the custom models and credentials from enterprise version which haven't add to the model list"""
|
||||
existing_model_set = {(record.model_name, record.model_type) for record in provider_model_records}
|
||||
@@ -829,9 +1394,9 @@ class ProviderManager:
|
||||
self,
|
||||
tenant_id: str,
|
||||
provider_entity: ProviderEntity,
|
||||
provider_model_records: list[ProviderModel],
|
||||
provider_model_records: list[_ProviderModelCacheEntry],
|
||||
can_added_models: list[dict],
|
||||
all_model_credentials: Sequence[ProviderModelCredential],
|
||||
all_model_credentials: Sequence[_ProviderModelCredentialCacheEntry],
|
||||
) -> list[CustomModelConfiguration]:
|
||||
"""Get custom model configurations."""
|
||||
# Get model credential secret variables
|
||||
@@ -1151,8 +1716,8 @@ class ProviderManager:
|
||||
def _to_model_settings(
|
||||
self,
|
||||
provider_entity: ProviderEntity,
|
||||
provider_model_settings: list[ProviderModelSetting] | None = None,
|
||||
load_balancing_model_configs: list[LoadBalancingModelConfig] | None = None,
|
||||
provider_model_settings: list[_ProviderModelSettingCacheEntry] | None = None,
|
||||
load_balancing_model_configs: list[_LoadBalancingModelConfigCacheEntry] | None = None,
|
||||
) -> list[ModelSettings]:
|
||||
"""
|
||||
Convert to model settings.
|
||||
|
||||
@@ -226,7 +226,7 @@ class ParagraphIndexProcessor(BaseIndexProcessor):
|
||||
all_multimodal_documents.append(file_document)
|
||||
doc.attachments = attachments
|
||||
else:
|
||||
account = AccountService.load_user(document.created_by)
|
||||
account = AccountService.load_user(document.created_by, db.session)
|
||||
if not account:
|
||||
raise ValueError("Invalid account")
|
||||
doc.attachments = self._get_content_files(doc, current_user=account)
|
||||
|
||||
@@ -291,7 +291,7 @@ class ParentChildIndexProcessor(BaseIndexProcessor):
|
||||
attachments.append(file_document)
|
||||
doc.attachments = attachments
|
||||
else:
|
||||
account = AccountService.load_user(document.created_by)
|
||||
account = AccountService.load_user(document.created_by, db.session)
|
||||
if not account:
|
||||
raise ValueError("Invalid account")
|
||||
doc.attachments = self._get_content_files(doc, current_user=account)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user